diff --git a/.mockery.yml b/.mockery.yml index 13b0083..a59f0df 100644 --- a/.mockery.yml +++ b/.mockery.yml @@ -1,6 +1,6 @@ all: false dir: '{{.InterfaceDir}}/mocks' -filename: authorizer.go +filename: '{{.InterfaceName | snakecase}}.go' formatter: goimports generate: true include-auto-generated: false @@ -14,3 +14,7 @@ packages: github.com/meigma/codemode/authz: interfaces: Authorizer: + github.com/meigma/codemode/mcpserver: + interfaces: + Service: + InvocationResolver: diff --git a/docs/docs/index.md b/docs/docs/index.md index 2ffa773..e7fe8dd 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -1,16 +1,32 @@ --- -title: template-go Docs +title: CodeMode slug: / -description: Starting point for Meigma Go projects. +description: Build MCP servers that expose bounded Go capabilities through Starlark. --- -# template-go Docs +# CodeMode -This repository is the starting point for Meigma Go projects. +CodeMode is a Go library for building code-native Model Context Protocol (MCP) servers. An application registers typed Go capabilities, selects the capabilities enabled for one deployment, and exposes them through a restricted Starlark runtime. -Generated projects should replace this page with project-specific documentation after bootstrapping: +## MCP boundary -- project summary -- quick start -- operating notes -- support and security paths +The `mcpserver` package exposes exactly three tools: + +| Tool | Input | Output | +| --- | --- | --- | +| `search_api` | A bounded query string | Enabled capability names, signatures, and summaries | +| `describe_api` | One exact capability name | Its signature, description, and supported input and output fields | +| `execute` | One bounded Starlark program | `{"result":
}` | + +Each tool call resolves an authenticated subject from trusted Go context before it reaches the CodeMode service. Tool arguments and MCP `_meta` cannot provide or replace identity, credentials, execution budgets, modules, or capability allow-lists. + +`execute` creates a fresh interpreter for each call. The program must define a zero-argument `main()` function. Only the value returned by `main()` crosses the MCP boundary; printed text and intermediate values do not. + +## Integration outline + +1. Build an immutable `codemode.Server` from typed capabilities and deployment options. +2. Implement `authz.Authorizer` for each native capability call. +3. Implement `mcpserver.InvocationResolver` to read the authenticated subject from host-owned typed context. +4. Call `mcpserver.New` and connect the returned official MCP SDK server to the transport owned by the host application. + +Disabled capabilities are absent from search, description, and execution. Authorization denial stops the native handler from running. Public tool errors use coarse classifications and do not include source text, arguments, credentials, policy details, stack traces, or handler results. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index dbe8ab6..4589f12 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,8 +1,8 @@ -site_name: template-go -site_description: Meigma Go repository template -site_url: https://meigma.github.io/template-go/ -repo_name: meigma/template-go -repo_url: https://github.com/meigma/template-go +site_name: CodeMode +site_description: Code-native MCP servers with bounded Go capabilities +site_url: https://meigma.github.io/codemode/ +repo_name: meigma/codemode +repo_url: https://github.com/meigma/codemode edit_uri: edit/master/docs/docs/ docs_dir: docs site_dir: build diff --git a/docs/moon.yml b/docs/moon.yml index 3b7ad95..068200b 100644 --- a/docs/moon.yml +++ b/docs/moon.yml @@ -7,8 +7,8 @@ tags: - 'uv' project: - title: 'template-go docs' - description: 'MkDocs documentation site for the Meigma Go repository template.' + title: 'CodeMode docs' + description: 'MkDocs documentation for the CodeMode Go library.' owner: 'meigma' maintainers: - 'meigma' diff --git a/go.mod b/go.mod index 1a35ef5..d73cf8f 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,20 @@ module github.com/meigma/codemode go 1.26.6 require ( + github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/stretchr/testify v1.12.1 go.starlark.net v0.0.0-20260708150628-5395d018f003 ) require ( + github.com/google/jsonschema-go v0.4.3 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/stretchr/objx v0.5.3 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect + golang.org/x/time v0.15.0 // indirect ) diff --git a/go.sum b/go.sum index 64966d2..a059781 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,34 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.starlark.net v0.0.0-20260708150628-5395d018f003 h1:cAxcqHgW8fnmT0cEBU3TzvVYHIFt8IIGDMWUF6rImk4= go.starlark.net v0.0.0-20260708150628-5395d018f003/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/mcpserver/doc.go b/mcpserver/doc.go new file mode 100644 index 0000000..528eb30 --- /dev/null +++ b/mcpserver/doc.go @@ -0,0 +1,6 @@ +// Package mcpserver exposes CodeMode as exactly three official MCP tools. +// +// The adapter resolves a trusted subject from host-owned Go context before every +// operation, ignores untrusted client metadata, and projects failures to stable +// coarse tool text. It does not proxy arbitrary downstream MCP tools. +package mcpserver diff --git a/mcpserver/e2e_test.go b/mcpserver/e2e_test.go new file mode 100644 index 0000000..1715176 --- /dev/null +++ b/mcpserver/e2e_test.go @@ -0,0 +1,424 @@ +package mcpserver_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "strconv" + "sync" + "sync/atomic" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/codemode" + "github.com/meigma/codemode/authz" + "github.com/meigma/codemode/mcpserver" +) + +const ( + // trustedSubjectID is the authenticated identity installed only in server context. + trustedSubjectID authz.SubjectID = "subject-trusted" + + // attackerSubjectID is a misleading identity supplied only in untrusted client metadata. + attackerSubjectID authz.SubjectID = "subject-attacker" + + // credentialCanary is a unique secret that must never appear in client-visible MCP payloads. + credentialCanary = "credential-canary-7f3a91c2e8b64d0f" + + // discardedPrint is top-level printed text that must not escape execute results. + discardedPrint = "print-must-not-escape" + + // discardedIntermediate is a top-level Starlark value that must not escape execute results. + discardedIntermediate = "intermediate-must-not-escape" + + // allowedLookupKey is the enabled lookup argument used by the successful program. + allowedLookupKey = "alpha" + + // deniedLookupKey is the enabled lookup argument rejected by the test authorizer. + deniedLookupKey = "forbidden" + + // allowedLookupLimit is the optional integer argument used by the successful program. + allowedLookupLimit int64 = 2 +) + +// invocationContextKey is the typed trusted-context key for e2e identity. +type invocationContextKey struct{} + +// invocationIdentity is the host-owned subject and credential canary. +type invocationIdentity struct { + // Subject is the trusted authenticated caller. + Subject authz.Subject + + // Canary is a unique credential that must never cross MCP. + Canary string +} + +// lookupInput is the enabled records.lookup argument contract. +type lookupInput struct { + // Key is the required record identifier. + Key string `json:"key"` + + // Limit is the optional result bound. + Limit *int64 `json:"limit,omitempty"` +} + +// lookupResult is the deterministic records.lookup handler output. +type lookupResult struct { + // Key is the looked-up record identifier. + Key string `json:"key"` + + // Count is the resolved optional limit. + Count int64 `json:"count"` +} + +// executeEnvelope is the exact structured execute payload. +type executeEnvelope struct { + // Result is main's final converted value. + Result lookupResult `json:"result"` +} + +// contextResolver reads the trusted subject from typed server context. +type contextResolver struct{} + +// Resolve returns the subject installed by the host in trusted context. +func (contextResolver) Resolve(ctx context.Context) (authz.Subject, error) { + identity, ok := invocationIdentityFrom(ctx) + if !ok || identity.Subject.ID == "" { + return authz.Subject{}, codemode.ErrUnauthenticated + } + return identity.Subject, nil +} + +// recordingAuthorizer records canonical authorization inputs and denies one key. +type recordingAuthorizer struct { + // mu protects recorded authorization inputs. + mu sync.Mutex + + // calls is the ordered clone of observed authorization inputs. + calls []authz.AuthorizationInput +} + +// Authorize records one canonical input and denies the reserved lookup key. +func (authorizer *recordingAuthorizer) Authorize(_ context.Context, input authz.AuthorizationInput) error { + authorizer.mu.Lock() + defer authorizer.mu.Unlock() + authorizer.calls = append(authorizer.calls, cloneAuthorizationInput(input)) + if key, _ := input.Arguments["key"].(string); key == deniedLookupKey { + return fmt.Errorf("trusted denial detail: %w", authz.ErrDenied) + } + return nil +} + +// snapshot returns a copy of recorded authorization inputs. +func (authorizer *recordingAuthorizer) snapshot() []authz.AuthorizationInput { + authorizer.mu.Lock() + defer authorizer.mu.Unlock() + return append([]authz.AuthorizationInput(nil), authorizer.calls...) +} + +// lookupRecorder is the enabled capability handler that records trusted dispatch. +type lookupRecorder struct { + // canary is the credential that must remain visible only in trusted context. + canary string + + // mu protects recorded handler observations. + mu sync.Mutex + + // calls is the number of handler dispatches. + calls int + + // subjects are the trusted subjects observed at dispatch. + subjects []authz.Subject + + // inputs are cloned typed arguments observed at dispatch. + inputs []lookupInput + + // sawCanary reports whether every dispatch could read the trusted canary. + sawCanary bool +} + +// invoke records trusted handler inputs and returns a deterministic lookup result. +func (recorder *lookupRecorder) invoke( + ctx context.Context, + subject authz.Subject, + input lookupInput, +) (lookupResult, error) { + recorder.mu.Lock() + defer recorder.mu.Unlock() + recorder.calls++ + recorder.subjects = append(recorder.subjects, subject) + recorder.inputs = append(recorder.inputs, cloneLookupInput(input)) + identity, ok := invocationIdentityFrom(ctx) + recorder.sawCanary = (recorder.calls == 1 || recorder.sawCanary) && + ok && identity.Canary == recorder.canary && identity.Subject.ID == subject.ID + count := int64(0) + if input.Limit != nil { + count = *input.Limit + } + return lookupResult{Key: input.Key, Count: count}, nil +} + +// snapshot returns a copy of recorded handler observations. +func (recorder *lookupRecorder) snapshot() (int, []authz.Subject, []lookupInput, bool) { + recorder.mu.Lock() + defer recorder.mu.Unlock() + return recorder.calls, + append([]authz.Subject(nil), recorder.subjects...), + append([]lookupInput(nil), recorder.inputs...), + recorder.sawCanary +} + +// TestActualMCPSecureLoop proves the official in-memory MCP boundary preserves the secure loop. +func TestActualMCPSecureLoop(t *testing.T) { + authorizer := &recordingAuthorizer{} + lookup := &lookupRecorder{canary: credentialCanary} + var hiddenCalls atomic.Int64 + + builder := codemode.New(codemode.Options{ + Authorizer: authorizer, + DisabledCapabilities: []codemode.CapabilityID{"records.entry.hidden"}, + Limits: codemode.DefaultLimits(), + }) + require.NoError(t, codemode.Register(builder, codemode.Capability[lookupInput, lookupResult]{ + ID: "records.entry.lookup", + Name: "records.lookup", + Summary: "Look up one record by key.", + Description: "Returns one deterministic record for the supplied key.", + Handler: lookup.invoke, + })) + require.NoError(t, codemode.Register(builder, codemode.Capability[lookupInput, lookupResult]{ + ID: "records.entry.hidden", + Name: "records.hidden", + Summary: "Look up one hidden record by key.", + Description: "Must remain absent from every live MCP surface.", + Handler: func(context.Context, authz.Subject, lookupInput) (lookupResult, error) { + hiddenCalls.Add(1) + return lookupResult{}, errors.New("disabled capability invoked") + }, + })) + root, err := builder.Build() + require.NoError(t, err) + + mcpServer, err := mcpserver.New(root, contextResolver{}) + require.NoError(t, err) + + trustedCtx := withInvocationIdentity(t.Context(), invocationIdentity{ + Subject: authz.Subject{ID: trustedSubjectID}, + Canary: credentialCanary, + }) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := mcpServer.Connect(trustedCtx, serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "codemode-e2e", Version: "test"}, nil) + session, err := client.Connect(t.Context(), clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + + listed, err := session.ListTools(t.Context(), &mcp.ListToolsParams{}) + require.NoError(t, err) + assertNoCanary(t, listed) + names := make([]string, 0, len(listed.Tools)) + for _, tool := range listed.Tools { + names = append(names, tool.Name) + } + assert.ElementsMatch(t, []string{"search_api", "describe_api", "execute"}, names) + + searched, err := session.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "search_api", + Arguments: map[string]any{"query": "record"}, + }) + require.NoError(t, err) + assertSuccessfulTool(t, searched) + assertNoCanary(t, searched) + searchResults := decodeStructured[[]codemode.SearchResult](t, searched) + require.Len(t, searchResults, 1) + assert.Equal(t, "records.lookup", searchResults[0].Name) + assert.Equal(t, "records.lookup(*, key: str, limit: int | None) -> lookupResult", searchResults[0].Signature) + assert.Equal(t, "Look up one record by key.", searchResults[0].Summary) + + hidden, err := session.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "describe_api", + Arguments: map[string]any{"name": "records.hidden"}, + }) + require.NoError(t, err) + assertToolError(t, hidden, codemode.ErrNotFound.Error()) + assertNoCanary(t, hidden) + + allowed, err := session.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "execute", + Arguments: map[string]any{ + "source": ` +print("` + discardedPrint + `") +intermediate = "` + discardedIntermediate + `" +def main(): + return records.lookup(key="` + allowedLookupKey + `", limit=` + strconv.FormatInt(allowedLookupLimit, 10) + `) +`, + }, + }) + require.NoError(t, err) + assertSuccessfulTool(t, allowed) + assertNoCanary(t, allowed) + assertNotContainsText(t, allowed, discardedPrint, discardedIntermediate) + wantAllowed := executeEnvelope{Result: lookupResult{Key: allowedLookupKey, Count: allowedLookupLimit}} + assert.Equal(t, wantAllowed, decodeStructured[executeEnvelope](t, allowed)) + assertExactExecuteEnvelope(t, allowed.StructuredContent) + assertMirroredExecuteContent(t, allowed, wantAllowed) + + handlerCalls, subjects, inputs, sawCanary := lookup.snapshot() + require.Equal(t, 1, handlerCalls) + require.Equal(t, []authz.Subject{{ID: trustedSubjectID}}, subjects) + require.Len(t, inputs, 1) + assert.Equal(t, allowedLookupKey, inputs[0].Key) + require.NotNil(t, inputs[0].Limit) + assert.Equal(t, allowedLookupLimit, *inputs[0].Limit) + assert.True(t, sawCanary, "enabled handler must observe the trusted context canary") + + authorizations := authorizer.snapshot() + require.Len(t, authorizations, 1) + assert.Equal(t, authz.Subject{ID: trustedSubjectID}, authorizations[0].Subject) + assert.Equal(t, "records.entry.lookup", authorizations[0].CapabilityID) + assert.Equal(t, "records.lookup", authorizations[0].CapabilityName) + assert.Equal(t, map[string]any{"key": allowedLookupKey, "limit": allowedLookupLimit}, authorizations[0].Arguments) + + denied, err := session.CallTool(t.Context(), &mcp.CallToolParams{ + Meta: mcp.Meta{ + "subject_id": string(attackerSubjectID), + "subject": map[string]any{"id": string(attackerSubjectID)}, + "canary": "forged-canary", + }, + Name: "execute", + Arguments: map[string]any{ + "source": ` +def main(): + return records.lookup(key="` + deniedLookupKey + `", limit=1) +`, + }, + }) + require.NoError(t, err) + assertToolError(t, denied, codemode.ErrPermissionDenied.Error()) + assertNoCanary(t, denied) + + handlerCalls, _, _, _ = lookup.snapshot() + assert.Equal(t, 1, handlerCalls, "denied native calls must not dispatch the handler") + assert.Zero(t, hiddenCalls.Load(), "disabled capabilities must never dispatch") + + authorizations = authorizer.snapshot() + require.Len(t, authorizations, 2) + assert.Equal(t, authz.Subject{ID: trustedSubjectID}, authorizations[1].Subject) + assert.Equal(t, "records.entry.lookup", authorizations[1].CapabilityID) + assert.Equal(t, "records.lookup", authorizations[1].CapabilityName) + assert.Equal(t, map[string]any{"key": deniedLookupKey, "limit": int64(1)}, authorizations[1].Arguments) + assert.NotEqual(t, string(attackerSubjectID), string(authorizations[1].Subject.ID)) +} + +// withInvocationIdentity stores trusted identity on the server-owned context. +func withInvocationIdentity(ctx context.Context, identity invocationIdentity) context.Context { + return context.WithValue(ctx, invocationContextKey{}, identity) +} + +// invocationIdentityFrom reads trusted identity from the server-owned context. +func invocationIdentityFrom(ctx context.Context) (invocationIdentity, bool) { + identity, ok := ctx.Value(invocationContextKey{}).(invocationIdentity) + return identity, ok +} + +// cloneAuthorizationInput copies one authorization input and its canonical argument map. +func cloneAuthorizationInput(input authz.AuthorizationInput) authz.AuthorizationInput { + cloned := input + if input.Arguments != nil { + cloned.Arguments = make(map[string]any, len(input.Arguments)) + maps.Copy(cloned.Arguments, input.Arguments) + } + return cloned +} + +// cloneLookupInput copies one typed lookup input including the optional limit. +func cloneLookupInput(input lookupInput) lookupInput { + cloned := input + if input.Limit != nil { + limit := *input.Limit + cloned.Limit = &limit + } + return cloned +} + +// decodeStructured decodes MCP structured content into a typed value. +func decodeStructured[T any](t *testing.T, result *mcp.CallToolResult) T { + t.Helper() + raw, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + var decoded T + require.NoError(t, json.Unmarshal(raw, &decoded), "structured content %s", raw) + return decoded +} + +// assertSuccessfulTool requires a non-error tool result. +func assertSuccessfulTool(t *testing.T, result *mcp.CallToolResult) { + t.Helper() + require.NotNil(t, result) + require.False(t, result.IsError, "tool call failed, content: %+v", result.Content) +} + +// assertToolError requires a tool-level error whose only client-visible text is expected. +func assertToolError(t *testing.T, result *mcp.CallToolResult, expected string) { + t.Helper() + require.NotNil(t, result) + require.True(t, result.IsError, "expected a tool-level error") + require.Len(t, result.Content, 1) + text, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok, "tool error content must be text") + assert.Equal(t, expected, text.Text) +} + +// assertExactExecuteEnvelope requires structured execute output to be only {"result": ...}. +func assertExactExecuteEnvelope(t *testing.T, structured any) { + t.Helper() + raw, err := json.Marshal(structured) + require.NoError(t, err) + var envelope map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &envelope)) + require.Len(t, envelope, 1) + _, ok := envelope["result"] + assert.True(t, ok, "execute structured content must wrap the final value under result") +} + +// assertMirroredExecuteContent requires the SDK text mirror to contain only the execute payload. +func assertMirroredExecuteContent( + t *testing.T, + result *mcp.CallToolResult, + expected executeEnvelope, +) { + t.Helper() + require.Len(t, result.Content, 1) + text, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok, "successful execute content must be text") + raw, err := json.Marshal(expected) + require.NoError(t, err) + assert.JSONEq(t, string(raw), text.Text) +} + +// assertNoCanary requires serialized MCP payloads to omit the trusted credential canary. +func assertNoCanary(t *testing.T, value any) { + t.Helper() + raw, err := json.Marshal(value) + require.NoError(t, err) + assert.NotContains(t, string(raw), credentialCanary) +} + +// assertNotContainsText requires tool content and structured output to omit leaked values. +func assertNotContainsText(t *testing.T, result *mcp.CallToolResult, forbidden ...string) { + t.Helper() + raw, err := json.Marshal(result) + require.NoError(t, err) + payload := string(raw) + for _, value := range forbidden { + assert.NotContains(t, payload, value) + } +} diff --git a/mcpserver/mocks/doc.go b/mcpserver/mocks/doc.go new file mode 100644 index 0000000..9f2e0a0 --- /dev/null +++ b/mcpserver/mocks/doc.go @@ -0,0 +1,2 @@ +// Package mocks contains generated test doubles for MCP adapter ports. +package mocks diff --git a/mcpserver/mocks/invocation_resolver.go b/mcpserver/mocks/invocation_resolver.go new file mode 100644 index 0000000..07bdd8d --- /dev/null +++ b/mcpserver/mocks/invocation_resolver.go @@ -0,0 +1,99 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/meigma/codemode/authz" + mock "github.com/stretchr/testify/mock" +) + +// NewMockInvocationResolver creates a new instance of MockInvocationResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockInvocationResolver(t interface { + mock.TestingT + Cleanup(func()) +}) *MockInvocationResolver { + mock := &MockInvocationResolver{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockInvocationResolver is an autogenerated mock type for the InvocationResolver type +type MockInvocationResolver struct { + mock.Mock +} + +type MockInvocationResolver_Expecter struct { + mock *mock.Mock +} + +func (_m *MockInvocationResolver) EXPECT() *MockInvocationResolver_Expecter { + return &MockInvocationResolver_Expecter{mock: &_m.Mock} +} + +// Resolve provides a mock function for the type MockInvocationResolver +func (_mock *MockInvocationResolver) Resolve(ctx context.Context) (authz.Subject, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Resolve") + } + + var r0 authz.Subject + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) (authz.Subject, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) authz.Subject); ok { + r0 = returnFunc(ctx) + } else { + r0 = ret.Get(0).(authz.Subject) + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockInvocationResolver_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve' +type MockInvocationResolver_Resolve_Call struct { + *mock.Call +} + +// Resolve is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockInvocationResolver_Expecter) Resolve(ctx any) *MockInvocationResolver_Resolve_Call { + return &MockInvocationResolver_Resolve_Call{Call: _e.mock.On("Resolve", ctx)} +} + +func (_c *MockInvocationResolver_Resolve_Call) Run(run func(ctx context.Context)) *MockInvocationResolver_Resolve_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockInvocationResolver_Resolve_Call) Return(subject authz.Subject, err error) *MockInvocationResolver_Resolve_Call { + _c.Call.Return(subject, err) + return _c +} + +func (_c *MockInvocationResolver_Resolve_Call) RunAndReturn(run func(ctx context.Context) (authz.Subject, error)) *MockInvocationResolver_Resolve_Call { + _c.Call.Return(run) + return _c +} diff --git a/mcpserver/mocks/service.go b/mcpserver/mocks/service.go new file mode 100644 index 0000000..5b15918 --- /dev/null +++ b/mcpserver/mocks/service.go @@ -0,0 +1,236 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/meigma/codemode" + "github.com/meigma/codemode/authz" + mock "github.com/stretchr/testify/mock" +) + +// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockService(t interface { + mock.TestingT + Cleanup(func()) +}) *MockService { + mock := &MockService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockService is an autogenerated mock type for the Service type +type MockService struct { + mock.Mock +} + +type MockService_Expecter struct { + mock *mock.Mock +} + +func (_m *MockService) EXPECT() *MockService_Expecter { + return &MockService_Expecter{mock: &_m.Mock} +} + +// Describe provides a mock function for the type MockService +func (_mock *MockService) Describe(name codemode.CapabilityName) (codemode.Description, error) { + ret := _mock.Called(name) + + if len(ret) == 0 { + panic("no return value specified for Describe") + } + + var r0 codemode.Description + var r1 error + if returnFunc, ok := ret.Get(0).(func(codemode.CapabilityName) (codemode.Description, error)); ok { + return returnFunc(name) + } + if returnFunc, ok := ret.Get(0).(func(codemode.CapabilityName) codemode.Description); ok { + r0 = returnFunc(name) + } else { + r0 = ret.Get(0).(codemode.Description) + } + if returnFunc, ok := ret.Get(1).(func(codemode.CapabilityName) error); ok { + r1 = returnFunc(name) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockService_Describe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Describe' +type MockService_Describe_Call struct { + *mock.Call +} + +// Describe is a helper method to define mock.On call +// - name codemode.CapabilityName +func (_e *MockService_Expecter) Describe(name any) *MockService_Describe_Call { + return &MockService_Describe_Call{Call: _e.mock.On("Describe", name)} +} + +func (_c *MockService_Describe_Call) Run(run func(name codemode.CapabilityName)) *MockService_Describe_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 codemode.CapabilityName + if args[0] != nil { + arg0 = args[0].(codemode.CapabilityName) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockService_Describe_Call) Return(v codemode.Description, err error) *MockService_Describe_Call { + _c.Call.Return(v, err) + return _c +} + +func (_c *MockService_Describe_Call) RunAndReturn(run func(name codemode.CapabilityName) (codemode.Description, error)) *MockService_Describe_Call { + _c.Call.Return(run) + return _c +} + +// Execute provides a mock function for the type MockService +func (_mock *MockService) Execute(ctx context.Context, subject authz.Subject, program codemode.Program) (any, error) { + ret := _mock.Called(ctx, subject, program) + + if len(ret) == 0 { + panic("no return value specified for Execute") + } + + var r0 any + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, authz.Subject, codemode.Program) (any, error)); ok { + return returnFunc(ctx, subject, program) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, authz.Subject, codemode.Program) any); ok { + r0 = returnFunc(ctx, subject, program) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(any) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, authz.Subject, codemode.Program) error); ok { + r1 = returnFunc(ctx, subject, program) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockService_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute' +type MockService_Execute_Call struct { + *mock.Call +} + +// Execute is a helper method to define mock.On call +// - ctx context.Context +// - subject authz.Subject +// - program codemode.Program +func (_e *MockService_Expecter) Execute(ctx any, subject any, program any) *MockService_Execute_Call { + return &MockService_Execute_Call{Call: _e.mock.On("Execute", ctx, subject, program)} +} + +func (_c *MockService_Execute_Call) Run(run func(ctx context.Context, subject authz.Subject, program codemode.Program)) *MockService_Execute_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 authz.Subject + if args[1] != nil { + arg1 = args[1].(authz.Subject) + } + var arg2 codemode.Program + if args[2] != nil { + arg2 = args[2].(codemode.Program) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockService_Execute_Call) Return(v any, err error) *MockService_Execute_Call { + _c.Call.Return(v, err) + return _c +} + +func (_c *MockService_Execute_Call) RunAndReturn(run func(ctx context.Context, subject authz.Subject, program codemode.Program) (any, error)) *MockService_Execute_Call { + _c.Call.Return(run) + return _c +} + +// Search provides a mock function for the type MockService +func (_mock *MockService) Search(query string) ([]codemode.SearchResult, error) { + ret := _mock.Called(query) + + if len(ret) == 0 { + panic("no return value specified for Search") + } + + var r0 []codemode.SearchResult + var r1 error + if returnFunc, ok := ret.Get(0).(func(string) ([]codemode.SearchResult, error)); ok { + return returnFunc(query) + } + if returnFunc, ok := ret.Get(0).(func(string) []codemode.SearchResult); ok { + r0 = returnFunc(query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]codemode.SearchResult) + } + } + if returnFunc, ok := ret.Get(1).(func(string) error); ok { + r1 = returnFunc(query) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockService_Search_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Search' +type MockService_Search_Call struct { + *mock.Call +} + +// Search is a helper method to define mock.On call +// - query string +func (_e *MockService_Expecter) Search(query any) *MockService_Search_Call { + return &MockService_Search_Call{Call: _e.mock.On("Search", query)} +} + +func (_c *MockService_Search_Call) Run(run func(query string)) *MockService_Search_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockService_Search_Call) Return(vs []codemode.SearchResult, err error) *MockService_Search_Call { + _c.Call.Return(vs, err) + return _c +} + +func (_c *MockService_Search_Call) RunAndReturn(run func(query string) ([]codemode.SearchResult, error)) *MockService_Search_Call { + _c.Call.Return(run) + return _c +} diff --git a/mcpserver/resolver.go b/mcpserver/resolver.go new file mode 100644 index 0000000..7031a87 --- /dev/null +++ b/mcpserver/resolver.go @@ -0,0 +1,20 @@ +package mcpserver + +import ( + "context" + + "github.com/meigma/codemode/authz" +) + +// InvocationResolver resolves the trusted invocation subject from host-owned Go context. +// +// Implementations must read only typed trusted context established by middleware or +// process composition. They must not derive identity from program data, tool +// arguments, or untrusted request metadata. +type InvocationResolver interface { + // Resolve returns the authenticated subject for the current request. + // + // A resolver failure or empty subject ID stops the request before discovery or + // execution. Resolve must not return credential material. + Resolve(ctx context.Context) (authz.Subject, error) +} diff --git a/mcpserver/resolver_test.go b/mcpserver/resolver_test.go new file mode 100644 index 0000000..1614e63 --- /dev/null +++ b/mcpserver/resolver_test.go @@ -0,0 +1,88 @@ +package mcpserver_test + +import ( + "errors" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/meigma/codemode" + "github.com/meigma/codemode/authz" + "github.com/meigma/codemode/mcpserver/mocks" +) + +// TestResolverFailureStopsEveryToolBeforeServiceWork proves authentication precedes all three operations. +func TestResolverFailureStopsEveryToolBeforeServiceWork(t *testing.T) { + tools := []struct { + // name is the official tool that must fail closed. + name string + + // arguments are valid tool arguments that would otherwise reach the service. + arguments map[string]any + }{ + {name: "search_api", arguments: map[string]any{"query": "lookup"}}, + {name: "describe_api", arguments: map[string]any{"name": "records.lookup"}}, + {name: "execute", arguments: map[string]any{"source": "source"}}, + } + + for _, tool := range tools { + t.Run(tool.name, func(t *testing.T) { + service := mocks.NewMockService(t) + resolver := mocks.NewMockInvocationResolver(t) + resolver.EXPECT(). + Resolve(mock.Anything). + Return(authz.Subject{}, errors.New("trusted identity store")). + Once() + session := newTestSession(t, service, resolver) + + result, err := session.client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: tool.name, + Arguments: tool.arguments, + }) + + require.NoError(t, err) + requireToolError(t, result, codemode.ErrUnauthenticated.Error()) + }) + } +} + +// TestEmptySubjectStopsEveryToolBeforeServiceWork proves a blank identity is unauthenticated. +func TestEmptySubjectStopsEveryToolBeforeServiceWork(t *testing.T) { + service := mocks.NewMockService(t) + resolver := mocks.NewMockInvocationResolver(t) + resolver.EXPECT().Resolve(mock.Anything).Return(authz.Subject{ID: ""}, nil).Once() + session := newTestSession(t, service, resolver) + + result, err := session.client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "execute", + Arguments: map[string]any{"source": "source"}, + }) + + require.NoError(t, err) + requireToolError(t, result, codemode.ErrUnauthenticated.Error()) +} + +// TestResolvedSubjectIsPassedToExecute proves execute uses the trusted subject, not request metadata. +func TestResolvedSubjectIsPassedToExecute(t *testing.T) { + service := mocks.NewMockService(t) + resolver := mocks.NewMockInvocationResolver(t) + resolver.EXPECT().Resolve(mock.Anything).Return(authz.Subject{ID: "subject-1"}, nil).Once() + service.EXPECT(). + Execute(mock.Anything, authz.Subject{ID: "subject-1"}, codemode.Program("source")). + Return("ok", nil). + Once() + session := newTestSession(t, service, resolver) + + result, err := session.client.CallTool(t.Context(), &mcp.CallToolParams{ + Meta: mcp.Meta{"subject": "attacker"}, + Name: "execute", + Arguments: map[string]any{"source": "source"}, + }) + + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, map[string]any{"result": "ok"}, result.StructuredContent) +} diff --git a/mcpserver/server.go b/mcpserver/server.go new file mode 100644 index 0000000..faaab49 --- /dev/null +++ b/mcpserver/server.go @@ -0,0 +1,210 @@ +package mcpserver + +import ( + "context" + "errors" + "fmt" + "reflect" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/meigma/codemode" + "github.com/meigma/codemode/authz" +) + +// searchInput is the exact search_api tool argument object. +type searchInput struct { + // Query is the bounded capability-search string. + Query string `json:"query"` +} + +// describeInput is the exact describe_api tool argument object. +type describeInput struct { + // Name is the exact model-facing capability name to describe. + Name string `json:"name"` +} + +// executeInput is the exact execute tool argument object. +type executeInput struct { + // Source is the bounded Starlark program text. + Source string `json:"source"` +} + +// executeOutput is the exact successful execute payload. +type executeOutput struct { + // Result is main's final converted value. + Result any `json:"result"` +} + +// operationResult carries one recovered tool operation outcome without named returns. +type operationResult[Value any] struct { + // value is the successful operation output. + value Value + + // err is the safe operation failure. + err error +} + +// adapter binds a Service and InvocationResolver to the three official MCP tools. +type adapter struct { + // service is the required CodeMode application port. + service Service + + // resolver is the required trusted-subject port. + resolver InvocationResolver +} + +// 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) { + if isNil(service) { + return nil, fmt.Errorf("%w: service is required", codemode.ErrInvalidRegistration) + } + if isNil(resolver) { + return nil, fmt.Errorf("%w: invocation resolver is required", codemode.ErrInvalidRegistration) + } + + bound := &adapter{service: service, resolver: resolver} + server := mcp.NewServer(&mcp.Implementation{Name: "codemode", Version: "1"}, nil) + mcp.AddTool(server, &mcp.Tool{ + Name: "search_api", + Description: "Search enabled capabilities by a bounded query string.", + }, bound.search) + mcp.AddTool(server, &mcp.Tool{ + Name: "describe_api", + Description: "Describe one enabled capability by its exact name.", + }, bound.describe) + mcp.AddTool(server, &mcp.Tool{ + Name: "execute", + Description: "Execute one bounded Starlark program and return only its final result.", + }, bound.execute) + return server, nil +} + +// search resolves a trusted subject and then searches enabled capabilities. +func (bound *adapter) search( + ctx context.Context, + _ *mcp.CallToolRequest, + input searchInput, +) (*mcp.CallToolResult, []codemode.SearchResult, error) { + outcome := runToolOperation(func() ([]codemode.SearchResult, error) { + if _, err := resolveSubject(ctx, bound.resolver); err != nil { + return nil, err + } + return bound.service.Search(input.Query) + }) + if outcome.value == nil && outcome.err == nil { + outcome.value = []codemode.SearchResult{} + } + return nil, outcome.value, outcome.err +} + +// describe resolves a trusted subject and then describes one enabled capability. +func (bound *adapter) describe( + ctx context.Context, + _ *mcp.CallToolRequest, + input describeInput, +) (*mcp.CallToolResult, codemode.Description, error) { + outcome := runToolOperation(func() (codemode.Description, error) { + if _, err := resolveSubject(ctx, bound.resolver); err != nil { + return codemode.Description{}, err + } + return bound.service.Describe(codemode.CapabilityName(input.Name)) + }) + return nil, outcome.value, outcome.err +} + +// execute resolves a trusted subject and then runs one bounded program. +func (bound *adapter) execute( + ctx context.Context, + _ *mcp.CallToolRequest, + input executeInput, +) (*mcp.CallToolResult, executeOutput, error) { + outcome := runToolOperation(func() (executeOutput, error) { + subject, err := resolveSubject(ctx, bound.resolver) + if err != nil { + return executeOutput{}, err + } + value, err := bound.service.Execute(ctx, subject, codemode.Program(input.Source)) + if err != nil { + return executeOutput{}, err + } + return executeOutput{Result: value}, nil + }) + return nil, outcome.value, outcome.err +} + +// resolveSubject returns a non-empty trusted subject or a coarse unauthenticated error. +func resolveSubject(ctx context.Context, resolver InvocationResolver) (authz.Subject, error) { + subject, err := resolver.Resolve(ctx) + if err != nil { + return authz.Subject{}, codemode.ErrUnauthenticated + } + if subject.ID == "" { + return authz.Subject{}, codemode.ErrUnauthenticated + } + return subject, nil +} + +// projectToolError maps a service failure to stable coarse tool-safe text. +func projectToolError(err error) error { + switch { + case errors.Is(err, codemode.ErrResourceLimit): + return codemode.ErrResourceLimit + case errors.Is(err, context.Canceled): + return context.Canceled + case errors.Is(err, context.DeadlineExceeded): + return context.DeadlineExceeded + case errors.Is(err, codemode.ErrUnauthenticated): + return codemode.ErrUnauthenticated + case errors.Is(err, codemode.ErrNotFound): + return codemode.ErrNotFound + case errors.Is(err, codemode.ErrInvalidProgram): + return codemode.ErrInvalidProgram + case errors.Is(err, codemode.ErrInvalidArguments): + return codemode.ErrInvalidArguments + case errors.Is(err, codemode.ErrPermissionDenied): + return codemode.ErrPermissionDenied + case errors.Is(err, codemode.ErrPolicyFailure): + return codemode.ErrPolicyFailure + case errors.Is(err, codemode.ErrCapabilityFailure): + return codemode.ErrCapabilityFailure + default: + return codemode.ErrInternal + } +} + +// runToolOperation recovers boundary panics and projects failures to coarse tool errors. +func runToolOperation[Value any](operation func() (Value, error)) operationResult[Value] { + var outcome operationResult[Value] + func() { + defer func() { + if recovered := recover(); recovered != nil { + outcome.err = codemode.ErrInternal + } + }() + outcome.value, outcome.err = operation() + }() + if outcome.err != nil { + outcome.err = projectToolError(outcome.err) + } + return outcome +} + +// isNil reports whether value is a nil interface or a typed-nil dependency. +func isNil(value any) bool { + if value == nil { + return true + } + reflected := reflect.ValueOf(value) + kind := reflected.Kind() + nilable := kind == reflect.Chan || + kind == reflect.Func || + kind == reflect.Interface || + kind == reflect.Map || + kind == reflect.Pointer || + kind == reflect.Slice + return nilable && reflected.IsNil() +} diff --git a/mcpserver/server_test.go b/mcpserver/server_test.go new file mode 100644 index 0000000..49f113a --- /dev/null +++ b/mcpserver/server_test.go @@ -0,0 +1,500 @@ +package mcpserver_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/meigma/codemode" + "github.com/meigma/codemode/authz" + "github.com/meigma/codemode/mcpserver" + "github.com/meigma/codemode/mcpserver/mocks" +) + +// TestNewRejectsMissingDependencies proves construction fails closed for nil and typed-nil ports. +func TestNewRejectsMissingDependencies(t *testing.T) { + tests := []struct { + // name identifies the rejected construction. + name string + + // service is the Service passed to New. + service mcpserver.Service + + // resolver is the InvocationResolver passed to New. + resolver mcpserver.InvocationResolver + }{ + { + name: "nil service", + service: nil, + resolver: mocks.NewMockInvocationResolver(t), + }, + { + name: "typed-nil service", + service: (*mocks.MockService)(nil), + resolver: mocks.NewMockInvocationResolver(t), + }, + { + name: "nil resolver", + service: mocks.NewMockService(t), + resolver: nil, + }, + { + name: "typed-nil resolver", + service: mocks.NewMockService(t), + resolver: (*mocks.MockInvocationResolver)(nil), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server, err := mcpserver.New(tt.service, tt.resolver) + + require.ErrorIs(t, err, codemode.ErrInvalidRegistration) + assert.Nil(t, server) + }) + } +} + +// TestNewRegistersExactlyThreeTools proves the adapter exposes only the three official tools. +func TestNewRegistersExactlyThreeTools(t *testing.T) { + session := newTestSession(t, mocks.NewMockService(t), mocks.NewMockInvocationResolver(t)) + + listed, err := session.client.ListTools(t.Context(), nil) + require.NoError(t, err) + require.Len(t, listed.Tools, 3) + assert.Equal(t, []string{"describe_api", "execute", "search_api"}, toolNames(listed.Tools)) +} + +// TestSDKRejectsMalformedArgumentsBeforeResolution proves schema validation owns malformed tool input. +func TestSDKRejectsMalformedArgumentsBeforeResolution(t *testing.T) { + tests := []struct { + // name identifies the malformed argument shape. + name string + + // tool is the official tool called through the SDK. + tool string + + // arguments are rejected before the typed handler runs. + arguments map[string]any + }{ + {name: "search missing query", tool: "search_api", arguments: map[string]any{}}, + {name: "search wrong query type", tool: "search_api", arguments: map[string]any{"query": 1}}, + { + name: "search unexpected field", + tool: "search_api", + arguments: map[string]any{"query": "lookup", "subject": "attacker"}, + }, + {name: "describe missing name", tool: "describe_api", arguments: map[string]any{}}, + {name: "describe wrong name type", tool: "describe_api", arguments: map[string]any{"name": true}}, + {name: "execute missing source", tool: "execute", arguments: map[string]any{}}, + {name: "execute wrong source type", tool: "execute", arguments: map[string]any{"source": []string{"program"}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := mocks.NewMockService(t) + resolver := mocks.NewMockInvocationResolver(t) + session := newTestSession(t, service, resolver) + + result, err := session.client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: tt.tool, + Arguments: tt.arguments, + }) + + require.NoError(t, err) + require.Empty(t, resolver.Calls) + require.Empty(t, service.Calls) + requireToolValidationError(t, result) + }) + } +} + +// TestToolsResolveSubjectBeforeServiceWork proves every operation authenticates before catalog or execution work. +func TestToolsResolveSubjectBeforeServiceWork(t *testing.T) { + events := make([]string, 0, 6) + service := mocks.NewMockService(t) + resolver := mocks.NewMockInvocationResolver(t) + resolver.EXPECT().Resolve(mock.Anything).RunAndReturn(func(context.Context) (authz.Subject, error) { + events = append(events, "resolve") + return authz.Subject{ID: "subject-1"}, nil + }).Times(3) + service.EXPECT().Search("lookup").RunAndReturn(func(string) ([]codemode.SearchResult, error) { + events = append(events, "search") + return []codemode.SearchResult{ + {Name: "records.lookup", Signature: "records.lookup() -> object", Summary: "lookup"}, + }, nil + }).Once() + service.EXPECT(). + Describe(codemode.CapabilityName("records.lookup")). + RunAndReturn(func(codemode.CapabilityName) (codemode.Description, error) { + events = append(events, "describe") + return codemode.Description{ + Name: "records.lookup", + Signature: "records.lookup() -> object", + Summary: "lookup", + }, nil + }). + Once() + service.EXPECT().Execute(mock.Anything, authz.Subject{ID: "subject-1"}, codemode.Program("source")).RunAndReturn( + func(context.Context, authz.Subject, codemode.Program) (any, error) { + events = append(events, "execute") + return "ok", nil + }, + ).Once() + session := newTestSession(t, service, resolver) + + searchResult, err := session.client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "search_api", + Arguments: map[string]any{"query": "lookup"}, + }) + require.NoError(t, err) + require.False(t, searchResult.IsError) + describeResult, err := session.client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "describe_api", + Arguments: map[string]any{"name": "records.lookup"}, + }) + require.NoError(t, err) + require.False(t, describeResult.IsError) + assert.Equal(t, map[string]any{ + "name": "records.lookup", + "signature": "records.lookup() -> object", + "summary": "lookup", + "description": "", + "input": nil, + "output": nil, + }, describeResult.StructuredContent) + executeResult, err := session.client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "execute", + Arguments: map[string]any{"source": "source"}, + }) + require.NoError(t, err) + require.False(t, executeResult.IsError) + assert.Equal(t, []string{"resolve", "search", "resolve", "describe", "resolve", "execute"}, events) + assert.Equal(t, map[string]any{"result": "ok"}, executeResult.StructuredContent) +} + +// TestToolsIgnoreUntrustedClientMetadata proves request _meta cannot affect subject resolution. +func TestToolsIgnoreUntrustedClientMetadata(t *testing.T) { + service := mocks.NewMockService(t) + resolver := mocks.NewMockInvocationResolver(t) + resolver.EXPECT().Resolve(mock.Anything).Return(authz.Subject{ID: "subject-1"}, nil).Once() + service.EXPECT().Search("lookup").Return([]codemode.SearchResult{}, nil).Once() + session := newTestSession(t, service, resolver) + + result, err := session.client.CallTool(t.Context(), &mcp.CallToolParams{ + Meta: mcp.Meta{"subject": "attacker", "credential": "canary"}, + Name: "search_api", + Arguments: map[string]any{"query": "lookup"}, + }) + + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, []any{}, result.StructuredContent) +} + +// TestToolsProjectResolverFailures proves resolver errors and empty subjects never reach the service. +func TestToolsProjectResolverFailures(t *testing.T) { + tests := []struct { + // name identifies the authentication failure. + name string + + // resolve returns the resolver outcome. + resolve func(context.Context) (authz.Subject, error) + }{ + { + name: "resolver error", + resolve: func(context.Context) (authz.Subject, error) { + return authz.Subject{}, errors.New("trusted identity detail") + }, + }, + { + name: "empty subject", + resolve: func(context.Context) (authz.Subject, error) { + return authz.Subject{}, nil + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := mocks.NewMockService(t) + resolver := mocks.NewMockInvocationResolver(t) + resolver.EXPECT().Resolve(mock.Anything).RunAndReturn(tt.resolve).Once() + session := newTestSession(t, service, resolver) + + result, err := session.client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "search_api", + Arguments: map[string]any{"query": "lookup"}, + }) + + require.NoError(t, err) + requireToolError(t, result, codemode.ErrUnauthenticated.Error()) + }) + } +} + +// TestToolsProjectStableServiceErrors proves known sentinels and unknown errors become coarse tool text. +func TestToolsProjectStableServiceErrors(t *testing.T) { + tests := []struct { + // name identifies the projected failure. + name string + + // tool is the official tool under test. + tool string + + // arguments are the valid tool arguments. + arguments map[string]any + + // configure installs one generated service expectation. + configure func(*mocks.MockService) + + // want is the exact coarse tool error text. + want string + }{ + { + name: "not found", + tool: "describe_api", + arguments: map[string]any{"name": "records.hidden"}, + configure: func(service *mocks.MockService) { + service.EXPECT(). + Describe(codemode.CapabilityName("records.hidden")). + Return(codemode.Description{}, fmt.Errorf("trusted catalog: %w", codemode.ErrNotFound)). + Once() + }, + want: codemode.ErrNotFound.Error(), + }, + { + name: "invalid program", + tool: "execute", + arguments: map[string]any{"source": "broken"}, + configure: func(service *mocks.MockService) { + service.EXPECT(). + Execute(mock.Anything, authz.Subject{ID: "subject-1"}, codemode.Program("broken")). + Return(nil, fmt.Errorf("trusted parse: %w", codemode.ErrInvalidProgram)). + Once() + }, + want: codemode.ErrInvalidProgram.Error(), + }, + { + name: "invalid arguments", + tool: "execute", + arguments: map[string]any{"source": "args"}, + configure: func(service *mocks.MockService) { + service.EXPECT(). + Execute(mock.Anything, authz.Subject{ID: "subject-1"}, codemode.Program("args")). + Return(nil, fmt.Errorf("trusted args: %w", codemode.ErrInvalidArguments)). + Once() + }, + want: codemode.ErrInvalidArguments.Error(), + }, + { + name: "permission denied", + tool: "execute", + arguments: map[string]any{"source": "denied"}, + configure: func(service *mocks.MockService) { + service.EXPECT(). + Execute(mock.Anything, authz.Subject{ID: "subject-1"}, codemode.Program("denied")). + Return(nil, fmt.Errorf("trusted denial: %w", codemode.ErrPermissionDenied)). + Once() + }, + want: codemode.ErrPermissionDenied.Error(), + }, + { + name: "policy failure", + tool: "execute", + arguments: map[string]any{"source": "policy"}, + configure: func(service *mocks.MockService) { + service.EXPECT(). + Execute(mock.Anything, authz.Subject{ID: "subject-1"}, codemode.Program("policy")). + Return(nil, fmt.Errorf("trusted policy: %w", codemode.ErrPolicyFailure)). + Once() + }, + want: codemode.ErrPolicyFailure.Error(), + }, + { + name: "resource limit", + tool: "search_api", + arguments: map[string]any{"query": "oversized"}, + configure: func(service *mocks.MockService) { + service.EXPECT(). + Search("oversized"). + Return(nil, fmt.Errorf("trusted budget: %w", codemode.ErrResourceLimit)). + Once() + }, + want: codemode.ErrResourceLimit.Error(), + }, + { + name: "capability failure", + tool: "execute", + arguments: map[string]any{"source": "handler"}, + configure: func(service *mocks.MockService) { + service.EXPECT(). + Execute(mock.Anything, authz.Subject{ID: "subject-1"}, codemode.Program("handler")). + Return(nil, fmt.Errorf("trusted handler: %w", codemode.ErrCapabilityFailure)). + Once() + }, + want: codemode.ErrCapabilityFailure.Error(), + }, + { + name: "canceled", + tool: "execute", + arguments: map[string]any{"source": "canceled"}, + configure: func(service *mocks.MockService) { + service.EXPECT(). + Execute(mock.Anything, authz.Subject{ID: "subject-1"}, codemode.Program("canceled")). + Return(nil, context.Canceled). + Once() + }, + want: context.Canceled.Error(), + }, + { + name: "elapsed resource limit", + tool: "execute", + arguments: map[string]any{"source": "deadline"}, + configure: func(service *mocks.MockService) { + service.EXPECT(). + Execute(mock.Anything, authz.Subject{ID: "subject-1"}, codemode.Program("deadline")). + Return(nil, fmt.Errorf("%w: %w", codemode.ErrResourceLimit, context.DeadlineExceeded)). + Once() + }, + want: codemode.ErrResourceLimit.Error(), + }, + { + name: "unknown internal", + tool: "search_api", + arguments: map[string]any{"query": "lookup"}, + configure: func(service *mocks.MockService) { + service.EXPECT().Search("lookup").Return(nil, errors.New("trusted stack dump")).Once() + }, + want: codemode.ErrInternal.Error(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := mocks.NewMockService(t) + resolver := mocks.NewMockInvocationResolver(t) + resolver.EXPECT().Resolve(mock.Anything).Return(authz.Subject{ID: "subject-1"}, nil).Once() + tt.configure(service) + session := newTestSession(t, service, resolver) + + result, err := session.client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: tt.tool, + Arguments: tt.arguments, + }) + + require.NoError(t, err) + requireToolError(t, result, tt.want) + }) + } +} + +// TestToolsSanitizePanics proves adapter recovery never leaks panic text. +func TestToolsSanitizePanics(t *testing.T) { + tests := []struct { + // name identifies the recovered panic source. + name string + + // configure installs one generated expectation that panics. + configure func(*mocks.MockService, *mocks.MockInvocationResolver) + }{ + { + name: "resolver panic", + configure: func(_ *mocks.MockService, resolver *mocks.MockInvocationResolver) { + resolver.EXPECT().Resolve(mock.Anything).Run(func(context.Context) { + panic("trusted resolver panic") + }).Return(authz.Subject{}, nil).Once() + }, + }, + { + name: "service panic", + configure: func(service *mocks.MockService, resolver *mocks.MockInvocationResolver) { + resolver.EXPECT().Resolve(mock.Anything).Return(authz.Subject{ID: "subject-1"}, nil).Once() + service.EXPECT().Search("lookup").Run(func(string) { + panic("trusted service panic") + }).Return(nil, nil).Once() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := mocks.NewMockService(t) + resolver := mocks.NewMockInvocationResolver(t) + tt.configure(service, resolver) + session := newTestSession(t, service, resolver) + + result, err := session.client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "search_api", + Arguments: map[string]any{"query": "lookup"}, + }) + + require.NoError(t, err) + requireToolError(t, result, codemode.ErrInternal.Error()) + }) + } +} + +// testSession owns one official in-memory client connected to the adapter. +type testSession struct { + // client is the official MCP client session. + client *mcp.ClientSession +} + +// newTestSession connects an official client to New through in-memory transports. +func newTestSession(t *testing.T, service mcpserver.Service, resolver mcpserver.InvocationResolver) *testSession { + t.Helper() + + server, err := mcpserver.New(service, resolver) + require.NoError(t, err) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(t.Context(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { + _ = serverSession.Close() + }) + client := mcp.NewClient(&mcp.Implementation{Name: "mcpserver-test", Version: "1"}, nil) + clientSession, err := client.Connect(t.Context(), clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { + _ = clientSession.Close() + }) + return &testSession{client: clientSession} +} + +// toolNames returns the listed tool names in listing order. +func toolNames(tools []*mcp.Tool) []string { + names := make([]string, len(tools)) + for index, tool := range tools { + names[index] = tool.Name + } + return names +} + +// requireToolValidationError asserts the SDK rejected malformed typed arguments. +func requireToolValidationError(t *testing.T, result *mcp.CallToolResult) { + t.Helper() + + require.NotNil(t, result) + require.True(t, result.IsError) + require.NotEmpty(t, result.Content) +} + +// requireToolError asserts a successful protocol response carrying one coarse tool error. +func requireToolError(t *testing.T, result *mcp.CallToolResult, want string) { + t.Helper() + + require.NotNil(t, result) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + text, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + assert.Equal(t, want, text.Text) + assert.NotContains(t, text.Text, "trusted") +} diff --git a/mcpserver/service.go b/mcpserver/service.go new file mode 100644 index 0000000..a129a5a --- /dev/null +++ b/mcpserver/service.go @@ -0,0 +1,23 @@ +package mcpserver + +import ( + "context" + + "github.com/meigma/codemode" + "github.com/meigma/codemode/authz" +) + +// Service is the inbound adapter's view of an immutable CodeMode server. +// +// The root *codemode.Server implements this port. The adapter does not re-enforce +// catalog bounds, hidden-capability filtering, or execution restrictions. +type Service interface { + // Search returns a bounded name-sorted scan of enabled capability names and summaries. + Search(query string) ([]codemode.SearchResult, error) + + // Describe returns one exact enabled capability description or a not-found error. + Describe(name codemode.CapabilityName) (codemode.Description, error) + + // Execute runs one bounded program for a trusted authenticated subject and returns only main's final value. + Execute(ctx context.Context, subject authz.Subject, program codemode.Program) (any, error) +}