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..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, 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..e41a4d6 100644 --- a/mcpserver/server_test.go +++ b/mcpserver/server_test.go @@ -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) @@ -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 { @@ -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) @@ -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} }