Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## [Unreleased]


### ⚠ BREAKING CHANGES

* **mcp:** `mcpserver.New` requires an `Options` argument.

### Features

* **mcp:** let hosts set MCP implementation identity and a slog logger

## 0.1.0 (2026-08-26)


Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func main() {

// StaticSubject suits a single-user stdio server; multi-user hosts
// resolve each authenticated request with ContextSubject.
srv, err := mcpserver.New(server, mcpserver.StaticSubject(authz.Subject{ID: "local"}))
srv, err := mcpserver.New(server, mcpserver.StaticSubject(authz.Subject{ID: "local"}), mcpserver.Options{})
if err != nil {
log.Fatal(err)
}
Expand Down
13 changes: 11 additions & 2 deletions docs/docs/reference/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -633,12 +633,21 @@ boundary, such as a local stdio server. Multi-user hosts must not use it.
authentication middleware remains responsible for validating credentials and
installing a subject for each request.

### `Options`

`Options` configures adapter construction:

| Field | Contract |
| --- | --- |
| `Implementation *mcp.Implementation` | MCP application identity advertised to clients. Nil retains Name `codemode` and Version `2`. A non-nil value is borrowed and passed to the SDK without copying or validating fields. |
| `Logger *slog.Logger` | Optional slog logger for SDK server diagnostics. Nil selects the SDK default logger, which discards records. |

### `New`

```text
New(service Service, resolver InvocationResolver) (*mcp.Server, error)
New(service Service, resolver InvocationResolver, options Options) (*mcp.Server, error)
```

`New` rejects nil and typed-nil dependencies with `ErrInvalidRegistration`. The returned official SDK server exposes exactly `search_api`, `describe_api`, and `execute`. It does not own authentication, transport creation, listeners, cancellation, or shutdown. The host connects the returned server to an official MCP transport and owns that lifecycle.
`New` rejects nil and typed-nil Service and InvocationResolver with `ErrInvalidRegistration`. Pass `Options{}` to keep the library identity. The returned official SDK server exposes exactly `search_api`, `describe_api`, and `execute`. It does not own authentication, transport creation, listeners, cancellation, or shutdown. The host connects the returned server to an official MCP transport and owns that lifecycle.

See [MCP tool reference](mcp-tools.md) for the wire contracts and [Understanding CodeMode's security model](../explanation/security-model.md) for the trust boundary.
2 changes: 1 addition & 1 deletion docs/docs/tutorials/first-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func main() {
log.Fatal(err)
}

srv, err := mcpserver.New(server, mcpserver.StaticSubject(authz.Subject{ID: "local"}))
srv, err := mcpserver.New(server, mcpserver.StaticSubject(authz.Subject{ID: "local"}), mcpserver.Options{})
if err != nil {
log.Fatal(err)
}
Expand Down
10 changes: 6 additions & 4 deletions mcpserver/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ func TestActualMCPSecureLoop(t *testing.T) {
root, err := builder.Build()
require.NoError(t, err)

mcpServer, err := mcpserver.New(root, contextResolver{})
mcpServer, err := mcpserver.New(root, contextResolver{}, mcpserver.Options{})
require.NoError(t, err)

trustedCtx := withInvocationIdentity(t.Context(), invocationIdentity{
Expand All @@ -344,6 +344,7 @@ func TestActualMCPSecureLoop(t *testing.T) {
initialized := session.InitializeResult()
require.NotNil(t, initialized)
require.NotNil(t, initialized.ServerInfo)
assert.Equal(t, "codemode", initialized.ServerInfo.Name)
assert.Equal(t, "2", initialized.ServerInfo.Version)

listed, err := session.ListTools(t.Context(), &mcp.ListToolsParams{})
Expand Down Expand Up @@ -538,7 +539,7 @@ func TestActualMCPCompositeProgram(t *testing.T) {
root, err := builder.Build()
require.NoError(t, err)

mcpServer, err := mcpserver.New(root, contextResolver{})
mcpServer, err := mcpserver.New(root, contextResolver{}, mcpserver.Options{})
require.NoError(t, err)

trustedCtx := withInvocationIdentity(t.Context(), invocationIdentity{
Expand All @@ -557,6 +558,7 @@ func TestActualMCPCompositeProgram(t *testing.T) {
initialized := session.InitializeResult()
require.NotNil(t, initialized)
require.NotNil(t, initialized.ServerInfo)
assert.Equal(t, "codemode", initialized.ServerInfo.Name)
assert.Equal(t, "2", initialized.ServerInfo.Version)

const (
Expand Down Expand Up @@ -668,7 +670,7 @@ func TestActualMCPPureComputeProgram(t *testing.T) {
root, err := builder.Build()
require.NoError(t, err)

mcpServer, err := mcpserver.New(root, contextResolver{})
mcpServer, err := mcpserver.New(root, contextResolver{}, mcpserver.Options{})
require.NoError(t, err)

trustedCtx := withInvocationIdentity(t.Context(), invocationIdentity{
Expand Down Expand Up @@ -726,7 +728,7 @@ func TestActualMCPModelDerivedDiagnostics(t *testing.T) {
root, err := builder.Build()
require.NoError(t, err)

mcpServer, err := mcpserver.New(root, contextResolver{})
mcpServer, err := mcpserver.New(root, contextResolver{}, mcpserver.Options{})
require.NoError(t, err)

trustedCtx := withInvocationIdentity(t.Context(), invocationIdentity{
Expand Down
6 changes: 5 additions & 1 deletion mcpserver/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ func Example_officialTransport() {
panic(err)
}

mcpServer, err := mcpserver.New(root, mcpserver.StaticSubject(authz.Subject{ID: "example-user"}))
mcpServer, err := mcpserver.New(
root,
mcpserver.StaticSubject(authz.Subject{ID: "example-user"}),
mcpserver.Options{},
)
if err != nil {
panic(err)
}
Expand Down
26 changes: 22 additions & 4 deletions mcpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"reflect"

"github.com/google/jsonschema-go/jsonschema"
Expand Down Expand Up @@ -47,6 +48,17 @@ type operationResult[Value any] struct {
err error
}

// Options configures official MCP server construction.
type Options struct {
// Implementation is the MCP application identity advertised to clients.
// Nil retains Name "codemode" and Version "2". A non-nil value is borrowed
// and passed to the SDK without copying or validating fields.
Implementation *mcp.Implementation

// Logger receives SDK server diagnostics. Nil selects the SDK default logger.
Logger *slog.Logger
}

// adapter binds a Service and InvocationResolver to the three official MCP tools.
type adapter struct {
// service is the required CodeMode application port.
Expand All @@ -58,9 +70,11 @@ type adapter struct {

// New constructs an official MCP server that exposes exactly search_api, describe_api, and execute.
//
// New rejects a nil or typed-nil Service or InvocationResolver. The returned server has no
// generic downstream MCP forwarding path. Client request metadata is untrusted and ignored.
func New(service Service, resolver InvocationResolver) (*mcp.Server, error) {
// New rejects a nil or typed-nil Service or InvocationResolver. A nil Implementation
// retains the library identity Name "codemode" and Version "2". Logger is optional.
// The returned server has no generic downstream MCP forwarding path. Client request
// metadata is untrusted and ignored.
func New(service Service, resolver InvocationResolver, options Options) (*mcp.Server, error) {
if isNil(service) {
return nil, fmt.Errorf("%w: service is required", codemode.ErrInvalidRegistration)
}
Expand Down Expand Up @@ -114,7 +128,11 @@ func New(service Service, resolver InvocationResolver) (*mcp.Server, error) {
}

bound := &adapter{service: service, resolver: resolver}
server := mcp.NewServer(&mcp.Implementation{Name: "codemode", Version: "2"}, nil)
implementation := options.Implementation
if implementation == nil {
implementation = &mcp.Implementation{Name: "codemode", Version: "2"}
}
server := mcp.NewServer(implementation, &mcp.ServerOptions{Logger: options.Logger})
mcp.AddTool(server, &mcp.Tool{
Name: "search_api",
Description: "Search enabled capabilities using task, resource, or exact-name vocabulary. Results are relevance-ranked. Pass the exact returned name to describe_api. If truncated is true and no result fits, submit a more specific task/resource query.",
Expand Down
39 changes: 36 additions & 3 deletions mcpserver/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func TestNewRejectsMissingDependencies(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server, err := mcpserver.New(tt.service, tt.resolver)
server, err := mcpserver.New(tt.service, tt.resolver, mcpserver.Options{})

require.ErrorIs(t, err, codemode.ErrInvalidRegistration)
assert.Nil(t, server)
Expand Down Expand Up @@ -132,6 +132,24 @@ func TestNewRegistersExactlyThreeTools(t *testing.T) {
}
}

// TestNewAdvertisesCustomImplementation proves initialize reports host-supplied application identity.
func TestNewAdvertisesCustomImplementation(t *testing.T) {
session := connectTestSession(t, mocks.NewMockService(t), mocks.NewMockInvocationResolver(t), mcpserver.Options{
Implementation: &mcp.Implementation{
Name: "inventory",
Title: "Inventory server",
Version: "9",
},
})

initialized := session.client.InitializeResult()
require.NotNil(t, initialized)
require.NotNil(t, initialized.ServerInfo)
assert.Equal(t, "inventory", initialized.ServerInfo.Name)
assert.Equal(t, "Inventory server", initialized.ServerInfo.Title)
assert.Equal(t, "9", initialized.ServerInfo.Version)
}

// TestSDKRejectsMalformedArgumentsBeforeResolution proves schema validation owns malformed tool input.
func TestSDKRejectsMalformedArgumentsBeforeResolution(t *testing.T) {
tests := []struct {
Expand Down Expand Up @@ -720,7 +738,19 @@ type testSession struct {
func newTestSession(t *testing.T, service mcpserver.Service, resolver mcpserver.InvocationResolver) *testSession {
t.Helper()

server, err := mcpserver.New(service, resolver)
return connectTestSession(t, service, resolver, mcpserver.Options{})
}

// connectTestSession connects an official client to New with the supplied adapter options.
func connectTestSession(
t *testing.T,
service mcpserver.Service,
resolver mcpserver.InvocationResolver,
options mcpserver.Options,
) *testSession {
t.Helper()

server, err := mcpserver.New(service, resolver, options)
require.NoError(t, err)
serverTransport, clientTransport := mcp.NewInMemoryTransports()
serverSession, err := server.Connect(t.Context(), serverTransport, nil)
Expand All @@ -737,7 +767,10 @@ func newTestSession(t *testing.T, service mcpserver.Service, resolver mcpserver.
initialized := clientSession.InitializeResult()
require.NotNil(t, initialized)
require.NotNil(t, initialized.ServerInfo)
assert.Equal(t, "2", initialized.ServerInfo.Version)
if options.Implementation == nil {
assert.Equal(t, "codemode", initialized.ServerInfo.Name)
assert.Equal(t, "2", initialized.ServerInfo.Version)
}
return &testSession{client: clientSession}
}

Expand Down