From 36d0bd25e5e5a6436d8d7e65073e4ab44c9c53b3 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 10 Sep 2026 22:41:05 -0700 Subject: [PATCH 1/3] feat(mcp)!: accept host implementation and logger options mcpserver.New now requires Options as a third argument so hosts can advertise application identity and supply a slog logger. A nil Implementation keeps Name "codemode" and Version "2". --- CHANGELOG.md | 11 ++++++ README.md | 2 +- docs/docs/reference/public-api.md | 13 ++++++-- docs/docs/tutorials/first-server.md | 2 +- mcpserver/e2e_test.go | 10 +++--- mcpserver/example_test.go | 2 +- mcpserver/server.go | 26 ++++++++++++--- mcpserver/server_test.go | 52 +++++++++++++++++++++++++++-- 8 files changed, 102 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2271c78..2bd1ca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 330caf6..7a03044 100644 --- a/README.md +++ b/README.md @@ -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) } diff --git a/docs/docs/reference/public-api.md b/docs/docs/reference/public-api.md index 2e418bb..17690f0 100644 --- a/docs/docs/reference/public-api.md +++ b/docs/docs/reference/public-api.md @@ -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. diff --git a/docs/docs/tutorials/first-server.md b/docs/docs/tutorials/first-server.md index 731a0ce..626bf0a 100644 --- a/docs/docs/tutorials/first-server.md +++ b/docs/docs/tutorials/first-server.md @@ -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) } diff --git a/mcpserver/e2e_test.go b/mcpserver/e2e_test.go index ee2142b..445a882 100644 --- a/mcpserver/e2e_test.go +++ b/mcpserver/e2e_test.go @@ -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{ @@ -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{}) @@ -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{ @@ -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 ( @@ -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{ @@ -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{ diff --git a/mcpserver/example_test.go b/mcpserver/example_test.go index 8491135..8292a17 100644 --- a/mcpserver/example_test.go +++ b/mcpserver/example_test.go @@ -55,7 +55,7 @@ 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) } diff --git a/mcpserver/server.go b/mcpserver/server.go index c6a6275..f4ff185 100644 --- a/mcpserver/server.go +++ b/mcpserver/server.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "reflect" "github.com/google/jsonschema-go/jsonschema" @@ -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. @@ -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) } @@ -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.", diff --git a/mcpserver/server_test.go b/mcpserver/server_test.go index ee1e226..aba5ace 100644 --- a/mcpserver/server_test.go +++ b/mcpserver/server_test.go @@ -1,10 +1,12 @@ package mcpserver_test import ( + "bytes" "context" "encoding/json" "errors" "fmt" + "log/slog" "sync/atomic" "testing" @@ -57,7 +59,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) @@ -132,6 +134,35 @@ 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) +} + +// TestNewEmitsLoggerDiagnostics proves a host logger receives SDK session diagnostics. +func TestNewEmitsLoggerDiagnostics(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, nil)) + _ = connectTestSession(t, mocks.NewMockService(t), mocks.NewMockInvocationResolver(t), mcpserver.Options{ + Logger: logger, + }) + + require.Contains(t, buf.String(), "server session connected") +} + // TestSDKRejectsMalformedArgumentsBeforeResolution proves schema validation owns malformed tool input. func TestSDKRejectsMalformedArgumentsBeforeResolution(t *testing.T) { tests := []struct { @@ -720,7 +751,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) @@ -737,7 +780,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} } From 996c3cf37377c4a94966e3d2e86043216f6fbf0c Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 10 Sep 2026 22:42:13 -0700 Subject: [PATCH 2/3] test(mcp): avoid pinning SDK diagnostic wording --- mcpserver/server_test.go | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/mcpserver/server_test.go b/mcpserver/server_test.go index aba5ace..e41a4d6 100644 --- a/mcpserver/server_test.go +++ b/mcpserver/server_test.go @@ -1,12 +1,10 @@ package mcpserver_test import ( - "bytes" "context" "encoding/json" "errors" "fmt" - "log/slog" "sync/atomic" "testing" @@ -152,17 +150,6 @@ func TestNewAdvertisesCustomImplementation(t *testing.T) { assert.Equal(t, "9", initialized.ServerInfo.Version) } -// TestNewEmitsLoggerDiagnostics proves a host logger receives SDK session diagnostics. -func TestNewEmitsLoggerDiagnostics(t *testing.T) { - var buf bytes.Buffer - logger := slog.New(slog.NewJSONHandler(&buf, nil)) - _ = connectTestSession(t, mocks.NewMockService(t), mocks.NewMockInvocationResolver(t), mcpserver.Options{ - Logger: logger, - }) - - require.Contains(t, buf.String(), "server session connected") -} - // TestSDKRejectsMalformedArgumentsBeforeResolution proves schema validation owns malformed tool input. func TestSDKRejectsMalformedArgumentsBeforeResolution(t *testing.T) { tests := []struct { From 6339db28a775491f36de2774b29a56afa01a7332 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 10 Sep 2026 22:47:34 -0700 Subject: [PATCH 3/3] style(mcp): format host options example --- mcpserver/example_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mcpserver/example_test.go b/mcpserver/example_test.go index 8292a17..5b4d1dd 100644 --- a/mcpserver/example_test.go +++ b/mcpserver/example_test.go @@ -55,7 +55,11 @@ func Example_officialTransport() { panic(err) } - mcpServer, err := mcpserver.New(root, mcpserver.StaticSubject(authz.Subject{ID: "example-user"}), mcpserver.Options{}) + mcpServer, err := mcpserver.New( + root, + mcpserver.StaticSubject(authz.Subject{ID: "example-user"}), + mcpserver.Options{}, + ) if err != nil { panic(err) }