diff --git a/README.md b/README.md index 20a64ce..5de9b9a 100644 --- a/README.md +++ b/README.md @@ -16,18 +16,46 @@ The module currently requires Go 1.26.6. ## Get started -Follow [Build your first CodeMode server](docs/docs/tutorials/first-server.md) to register the typed `records.entry.lookup` capability, expose it as `records.lookup`, connect the official in-memory MCP transport, and call all three tools. +Follow [Build your first CodeMode server](docs/docs/tutorials/first-server.md) +to register `records.lookup`, run a real stdio MCP server, and add it to an +agent. The server assembly is: + +```go +func main() { + codemode.ServeWorkerAndExit() + + builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll()}) + codemode.Register(builder, codemode.Capability[lookupInput, lookupOutput]{ + Name: "records.lookup", + Summary: "Look up one record by key.", + Handler: lookup, + }) + + server, err := builder.Build() + if err != nil { + log.Fatal(err) + } + srv, err := mcpserver.New(server, mcpserver.StaticSubject(authz.Subject{ID: "local"})) + if err != nil { + log.Fatal(err) + } + log.Fatal(srv.Run(context.Background(), &mcp.StdioTransport{})) +} +``` The repository also contains shorter, compile-checked examples: -- [`example_test.go`](example_test.go) — typed capability registration, explicit `authz.AllowAll()`, default limits, and direct execution -- [`mcpserver/example_test.go`](mcpserver/example_test.go) — trusted typed-context resolution and the official in-memory MCP transport +- [`example_test.go`](example_test.go) — typed registration with default identity and limits, plus direct execution +- [`mcpserver/example_test.go`](mcpserver/example_test.go) — a fixed single-user subject and the official in-memory MCP transport -`authz.AllowAll()` is deliberate in the simple examples and permits every native call whose arguments bind successfully. Use `authz.AllowAll()` only when every resolved subject may call every enabled capability; otherwise supply an `authz.Authorizer`. +`authz.AllowAll()` is deliberate in the simple examples. CodeMode never +defaults authorization to allow. `mcpserver.StaticSubject` is only for +single-user transports where process ownership is the authentication boundary; +multi-user hosts must resolve each authenticated request separately. -The final host binary must call `codemode.ServeWorkerAndExit()` as the first -statement of `main`, before flag parsing or any other setup. Test binaries that -call `Builder.Build` must do the same from `TestMain` before `m.Run`. +`codemode.ServeWorkerAndExit()` must remain the first statement of `main`, +before flag parsing or any other setup. Test binaries that call `Builder.Build` +must make the same call from `TestMain` before `m.Run`. ## Documentation @@ -52,10 +80,10 @@ budget. The worker binds keyword arguments first. The parent then rebinds them to the registered Go input, creates a fresh canonical authorization map, authorizes the call, and dispatches the handler. The parent converts the handler output to -a process-neutral value, and the worker converts that value to Starlark. -Trusted subjects come -from typed, host-owned Go context through `mcpserver.InvocationResolver`; tool -arguments, Starlark source, and MCP `_meta` are not trusted identity or +a process-neutral value, and the worker converts that value to Starlark. Every +MCP operation gets a trusted subject through `mcpserver.InvocationResolver`, +either from a single-user process boundary or authenticated host-owned context. +Tool arguments, Starlark source, and MCP `_meta` are not trusted identity or credential sources. The worker process lets CodeMode kill Starlark when an execution deadline or diff --git a/authz/authz.go b/authz/authz.go index 9aeea02..aa21df6 100644 --- a/authz/authz.go +++ b/authz/authz.go @@ -14,6 +14,24 @@ type Subject struct { ID SubjectID } +// subjectContextKey prevents collisions with context values owned by host packages. +type subjectContextKey struct{} + +// WithSubject returns a child context containing a trusted authenticated subject. +// +// Hosts must derive subject from their authentication boundary before calling +// WithSubject. Tool arguments, program source, and MCP request metadata are not +// trusted identity sources. +func WithSubject(ctx context.Context, subject Subject) context.Context { + return context.WithValue(ctx, subjectContextKey{}, subject) +} + +// SubjectFromContext returns the trusted subject stored by WithSubject. +func SubjectFromContext(ctx context.Context) (Subject, bool) { + subject, ok := ctx.Value(subjectContextKey{}).(Subject) + return subject, ok +} + // AuthorizationInput contains the complete trusted input for one authorization decision. type AuthorizationInput struct { // Subject is the authenticated identity resolved from trusted host context. diff --git a/builder.go b/builder.go index c7fdbfb..294af48 100644 --- a/builder.go +++ b/builder.go @@ -2,6 +2,7 @@ package codemode import ( "context" + "errors" "fmt" "reflect" "slices" @@ -19,7 +20,8 @@ type Options struct { // DisabledCapabilities lists stable capability IDs removed from every live server surface. DisabledCapabilities []CapabilityID - // Limits contains positive execution, conversion, and discovery budgets. + // Limits contains execution, conversion, and discovery budgets. Build + // replaces each zero-valued field with the corresponding DefaultLimits value. Limits Limits } @@ -40,6 +42,9 @@ type Builder struct { // registrations contains validated, precompiled capability registrations until Build. registrations []catalog.Registration + // registrationErrors contains capability-specific failures deferred to Build. + registrationErrors []error + // built reports whether Build has closed this builder. built bool } @@ -61,28 +66,43 @@ func New(options Options) *Builder { } } -// Register compiles and retains one typed capability without erasing its binding contract first. -func Register[Input, Output any](builder *Builder, capability Capability[Input, Output]) error { +// Register compiles and retains one typed capability without erasing its +// binding contract first. +// +// Capability-specific failures are accumulated and returned together by Build. +// Register panics when builder is nil or already closed because no future Build +// call can report those lifecycle violations. +func Register[Input, Output any](builder *Builder, capability Capability[Input, Output]) { if builder == nil { - return fmt.Errorf("%w: nil builder", ErrInvalidRegistration) + panic(fmt.Errorf("%w: nil builder", ErrInvalidRegistration)) } if builder.built { - return fmt.Errorf("%w: builder is already closed", ErrInvalidRegistration) + panic(fmt.Errorf("%w: builder is already closed", ErrInvalidRegistration)) } if capability.Handler == nil { - return fmt.Errorf("%w: capability handler must not be nil", ErrInvalidRegistration) + builder.recordRegistrationError(capability.Name, errors.New("handler must not be nil")) + return } plan, err := binding.CompileFor[Input, Output]() if err != nil { - return fmt.Errorf("%w: capability %q: %w", ErrInvalidRegistration, capability.Name, err) + builder.recordRegistrationError(capability.Name, err) + return + } + id := capability.ID + if id == "" { + id = CapabilityID(capability.Name) + } + description := capability.Description + if description == "" { + description = capability.Summary } handler := capability.Handler registration := catalog.Registration{ - ID: string(capability.ID), + ID: string(id), Name: string(capability.Name), Summary: capability.Summary, - Description: capability.Description, + Description: description, Plan: plan, Invoke: func(ctx context.Context, subject authz.Subject, input any) (any, error) { typed, ok := input.(Input) @@ -93,18 +113,36 @@ func Register[Input, Output any](builder *Builder, capability Capability[Input, }, } if err := catalog.ValidateRegistration(registration); err != nil { - return fmt.Errorf("%w: %w", ErrInvalidRegistration, err) + builder.recordRegistrationError(capability.Name, err) + return } for _, existing := range builder.registrations { if existing.ID == registration.ID { - return fmt.Errorf("%w: duplicate capability ID %q", ErrInvalidRegistration, registration.ID) + builder.recordRegistrationError( + capability.Name, + fmt.Errorf("duplicate capability ID %q", registration.ID), + ) + return } if existing.Name == registration.Name { - return fmt.Errorf("%w: duplicate capability name %q", ErrInvalidRegistration, registration.Name) + builder.recordRegistrationError( + capability.Name, + fmt.Errorf("duplicate capability name %q", registration.Name), + ) + return } } builder.registrations = append(builder.registrations, registration) - return nil +} + +// recordRegistrationError retains one capability-specific programmer error. +func (builder *Builder) recordRegistrationError(name CapabilityName, err error) { + builder.registrationErrors = append(builder.registrationErrors, fmt.Errorf( + "%w: capability %q: %w", + ErrInvalidRegistration, + name, + err, + )) } // Build closes the Builder and returns an immutable concurrency-safe Server @@ -129,22 +167,31 @@ func (builder *Builder) Build() (*Server, error) { builder.built = true registrations := slices.Clone(builder.registrations) builder.registrations = nil + buildErrors := slices.Clone(builder.registrationErrors) + builder.registrationErrors = nil + limits := builder.limits.withDefaults() if isNilAuthorizer(builder.authorizer) { - return nil, fmt.Errorf("%w: Authorizer must not be nil", ErrInvalidRegistration) + buildErrors = append( + buildErrors, + fmt.Errorf("%w: Authorizer must not be nil", ErrInvalidRegistration), + ) + } + if err := limits.Validate(); err != nil { + buildErrors = append(buildErrors, err) } - if err := builder.limits.Validate(); err != nil { - return nil, err + if len(buildErrors) > 0 { + return nil, errors.Join(buildErrors...) } capabilityCatalog, err := catalog.Build(registrations, catalog.Options{ DisabledCapabilities: slices.Clone(builder.disabledCapabilities), - MaxSearchQueryBytes: builder.limits.MaxSearchQueryBytes, - MaxSearchResults: builder.limits.MaxSearchResults, + MaxSearchQueryBytes: limits.MaxSearchQueryBytes, + MaxSearchResults: limits.MaxSearchResults, }) if err != nil { return nil, fmt.Errorf("%w: %w", ErrInvalidRegistration, err) } - server, err := newServer(capabilityCatalog, builder.authorizer, builder.limits) + server, err := newServer(capabilityCatalog, builder.authorizer, limits) if err != nil { return nil, fmt.Errorf("%w: %w", ErrInvalidRegistration, err) } diff --git a/builder_test.go b/builder_test.go index 564339c..3db1612 100644 --- a/builder_test.go +++ b/builder_test.go @@ -81,11 +81,8 @@ func (*nilPolicy) Authorize(context.Context, authz.AuthorizationInput) error { // TestBuilderBuildsOnceAndClosesRegistration proves the mutable lifecycle ends at the first Build call. func TestBuilderBuildsOnceAndClosesRegistration(t *testing.T) { - builder := codemode.New(codemode.Options{ - Authorizer: authz.AllowAll(), - Limits: codemode.DefaultLimits(), - }) - require.NoError(t, codemode.Register(builder, validBuilderCapability("cap.one", "records.one"))) + builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll()}) + codemode.Register(builder, validBuilderCapability("cap.one", "records.one")) server, err := builder.Build() @@ -93,8 +90,9 @@ func TestBuilderBuildsOnceAndClosesRegistration(t *testing.T) { require.NotNil(t, server) _, err = builder.Build() require.ErrorIs(t, err, codemode.ErrInvalidRegistration) - err = codemode.Register(builder, validBuilderCapability("cap.two", "records.two")) - require.ErrorIs(t, err, codemode.ErrInvalidRegistration) + assert.Panics(t, func() { + codemode.Register(builder, validBuilderCapability("cap.two", "records.two")) + }) } // TestBuilderCopiesStaticFilteringOptions proves caller slice mutation cannot alter retained configuration. @@ -106,7 +104,7 @@ func TestBuilderCopiesStaticFilteringOptions(t *testing.T) { Limits: codemode.DefaultLimits(), }) disabled[0] = "cap.unknown" - require.NoError(t, codemode.Register(builder, validBuilderCapability("cap.disabled", "records.disabled"))) + codemode.Register(builder, validBuilderCapability("cap.disabled", "records.disabled")) server, err := builder.Build() @@ -116,9 +114,10 @@ func TestBuilderCopiesStaticFilteringOptions(t *testing.T) { assert.Empty(t, results) } -// TestBuilderRejectsNilAuthorizersAndInvalidLimits proves required construction policy fails closed. -func TestBuilderRejectsNilAuthorizersAndInvalidLimits(t *testing.T) { +// TestBuilderRejectsNilAuthorizersAndNegativeLimits proves required construction policy fails closed. +func TestBuilderRejectsNilAuthorizersAndNegativeLimits(t *testing.T) { var typedNil *nilPolicy + negativeLimits := codemode.Limits{MaxSourceBytes: -1} tests := []struct { // name identifies the invalid server options. name string @@ -126,12 +125,12 @@ func TestBuilderRejectsNilAuthorizersAndInvalidLimits(t *testing.T) { // options contains the construction input. options codemode.Options }{ - {name: "nil authorizer", options: codemode.Options{Limits: codemode.DefaultLimits()}}, + {name: "nil authorizer", options: codemode.Options{}}, + {name: "typed nil authorizer", options: codemode.Options{Authorizer: typedNil}}, { - name: "typed nil authorizer", - options: codemode.Options{Authorizer: typedNil, Limits: codemode.DefaultLimits()}, + name: "negative limit", + options: codemode.Options{Authorizer: authz.AllowAll(), Limits: negativeLimits}, }, - {name: "zero limits", options: codemode.Options{Authorizer: authz.AllowAll()}}, } for _, tt := range tests { @@ -148,16 +147,17 @@ func TestBuilderRejectsNilAuthorizersAndInvalidLimits(t *testing.T) { } } -// TestRegisterRejectsInvalidContractsBeforeRetention proves malformed capabilities never enter the builder. -func TestRegisterRejectsInvalidContractsBeforeRetention(t *testing.T) { - builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll(), Limits: codemode.DefaultLimits()}) - invalidMetadata := validBuilderCapability("", "records.valid") +// TestBuildJoinsRegistrationFailures proves one build reports every invalid capability by name. +func TestBuildJoinsRegistrationFailures(t *testing.T) { + builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll()}) + invalidMetadata := validBuilderCapability("cap.metadata", "records.metadata") + invalidMetadata.Summary = "" nilHandler := validBuilderCapability("cap.nil", "records.nil") nilHandler.Handler = nil - require.ErrorIs(t, codemode.Register(builder, invalidMetadata), codemode.ErrInvalidRegistration) - require.ErrorIs(t, codemode.Register(builder, nilHandler), codemode.ErrInvalidRegistration) - require.ErrorIs(t, codemode.Register(builder, codemode.Capability[invalidBuilderInput, builderOutput]{ + codemode.Register(builder, invalidMetadata) + codemode.Register(builder, nilHandler) + codemode.Register(builder, codemode.Capability[invalidBuilderInput, builderOutput]{ ID: "cap.unsupported", Name: "records.unsupported", Summary: "Unsupported input.", @@ -165,27 +165,37 @@ func TestRegisterRejectsInvalidContractsBeforeRetention(t *testing.T) { Handler: func(context.Context, authz.Subject, invalidBuilderInput) (builderOutput, error) { return builderOutput{}, nil }, - }), codemode.ErrInvalidRegistration) - require.ErrorIs(t, codemode.Register[builderInput, builderOutput](nil, validBuilderCapability( - "cap.nil_builder", - "records.nil_builder", - )), codemode.ErrInvalidRegistration) + }) + + server, err := builder.Build() + + require.ErrorIs(t, err, codemode.ErrInvalidRegistration) + assert.Nil(t, server) + require.ErrorContains(t, err, `capability "records.metadata"`) + require.ErrorContains(t, err, `capability "records.nil"`) + require.ErrorContains(t, err, `capability "records.unsupported"`) + assert.Panics(t, func() { + codemode.Register[builderInput, builderOutput](nil, validBuilderCapability( + "cap.nil_builder", + "records.nil_builder", + )) + }) } -// TestRegisterRejectsUnsupportedNestedOutputsBeforeRetention proves representative -// unsupportable output graphs never enter the builder. -func TestRegisterRejectsUnsupportedNestedOutputsBeforeRetention(t *testing.T) { +// TestBuildRejectsUnsupportedNestedOutputs proves representative unsupportable +// output graphs are reported during the consolidated build. +func TestBuildRejectsUnsupportedNestedOutputs(t *testing.T) { tests := []struct { // name identifies the unsupported nested output. name string // register attempts one invalid public registration. - register func(*codemode.Builder) error + register func(*codemode.Builder) }{ { name: "interface any", - register: func(builder *codemode.Builder) error { - return codemode.Register(builder, codemode.Capability[builderInput, interfaceBuilderOutput]{ + register: func(builder *codemode.Builder) { + codemode.Register(builder, codemode.Capability[builderInput, interfaceBuilderOutput]{ ID: "cap.interface", Name: "records.interface", Summary: "Unsupported interface output.", @@ -198,8 +208,8 @@ func TestRegisterRejectsUnsupportedNestedOutputsBeforeRetention(t *testing.T) { }, { name: "json.RawMessage", - register: func(builder *codemode.Builder) error { - return codemode.Register(builder, codemode.Capability[builderInput, rawMessageBuilderOutput]{ + register: func(builder *codemode.Builder) { + codemode.Register(builder, codemode.Capability[builderInput, rawMessageBuilderOutput]{ ID: "cap.raw", Name: "records.raw", Summary: "Unsupported raw message output.", @@ -212,8 +222,8 @@ func TestRegisterRejectsUnsupportedNestedOutputsBeforeRetention(t *testing.T) { }, { name: "cyclic type", - register: func(builder *codemode.Builder) error { - return codemode.Register(builder, codemode.Capability[builderInput, cyclicBuilderOutput]{ + register: func(builder *codemode.Builder) { + codemode.Register(builder, codemode.Capability[builderInput, cyclicBuilderOutput]{ ID: "cap.cycle", Name: "records.cycle", Summary: "Unsupported cyclic output.", @@ -226,8 +236,8 @@ func TestRegisterRejectsUnsupportedNestedOutputsBeforeRetention(t *testing.T) { }, { name: "custom marshaler", - register: func(builder *codemode.Builder) error { - return codemode.Register(builder, codemode.Capability[builderInput, marshalerBuilderOutput]{ + register: func(builder *codemode.Builder) { + codemode.Register(builder, codemode.Capability[builderInput, marshalerBuilderOutput]{ ID: "cap.marshaler", Name: "records.marshaler", Summary: "Unsupported marshaler output.", @@ -240,8 +250,8 @@ func TestRegisterRejectsUnsupportedNestedOutputsBeforeRetention(t *testing.T) { }, { name: "non-string map key", - register: func(builder *codemode.Builder) error { - return codemode.Register(builder, codemode.Capability[builderInput, mapKeyBuilderOutput]{ + register: func(builder *codemode.Builder) { + codemode.Register(builder, codemode.Capability[builderInput, mapKeyBuilderOutput]{ ID: "cap.mapkey", Name: "records.mapkey", Summary: "Unsupported map key output.", @@ -256,36 +266,31 @@ func TestRegisterRejectsUnsupportedNestedOutputsBeforeRetention(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll(), Limits: codemode.DefaultLimits()}) + builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll()}) - err := tt.register(builder) + tt.register(builder) + codemode.Register(builder, validBuilderCapability("cap.retained", "records.retained")) + server, err := builder.Build() require.ErrorIs(t, err, codemode.ErrInvalidRegistration) - require.NoError(t, codemode.Register(builder, validBuilderCapability("cap.retained", "records.retained"))) - server, buildErr := builder.Build() - require.NoError(t, buildErr) - results, searchErr := server.Search("unsupported") - require.NoError(t, searchErr) - assert.Empty(t, results) + assert.Nil(t, server) }) } } -// TestRegisterRejectsObviousDuplicates proves duplicate stable and model-facing identities fail immediately. -func TestRegisterRejectsObviousDuplicates(t *testing.T) { - builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll(), Limits: codemode.DefaultLimits()}) - require.NoError(t, codemode.Register(builder, validBuilderCapability("cap.one", "records.one"))) - - require.ErrorIs( - t, - codemode.Register(builder, validBuilderCapability("cap.one", "records.two")), - codemode.ErrInvalidRegistration, - ) - require.ErrorIs( - t, - codemode.Register(builder, validBuilderCapability("cap.two", "records.one")), - codemode.ErrInvalidRegistration, - ) +// TestBuildJoinsDuplicateRegistrationFailures proves duplicate identities are deferred and complete. +func TestBuildJoinsDuplicateRegistrationFailures(t *testing.T) { + builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll()}) + codemode.Register(builder, validBuilderCapability("cap.one", "records.one")) + codemode.Register(builder, validBuilderCapability("cap.one", "records.two")) + codemode.Register(builder, validBuilderCapability("cap.two", "records.one")) + + server, err := builder.Build() + + require.ErrorIs(t, err, codemode.ErrInvalidRegistration) + assert.Nil(t, server) + require.ErrorContains(t, err, `capability "records.two"`) + require.ErrorContains(t, err, `capability "records.one"`) } // TestBuildRejectsWholeCatalogFailures proves filtering and namespace checks run before a server escapes. @@ -305,8 +310,8 @@ func TestBuildRejectsWholeCatalogFailures(t *testing.T) { t.Run("function namespace collision", func(t *testing.T) { builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll(), Limits: codemode.DefaultLimits()}) - require.NoError(t, codemode.Register(builder, validBuilderCapability("cap.lookup", "records.lookup"))) - require.NoError(t, codemode.Register(builder, validBuilderCapability("cap.detail", "records.lookup.detail"))) + codemode.Register(builder, validBuilderCapability("cap.lookup", "records.lookup")) + codemode.Register(builder, validBuilderCapability("cap.detail", "records.lookup.detail")) server, err := builder.Build() @@ -336,13 +341,11 @@ func TestBuilderRejectsWorkerFrameOverflow(t *testing.T) { Authorizer: authz.AllowAll(), Limits: limits, }) - require.NoError(t, codemode.Register( - builder, + codemode.Register(builder, validBuilderCapability( codemode.CapabilityID("cap."+strings.Repeat("x", 128)), "records.lookup", - ), - )) + )) server, err := builder.Build() diff --git a/capability.go b/capability.go index 870ac66..053b81c 100644 --- a/capability.go +++ b/capability.go @@ -17,7 +17,9 @@ type Handler[Input, Output any] func(context.Context, authz.Subject, Input) (Out // Capability describes one typed native operation available to CodeMode. type Capability[Input, Output any] struct { - // ID is the stable identity used by deployment filtering and authorization policy. + // ID is the stable identity used by deployment filtering and authorization + // policy. An empty ID defaults to Name. Set ID explicitly before writing + // policy or deployment filters against this capability. ID CapabilityID // Name is the dotted Starlark name exposed to programs and discovery. @@ -26,7 +28,8 @@ type Capability[Input, Output any] struct { // Summary is a compact description used by capability search. Summary string - // Description explains the capability behavior for exact description requests. + // Description explains the capability behavior for exact description + // requests. An empty Description defaults to Summary. Description string // Handler executes the capability after binding and authorization succeed. diff --git a/docs/docs/explanation/security-model.md b/docs/docs/explanation/security-model.md index f56ded6..1b81a62 100644 --- a/docs/docs/explanation/security-model.md +++ b/docs/docs/explanation/security-model.md @@ -9,9 +9,16 @@ CodeMode separates client-controlled Starlark from host-controlled identity, pol ## The host establishes identity -The host authenticates a connection or request before the MCP adapter uses it. Authentication middleware or process composition stores a non-secret `authz.Subject` in typed, host-owned Go context. An `mcpserver.InvocationResolver` reads that context for every `search_api`, `describe_api`, and `execute` call. - -Typed Go context matters because it gives the host an identity channel outside model-visible data. These values are untrusted and cannot establish or replace a subject: +Every `search_api`, `describe_api`, and `execute` call runs an +`mcpserver.InvocationResolver`. In a single-user stdio deployment, +`mcpserver.StaticSubject` treats process ownership as the authentication +boundary. A multi-user host must not use `StaticSubject`; authentication +middleware must validate each request, store the non-secret identity with +`authz.WithSubject`, and use `mcpserver.ContextSubject`. + +Typed, host-owned Go context gives a multi-user host an identity channel outside +model-visible data. These values are untrusted and cannot establish or replace +a subject: - MCP tool arguments - Starlark source or values diff --git a/docs/docs/how-to/disable-capabilities.md b/docs/docs/how-to/disable-capabilities.md index 9cc7ef1..1b65d82 100644 --- a/docs/docs/how-to/disable-capabilities.md +++ b/docs/docs/how-to/disable-capabilities.md @@ -7,9 +7,27 @@ description: Remove registered capabilities from every live CodeMode surface by Use `codemode.Options.DisabledCapabilities` to remove capabilities from one server build. The filter is static: construct and build another server to change the enabled set. -## Add stable capability IDs to the filter +## Set a stable capability ID -Starting from the [first-server tutorial](../tutorials/first-server.md), keep the capability registered and add its stable `codemode.CapabilityID` to the builder options. In the tutorial's `run` function, replace the builder initialization with: +Starting from the [first-server tutorial](../tutorials/first-server.md), set an +explicit ID before creating a deployment filter: + +```go +codemode.Register(builder, codemode.Capability[lookupInput, lookupOutput]{ + ID: "records.entry.lookup", + Name: "records.lookup", + Summary: "Look up one record by key.", + Handler: lookup, +}) +``` + +An omitted ID defaults to the capability name. Set the ID explicitly before +writing policy or filters so renaming `records.lookup` does not silently change +its deployment identity. + +## Add the ID to the filter + +Replace the builder initialization: ```go builder := codemode.New(codemode.Options{ @@ -17,41 +35,27 @@ builder := codemode.New(codemode.Options{ DisabledCapabilities: []codemode.CapabilityID{ "records.entry.lookup", }, - Limits: codemode.DefaultLimits(), }) ``` -Use the stable ID, `records.entry.lookup`, not the model-facing dotted name, `records.lookup`. IDs are the deployment and authorization identity. Dotted names define discovery and Starlark namespace access. - -Continue to call `codemode.Register` for `records.entry.lookup` before `builder.Build()`. CodeMode validates the complete registration set before applying the filter, and `Build` rejects a disabled ID that does not identify a registered capability. Static filtering cannot hide invalid or conflicting registrations. +Use the stable ID, `records.entry.lookup`, not the model-facing dotted name, +`records.lookup`. IDs are the deployment and authorization identity. Dotted +names define discovery and Starlark namespace access. -The explicit `authz.AllowAll()` remains deliberate only for the simple tutorial. Use `authz.AllowAll()` only when every resolved subject may call every enabled capability; otherwise supply an `authz.Authorizer`. Static filtering and per-call authorization solve different problems: filtering fixes the deployment-wide surface, while the authorizer decides whether a trusted subject may make an enabled native call. +Keep the capability registered before `builder.Build()`. CodeMode validates the +complete registration set before applying the filter, and `Build` rejects a +disabled ID that does not identify a registered capability. Static filtering +cannot hide invalid or conflicting registrations. -## Replace the built server +The explicit `authz.AllowAll()` remains deliberate only for the simple +tutorial. Static filtering fixes the deployment-wide surface; the authorizer +decides whether a trusted subject may make an enabled native call. -After registering all capabilities, call `Build`. Pass the returned server to `mcpserver.New`. To deploy a different enabled set, replace the existing server instance and its host-owned transport lifecycle. +## Replace and verify the server -## Verify the filtered surface - -The tutorial's `callTool` helper returns on the first tool error, so `describe_api` would stop the program before `execute`. Replace the `result.IsError` branch in `callTool` with: - -```go -if result.IsError { - text, ok := result.Content[0].(*mcp.TextContent) - if !ok { - return fmt.Errorf("%s returned a tool error: %v", params.Name, result.Content) - } - fmt.Printf("%s: %s\n", params.Name, text.Text) - return nil -} -``` - -Run `go run .`. The program prints: - -``` -search_api: [] -describe_api: capability not found -execute: invalid program -``` +Build and replace the existing server process, then reload the server in your +agent. `search_api` no longer returns `records.lookup`, `describe_api` reports +`capability not found`, and an `execute` program that references +`records.lookup` reports `invalid program`. See the [public API reference](../reference/public-api.md#static-capability-filtering) for the complete filter contract, and the [MCP tools reference](../reference/mcp-tools.md) for discovery and execution results. diff --git a/docs/docs/how-to/use-rego-authorization.md b/docs/docs/how-to/use-rego-authorization.md index 056833e..5d97957 100644 --- a/docs/docs/how-to/use-rego-authorization.md +++ b/docs/docs/how-to/use-rego-authorization.md @@ -17,7 +17,7 @@ package codemode.authz default allow := false allow if { - input.subject.id == "example-user" + input.subject.id == "local" input.capability.id == "records.entry.lookup" input.arguments.key != "forbidden" } @@ -25,12 +25,17 @@ allow if { Use `input.capability.id` for policy identity. In this example, `records.entry.lookup` is the stable capability ID. `input.capability.name` is the dotted discovery and Starlark name, `records.lookup`; it can change independently of the policy identity. +Set `ID: "records.entry.lookup"` in the tutorial's capability registration +before writing this policy. An omitted ID defaults to `records.lookup`; explicit +policy identity prevents a later capability rename from changing the decision +input. + The adapter supplies only this input shape: ```json { "subject": { - "id": "example-user" + "id": "local" }, "capability": { "id": "records.entry.lookup", @@ -67,7 +72,7 @@ const authorizationPolicy = `package codemode.authz default allow := false allow if { - input.subject.id == "example-user" + input.subject.id == "local" input.capability.id == "records.entry.lookup" input.arguments.key != "forbidden" }` @@ -76,6 +81,7 @@ allow if { Then replace the tutorial's builder initialization with the following code: ```go +ctx := context.Background() authorizer, err := rego.New( ctx, "data.codemode.authz.allow", @@ -84,18 +90,17 @@ authorizer, err := rego.New( }, ) if err != nil { - return fmt.Errorf("prepare authorization policy: %w", err) + log.Fatal(err) } -builder := codemode.New(codemode.Options{ - Authorizer: authorizer, - Limits: codemode.DefaultLimits(), -}) +builder := codemode.New(codemode.Options{Authorizer: authorizer}) ``` -The replacement declares `err`. In the tutorial's next statement, change `err := codemode.Register(` to `err = codemode.Register(` so the program still compiles. - -`rego.New` validates the ground reference syntax, compiles every supplied module, and prepares the policy synchronously. It cannot prove that the decision is defined and Boolean for every future input. Return the error instead of building a server with another authorizer. The returned authorizer is immutable and can serve concurrent calls. +`rego.New` validates the ground reference syntax, compiles every supplied +module, and prepares the policy synchronously. It cannot prove that the +decision is defined and Boolean for every future input; those outcomes are +checked during authorization. Do not build the server when preparation fails. +The returned authorizer is immutable and can serve concurrent calls. To embed the policy, save the same module as `policies/authorization.rego` and replace the string constant with: diff --git a/docs/docs/index.md b/docs/docs/index.md index 9e21ae5..b6da946 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -12,7 +12,7 @@ CodeMode is a Go library that exposes registered, typed Go capabilities to Model Learn the workflow by building a working server. -- [Build your first CodeMode server](tutorials/first-server.md) — register one typed capability and call `search_api`, `describe_api`, and `execute` over the official MCP in-memory transport. +- [Build your first CodeMode server](tutorials/first-server.md) — register one typed capability and run a real stdio MCP server that an agent can call. ## How-to guides diff --git a/docs/docs/reference/public-api.md b/docs/docs/reference/public-api.md index 453b464..48ddb27 100644 --- a/docs/docs/reference/public-api.md +++ b/docs/docs/reference/public-api.md @@ -55,10 +55,10 @@ to perform that setup; `IsWorker` does not serve worker mode. | Field | Contract | | --- | --- | -| `ID CapabilityID` | Non-empty, with no surrounding whitespace. IDs must be unique. Use this value for deployment filters and policy rules. | +| `ID CapabilityID` | Stable deployment and policy identity. An empty ID defaults to `Name`; explicit IDs must have no surrounding whitespace and must be unique. Set it before writing policy or deployment filters that must survive a rename. | | `Name CapabilityName` | A unique dotted Starlark name. A complete capability name cannot also be another capability's namespace. | | `Summary string` | Non-empty compact text searched by `Search`, with no surrounding whitespace. | -| `Description string` | Non-empty detail returned by `Describe`, with no surrounding whitespace. | +| `Description string` | Detail returned by `Describe`. An empty value defaults to `Summary`; an explicit value must have no surrounding whitespace. | | `Handler Handler[Input, Output]` | A non-nil function called after argument binding and authorization. | `Handler[Input, Output]` has this signature: @@ -69,7 +69,7 @@ func(context.Context, authz.Subject, Input) (Output, error) The subject is the trusted subject supplied to `Server.Execute`. The input and output are the exact generic types registered for the capability. -The site examples use one capability contract throughout: +The policy and deployment-filter examples use this explicit capability identity: | Property | Value | | --- | --- | @@ -139,15 +139,17 @@ their discovery types are `list[T]` or `dict[str, T]`. Non-nil empty slices and maps become empty lists and dictionaries. A nil pointer inside a list or map becomes `None`; `omitempty` omits only struct fields. -`Register(builder, capability)` compiles and validates the complete reflected -type graph before retaining the capability. Registration rejects unsupported -input types; pointer roots; interfaces including `any`; `json.RawMessage`; -types whose value or pointer method set implements `json.Marshaler` or +`Register(builder, capability)` compiles the complete reflected type graph. +Capability-specific failures, including nil handlers, invalid metadata, +unsupported types, and duplicate IDs or names, are accumulated and returned +together by `Build`; each diagnostic names the capability. Unsupported types +include pointer roots; interfaces including `any`; `json.RawMessage`; types +whose value or pointer method set implements `json.Marshaler` or `encoding.TextMarshaler`; functions, channels, complex values, `unsafe.Pointer`, and `uintptr`; non-string map keys; cyclic type graphs; and -invalid fields or tags. +invalid fields or tags. `Register` panics for a nil or already closed builder +because no future `Build` call can report those lifecycle violations. -These are registration-time failures and map to `ErrInvalidRegistration`. Returned values are checked at call time. An unsigned result above `math.MaxInt64` or a non-finite float maps to `ErrCapabilityFailure`. Output-depth, per-value byte, and aggregate intermediate-byte exhaustion map to @@ -161,7 +163,7 @@ Output-depth, per-value byte, and aggregate intermediate-byte exhaustion map to | --- | --- | | `Authorizer authz.Authorizer` | Required. Nil and typed-nil implementations are rejected by `Build`. | | `DisabledCapabilities []CapabilityID` | Stable IDs removed when the immutable catalog is built. `New` copies the slice. | -| `Limits Limits` | Positive execution and discovery budgets. `New` copies the value. | +| `Limits Limits` | Execution and discovery budgets. `New` copies the value; `Build` replaces each zero-valued field with its `DefaultLimits()` value. | `New(options)` returns a mutable `*Builder`. A builder is single-threaded and one-shot: @@ -170,19 +172,21 @@ Output-depth, per-value byte, and aggregate intermediate-byte exhaustion map to 3. Use the returned immutable `*Server` concurrently. The first `Build` call closes the builder before full validation. This remains -true when the build fails. Later `Register` and `Build` calls return -`ErrInvalidRegistration`. Create another builder to change registrations, +true when the build fails. A later `Build` returns `ErrInvalidRegistration`, and +a later `Register` panics. Create another builder to change registrations, options, or capability visibility. -`Build` rejects a missing authorizer, non-positive limits, namespace +`Build` returns all capability-specific registration failures as one joined +error. It also rejects a missing authorizer, negative signed limits, namespace collisions, duplicate disabled IDs, and disabled IDs that do not match a -registered capability. It also re-executes the current binary and performs a -fixed five-second private worker probe. The probe detects an absent or -nonfunctional worker entry and returns `ErrInvalidRegistration` with a -host-wiring diagnostic that identifies the missing or misplaced -`ServeWorkerAndExit` call. It cannot detect ordinary host work that completes -silently before `ServeWorkerAndExit`; first-statement ordering is a host -obligation. The fixed probe deadline is independent of `MaxExecutionTime`. +registered capability. It re-executes the current binary and performs a fixed +five-second private worker probe only after construction validation succeeds. +The probe detects an absent or nonfunctional worker entry and returns +`ErrInvalidRegistration` with a host-wiring diagnostic that identifies the +missing or misplaced `ServeWorkerAndExit` call. It cannot detect ordinary host +work that completes silently before `ServeWorkerAndExit`; first-statement +ordering is a host obligation. The fixed probe deadline is independent of +`MaxExecutionTime`. ### Static capability filtering @@ -193,6 +197,9 @@ obligation. The fixed probe deadline is independent of `MaxExecutionTime`. - the Starlark namespace - handler dispatch +When a capability omits `ID`, its dotted `Name` is also its filter identity. +Set `ID` before writing a filter that must remain stable across name changes. + The filter is deployment configuration, not a per-request policy. Use an `authz.Authorizer` for decisions that depend on the subject or validated arguments. ### Limits @@ -212,9 +219,12 @@ The filter is deployment configuration, not a per-request policy. Use an `authz. | `MaxSearchResults int` | 20 | Search results returned. | | `MaxConcurrentExecutions int` | 8 | Concurrent spawn attempts and live worker processes. | -`Limits.Validate()` returns `ErrInvalidRegistration` if any field is zero or -otherwise non-positive. Zero never means unlimited. Passing a zero-value -`Limits` does not select defaults; use `DefaultLimits()` explicitly. +`Build` replaces every zero-valued field with the corresponding value from +`DefaultLimits()`, then validates the complete result. A zero field never means +unlimited. This permits `Limits{}` and partial overrides without restating +unrelated budgets. Negative signed fields return `ErrInvalidRegistration`. +Calling `Limits.Validate()` directly still rejects zero and otherwise +non-positive fields because it validates an already resolved limit set. `MaxValueDepth` is inclusive. A scalar or `None` is depth 1. Each tuple, list, or dictionary wrapper adds one. A scalar with limit 1 succeeds, a one-level @@ -382,6 +392,11 @@ Internal packages and trusted authorizer and handler implementations can hold de Credentials do not belong in `Subject`. The host keeps credentials in its authentication layer and places only the non-secret subject in trusted Go context. +`WithSubject(ctx, subject)` stores a subject under an `authz`-owned private +context key. `SubjectFromContext(ctx)` returns that subject and reports whether +it was present. Authentication middleware must validate credentials before +calling `WithSubject`; these functions do not authenticate a caller. + ### Authorization input `AuthorizationInput` contains: @@ -499,7 +514,18 @@ A root `*codemode.Server` implements `Service`. The adapter relies on the servic Resolve(context.Context) (authz.Subject, error) ``` -The resolver reads a subject from typed, host-owned Go context. It must not derive identity from tool arguments, Starlark source, MCP `_meta`, or other client-controlled request metadata. A resolver error or empty subject ID stops every tool before discovery or execution. A resolver must not return credential material. +Every valid MCP tool call runs the resolver before discovery or execution. A +resolver error or empty subject ID stops the operation. Resolvers must not +derive identity from tool arguments, Starlark source, MCP `_meta`, or other +client-controlled metadata, and must not return credential material. + +`StaticSubject(subject)` returns a resolver that uses one fixed identity. It is +only for single-user transports where process ownership is the authentication +boundary, such as a local stdio server. Multi-user hosts must not use it. + +`ContextSubject()` reads subjects stored with `authz.WithSubject`. The host's +authentication middleware remains responsible for validating credentials and +installing a subject for each request. ### `New` diff --git a/docs/docs/tutorials/first-server.md b/docs/docs/tutorials/first-server.md index 6b1663d..f6feb5b 100644 --- a/docs/docs/tutorials/first-server.md +++ b/docs/docs/tutorials/first-server.md @@ -1,11 +1,12 @@ --- title: Build your first CodeMode server -description: Register a typed Go capability and call it through the official MCP in-memory transport. +description: Register a typed Go capability and expose it through a real stdio MCP server. --- # Build your first CodeMode server -This tutorial builds an MCP server with one typed capability. You will connect an official MCP client over the SDK's in-memory transport and call `search_api`, `describe_api`, and `execute`. +This tutorial builds a stdio MCP server with one typed capability. After you add +the server to an agent, the agent can discover and call `records.lookup`. ## Prerequisites @@ -13,7 +14,8 @@ The repository currently requires Go 1.26.6. ## Create a module -CodeMode has not published a release. To follow the tutorial against the current `master` branch, create a module and add CodeMode and the official MCP Go SDK: +CodeMode has not published a release. Create a module and add CodeMode and the +official MCP Go SDK from their current default branches: ```sh mkdir codemode-first-server @@ -24,15 +26,13 @@ go get github.com/meigma/codemode@master github.com/modelcontextprotocol/go-sdk/ ## Add the server -Create `main.go` with the following program: +Create `main.go`: ```go package main import ( "context" - "encoding/json" - "fmt" "log" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -42,185 +42,108 @@ import ( "github.com/meigma/codemode/mcpserver" ) -// invocationContextKey is the private key for trusted invocation context. -type invocationContextKey struct{} - -// lookupInput is the records.lookup input contract. type lookupInput struct { - // Key is the required record identifier. - Key string `json:"key"` - - // Limit is the optional result limit. + Key string `json:"key"` Limit *int64 `json:"limit,omitempty"` } -// lookupOutput is the records.lookup output contract. type lookupOutput struct { - // Key is the record identifier returned by the handler. - Key string `json:"key"` - - // Count is the limit observed by the handler. - Count int64 `json:"count"` + Key string `json:"key"` + Count int64 `json:"count"` } -// subjectResolver reads the authenticated subject from host-owned Go context. -type subjectResolver struct{} - -// Resolve returns the authenticated subject stored by the host. -func (subjectResolver) Resolve(ctx context.Context) (authz.Subject, error) { - subject, ok := ctx.Value(invocationContextKey{}).(authz.Subject) - if !ok || subject.ID == "" { - return authz.Subject{}, codemode.ErrUnauthenticated - } - return subject, nil -} - -// withSubject stores a non-secret authenticated subject in trusted host context. -func withSubject(ctx context.Context, subject authz.Subject) context.Context { - return context.WithValue(ctx, invocationContextKey{}, subject) -} - -// lookupRecords returns a deterministic result for the tutorial capability. -func lookupRecords(_ context.Context, _ authz.Subject, input lookupInput) (lookupOutput, error) { +func lookup(_ context.Context, _ authz.Subject, in lookupInput) (lookupOutput, error) { count := int64(0) - if input.Limit != nil { - count = *input.Limit + if in.Limit != nil { + count = *in.Limit } - return lookupOutput{Key: input.Key, Count: count}, nil + return lookupOutput{Key: in.Key, Count: count}, nil } -// callTool calls one MCP tool and prints its structured output. -func callTool(ctx context.Context, session *mcp.ClientSession, params *mcp.CallToolParams) error { - result, err := session.CallTool(ctx, params) - if err != nil { - return fmt.Errorf("call %s: %w", params.Name, err) - } - if result.IsError { - return fmt.Errorf("%s returned a tool error: %v", params.Name, result.Content) - } - - encoded, err := json.Marshal(result.StructuredContent) - if err != nil { - return fmt.Errorf("encode %s result: %w", params.Name, err) - } - fmt.Printf("%s: %s\n", params.Name, encoded) - return nil -} +func main() { + codemode.ServeWorkerAndExit() -// run builds the server, connects the in-memory transport, and calls each tool. -func run(ctx context.Context) error { - builder := codemode.New(codemode.Options{ - Authorizer: authz.AllowAll(), - Limits: codemode.DefaultLimits(), - }) + builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll()}) - err := codemode.Register(builder, codemode.Capability[lookupInput, lookupOutput]{ - ID: "records.entry.lookup", - Name: "records.lookup", - Summary: "Look up one record by key.", - Description: "Returns one deterministic record for the supplied key.", - Handler: lookupRecords, + codemode.Register(builder, codemode.Capability[lookupInput, lookupOutput]{ + Name: "records.lookup", + Summary: "Look up one record by key.", + Handler: lookup, }) - if err != nil { - return fmt.Errorf("register capability: %w", err) - } server, err := builder.Build() if err != nil { - return fmt.Errorf("build CodeMode server: %w", err) - } - mcpServer, err := mcpserver.New(server, subjectResolver{}) - if err != nil { - return fmt.Errorf("build MCP server: %w", err) - } - - serverTransport, clientTransport := mcp.NewInMemoryTransports() - serverContext := withSubject(ctx, authz.Subject{ID: "example-user"}) - serverSession, err := mcpServer.Connect(serverContext, serverTransport, nil) - if err != nil { - return fmt.Errorf("connect MCP server: %w", err) + log.Fatal(err) } - defer serverSession.Close() - client := mcp.NewClient(&mcp.Implementation{ - Name: "codemode-first-server", - Version: "tutorial", - }, nil) - clientSession, err := client.Connect(ctx, clientTransport, nil) + srv, err := mcpserver.New(server, mcpserver.StaticSubject(authz.Subject{ID: "local"})) if err != nil { - return fmt.Errorf("connect MCP client: %w", err) - } - defer clientSession.Close() - - if err := callTool(ctx, clientSession, &mcp.CallToolParams{ - Name: "search_api", - Arguments: map[string]any{"query": "record"}, - }); err != nil { - return err - } - if err := callTool(ctx, clientSession, &mcp.CallToolParams{ - Name: "describe_api", - Arguments: map[string]any{"name": "records.lookup"}, - }); err != nil { - return err - } - return callTool(ctx, clientSession, &mcp.CallToolParams{ - Name: "execute", - Arguments: map[string]any{ - "source": `def main(): - return records.lookup(key="alpha", limit=2) -`, - }, - }) -} - -// main serves re-executed workers before starting the ordinary host. -func main() { - codemode.ServeWorkerAndExit() - if err := run(context.Background()); err != nil { log.Fatal(err) } + log.Fatal(srv.Run(context.Background(), &mcp.StdioTransport{})) } ``` -`authz.AllowAll()` is deliberate in this small example: it makes the authorization decision explicit without adding application policy, and it permits every native call whose arguments bind successfully. Use `authz.AllowAll()` only when every resolved subject may call every enabled capability; otherwise supply an `authz.Authorizer` that evaluates the trusted subject, stable capability ID, exposed name, and canonical arguments. +`authz.AllowAll()` is deliberate. CodeMode has no default authorizer, so +disabling authorization requires an explicit decision. -The resolver reads only the typed, server-side Go context. In a production host, authentication middleware establishes that context before the MCP server handles a request. Do not accept a subject or credentials from Starlark source, tool arguments, or MCP `_meta`. +`StaticSubject` matches this single-user stdio deployment: possession of the +process is the authentication boundary. A multi-user host must not use +`StaticSubject`. Authenticate each request, store the resulting identity with +`authz.WithSubject`, and use `mcpserver.ContextSubject`. -## Run the server +`Build` supplies bounded defaults for each zero-valued `Limits` field. It also +reports all invalid registrations together. An omitted capability `ID` defaults +to `Name`; set `ID` explicitly before writing authorization policy or deployment +filters that must survive a capability rename. An omitted `Description` +defaults to `Summary`. -Clean up the module metadata: +## Build and configure the server + +Clean up the module metadata, then build the server: ```sh go mod tidy +go build -o codemode-first-server . ``` -Run the program: +Add the absolute binary path to your agent's MCP configuration. For agents that +use the common `mcpServers` shape: -```sh -go run . +```json +{ + "mcpServers": { + "codemode-first-server": { + "command": "/absolute/path/to/codemode-first-server" + } + } +} ``` -The program prints structured results for all three tools. The final line contains an `execute` result equivalent to: +Restart or reload the agent's MCP servers. Ask the agent to search for a record +capability and call `records.lookup` with `key="alpha"` and `limit=2`. The agent +can use `search_api`, `describe_api`, and `execute`; the final structured result +is equivalent to: ```json {"result":{"count":2,"key":"alpha"}} ``` -`execute` first binds the keyword arguments in the worker. The parent rebinds -them to `lookupInput`, creates a fresh canonical authorization map, authorizes -the native call, and then dispatches `lookupRecords`. The parent converts the -handler output to a process-neutral value, and the worker converts it to -Starlark. Each `execute` call runs a fresh bounded interpreter in a re-executed -worker process. Only the final converted value returned by a zero-argument -`main()` is exposed in the successful MCP result; printed text, globals, and -interpreter-local intermediate values are not returned. Each native-call -argument map, native result, and final value is independently subject to -`MaxValueDepth` and `MaxValueBytes`. Successful native-result value bodies also -consume the fresh request-scoped `MaxIntermediateValueBytes` budget. - -`ServeWorkerAndExit` must be the first statement in `main`, before flag parsing, credential loading, client construction, or other application setup. It returns immediately in the ordinary host. In a re-executed worker, it serves one private probe or execution request and terminates the process. - -The in-memory MCP transport keeps the client and server in one parent process; Starlark still runs in fresh workers. In an application, the host owns authentication, transport selection and startup, listeners, request cancellation, and shutdown. A Go authorizer or handler that can block must honor its context. CodeMode can kill the Starlark worker, but it cannot forcibly stop Go code after parent dispatch. - -For shorter compile-checked forms of this setup, see the [typed registration example](https://github.com/meigma/codemode/blob/master/example_test.go) and [official transport example](https://github.com/meigma/codemode/blob/master/mcpserver/example_test.go). +`ServeWorkerAndExit` must be the first statement in `main`, before flag parsing, +credential loading, client construction, or other setup. `execute` first binds +the keyword arguments in the worker. The parent rebinds them to `lookupInput`, +creates a fresh canonical authorization map, authorizes the native call, and +then dispatches `lookup`. The parent converts the handler output to a +process-neutral value, and the worker converts it to Starlark. Each `execute` +call runs a fresh bounded interpreter in a re-executed worker process. Only the +final converted value returned by a zero-argument `main()` is exposed in the +successful MCP result; printed text, globals, and interpreter-local intermediate +values are not returned. Each native-call argument map, native result, and final +value is independently subject to `MaxValueDepth` and `MaxValueBytes`. +Successful native-result value bodies also consume the fresh request-scoped +`MaxIntermediateValueBytes` budget. + +For shorter compile-checked forms, see the [typed registration +example](https://github.com/meigma/codemode/blob/master/example_test.go) and +[official transport +example](https://github.com/meigma/codemode/blob/master/mcpserver/example_test.go). diff --git a/example_test.go b/example_test.go index 66e72ba..1e86476 100644 --- a/example_test.go +++ b/example_test.go @@ -32,15 +32,10 @@ func Example_registerAndExecute() { Count int64 `json:"count"` } - builder := codemode.New(codemode.Options{ - Authorizer: authz.AllowAll(), - Limits: codemode.DefaultLimits(), - }) - err := codemode.Register(builder, codemode.Capability[lookupInput, lookupOutput]{ - ID: "records.entry.lookup", - Name: "records.lookup", - Summary: "Look up one record by key.", - Description: "Returns one deterministic record for the supplied key.", + builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll()}) + codemode.Register(builder, codemode.Capability[lookupInput, lookupOutput]{ + Name: "records.lookup", + Summary: "Look up one record by key.", Handler: func(_ context.Context, _ authz.Subject, input lookupInput) (lookupOutput, error) { count := int64(0) if input.Limit != nil { @@ -49,9 +44,6 @@ func Example_registerAndExecute() { return lookupOutput{Key: input.Key, Count: count}, nil }, }) - if err != nil { - panic(err) - } server, err := builder.Build() if err != nil { diff --git a/limits.go b/limits.go index 4736b8d..6070537 100644 --- a/limits.go +++ b/limits.go @@ -79,7 +79,44 @@ func DefaultLimits() Limits { } } -// Validate rejects zero and negative limits; zero never means unlimited. +// withDefaults replaces each zero-valued field with its bounded default. +func (limits Limits) withDefaults() Limits { + defaults := DefaultLimits() + if limits.MaxSourceBytes == 0 { + limits.MaxSourceBytes = defaults.MaxSourceBytes + } + if limits.MaxExecutionSteps == 0 { + limits.MaxExecutionSteps = defaults.MaxExecutionSteps + } + if limits.MaxExecutionTime == 0 { + limits.MaxExecutionTime = defaults.MaxExecutionTime + } + if limits.MaxNativeCalls == 0 { + limits.MaxNativeCalls = defaults.MaxNativeCalls + } + if limits.MaxValueDepth == 0 { + limits.MaxValueDepth = defaults.MaxValueDepth + } + if limits.MaxValueBytes == 0 { + limits.MaxValueBytes = defaults.MaxValueBytes + } + if limits.MaxIntermediateValueBytes == 0 { + limits.MaxIntermediateValueBytes = defaults.MaxIntermediateValueBytes + } + if limits.MaxSearchQueryBytes == 0 { + limits.MaxSearchQueryBytes = defaults.MaxSearchQueryBytes + } + if limits.MaxSearchResults == 0 { + limits.MaxSearchResults = defaults.MaxSearchResults + } + if limits.MaxConcurrentExecutions == 0 { + limits.MaxConcurrentExecutions = defaults.MaxConcurrentExecutions + } + return limits +} + +// Validate rejects non-positive limits; Build replaces zero-valued fields with +// bounded defaults before validation. func (limits Limits) Validate() error { switch { case limits.MaxSourceBytes <= 0: diff --git a/limits_test.go b/limits_test.go index 01098fe..e7fe59c 100644 --- a/limits_test.go +++ b/limits_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/meigma/codemode" + "github.com/meigma/codemode/authz" ) // TestDefaultLimitsAreValid proves every exact default budget is positive. @@ -27,6 +28,31 @@ func TestDefaultLimitsAreValid(t *testing.T) { assert.Equal(t, 8, limits.MaxConcurrentExecutions) } +// TestBuildDefaultsZeroLimitFields proves zero values are bounded defaults and +// a partial override does not require restating unrelated budgets. +func TestBuildDefaultsZeroLimitFields(t *testing.T) { + defaultBuilder := codemode.New(codemode.Options{Authorizer: authz.AllowAll()}) + + defaultServer, err := defaultBuilder.Build() + + require.NoError(t, err) + require.NotNil(t, defaultServer) + + partialBuilder := codemode.New(codemode.Options{ + Authorizer: authz.AllowAll(), + Limits: codemode.Limits{MaxSearchResults: 1}, + }) + codemode.Register(partialBuilder, validBuilderCapability("cap.one", "records.one")) + codemode.Register(partialBuilder, validBuilderCapability("cap.two", "records.two")) + + partialServer, err := partialBuilder.Build() + + require.NoError(t, err) + results, err := partialServer.Search("records") + require.NoError(t, err) + assert.Len(t, results, 1) +} + // TestLimitsRejectNonPositiveValues proves zero never selects an unlimited budget. func TestLimitsRejectNonPositiveValues(t *testing.T) { tests := []struct { diff --git a/mcpserver/doc.go b/mcpserver/doc.go index 528eb30..dd6ce8e 100644 --- a/mcpserver/doc.go +++ b/mcpserver/doc.go @@ -1,6 +1,9 @@ // 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. +// The adapter resolves a trusted subject through the required +// InvocationResolver before every operation, ignores untrusted client metadata, +// and projects failures to stable coarse tool text. StaticSubject supports +// single-user process-owned identity; multi-user hosts must resolve each +// authenticated request separately. The adapter does not proxy arbitrary +// downstream MCP tools. package mcpserver diff --git a/mcpserver/e2e_test.go b/mcpserver/e2e_test.go index 240a047..65ac594 100644 --- a/mcpserver/e2e_test.go +++ b/mcpserver/e2e_test.go @@ -277,14 +277,14 @@ func TestActualMCPSecureLoop(t *testing.T) { DisabledCapabilities: []codemode.CapabilityID{"records.entry.hidden"}, Limits: codemode.DefaultLimits(), }) - require.NoError(t, codemode.Register(builder, codemode.Capability[lookupInput, lookupResult]{ + 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]{ + }) + codemode.Register(builder, codemode.Capability[lookupInput, lookupResult]{ ID: "records.entry.hidden", Name: "records.hidden", Summary: "Look up one hidden record by key.", @@ -293,8 +293,8 @@ func TestActualMCPSecureLoop(t *testing.T) { hiddenCalls.Add(1) return lookupResult{}, errors.New("disabled capability invoked") }, - })) - require.NoError(t, codemode.Register(builder, codemode.Capability[struct{}, StatusResult]{ + }) + codemode.Register(builder, codemode.Capability[struct{}, StatusResult]{ ID: "health.entry.status", Name: "health.status", Summary: "Report current health.", @@ -302,7 +302,7 @@ func TestActualMCPSecureLoop(t *testing.T) { Handler: func(context.Context, authz.Subject, struct{}) (StatusResult, error) { return StatusResult{State: "ok"}, nil }, - })) + }) root, err := builder.Build() require.NoError(t, err) @@ -503,13 +503,13 @@ func TestActualMCPCompositeProgram(t *testing.T) { Authorizer: authorizer, Limits: codemode.DefaultLimits(), }) - require.NoError(t, codemode.Register(builder, codemode.Capability[searchInput, searchOutput]{ + codemode.Register(builder, codemode.Capability[searchInput, searchOutput]{ ID: "records.entry.search", Name: "records.search", Summary: "Search records and return nested items.", Description: "Returns multiple nested rows for one widened search call.", Handler: search.invoke, - })) + }) root, err := builder.Build() require.NoError(t, err) diff --git a/mcpserver/example_test.go b/mcpserver/example_test.go index 5ccfb29..8491135 100644 --- a/mcpserver/example_test.go +++ b/mcpserver/example_test.go @@ -12,34 +12,12 @@ import ( "github.com/meigma/codemode/mcpserver" ) -// subjectKey is the typed host-owned context key for the authenticated subject. -type subjectKey struct{} - -// subjectResolver reads the trusted subject from typed host-owned Go context. -type subjectResolver struct{} - -// Resolve returns the authenticated subject installed by the host. -// -// Resolve does not read tool arguments, program source, or request metadata. -func (subjectResolver) Resolve(ctx context.Context) (authz.Subject, error) { - subject, ok := ctx.Value(subjectKey{}).(authz.Subject) - if !ok || subject.ID == "" { - return authz.Subject{}, codemode.ErrUnauthenticated - } - return subject, nil -} - -// withSubject stores a non-secret authenticated subject in trusted host context. -func withSubject(ctx context.Context, subject authz.Subject) context.Context { - return context.WithValue(ctx, subjectKey{}, subject) -} - // Example_officialTransport connects official MCP sessions and calls execute. // -// The host owns authentication, transport, listeners, cancellation, and -// shutdown. Trusted subjects come from typed host-owned Go context, never from -// tool arguments, source, or _meta. authz.AllowAll is deliberate in this -// sample; production hosts normally supply policy. +// This in-memory sample has one process-owned identity, matching a single-user +// stdio host. Multi-user hosts must resolve each request from authenticated, +// host-owned context instead. authz.AllowAll is deliberate in this sample; +// production hosts normally supply policy. func Example_officialTransport() { // lookupInput is the records.lookup argument contract. type lookupInput struct { @@ -59,15 +37,10 @@ func Example_officialTransport() { Count int64 `json:"count"` } - builder := codemode.New(codemode.Options{ - Authorizer: authz.AllowAll(), - Limits: codemode.DefaultLimits(), - }) - err := codemode.Register(builder, codemode.Capability[lookupInput, lookupOutput]{ - ID: "records.entry.lookup", - Name: "records.lookup", - Summary: "Look up one record by key.", - Description: "Returns one deterministic record for the supplied key.", + builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll()}) + codemode.Register(builder, codemode.Capability[lookupInput, lookupOutput]{ + Name: "records.lookup", + Summary: "Look up one record by key.", Handler: func(_ context.Context, _ authz.Subject, input lookupInput) (lookupOutput, error) { count := int64(0) if input.Limit != nil { @@ -76,23 +49,19 @@ func Example_officialTransport() { return lookupOutput{Key: input.Key, Count: count}, nil }, }) - if err != nil { - panic(err) - } root, err := builder.Build() if err != nil { panic(err) } - mcpServer, err := mcpserver.New(root, subjectResolver{}) + mcpServer, err := mcpserver.New(root, mcpserver.StaticSubject(authz.Subject{ID: "example-user"})) if err != nil { panic(err) } - trusted := withSubject(context.Background(), authz.Subject{ID: "example-user"}) serverTransport, clientTransport := mcp.NewInMemoryTransports() - serverSession, err := mcpServer.Connect(trusted, serverTransport, nil) + serverSession, err := mcpServer.Connect(context.Background(), serverTransport, nil) if err != nil { panic(err) } diff --git a/mcpserver/resolver.go b/mcpserver/resolver.go index 7031a87..d38d28d 100644 --- a/mcpserver/resolver.go +++ b/mcpserver/resolver.go @@ -3,6 +3,7 @@ package mcpserver import ( "context" + "github.com/meigma/codemode" "github.com/meigma/codemode/authz" ) @@ -18,3 +19,48 @@ type InvocationResolver interface { // execution. Resolve must not return credential material. Resolve(ctx context.Context) (authz.Subject, error) } + +// staticSubjectResolver returns one process-owned identity for every invocation. +type staticSubjectResolver struct { + // subject is the fixed trusted identity supplied by the host. + subject authz.Subject +} + +// Resolve returns the fixed trusted subject. +func (resolver staticSubjectResolver) Resolve(context.Context) (authz.Subject, error) { + if resolver.subject.ID == "" { + return authz.Subject{}, codemode.ErrUnauthenticated + } + return resolver.subject, nil +} + +// contextSubjectResolver reads identity from authz-owned trusted context. +type contextSubjectResolver struct{} + +// Resolve returns a non-empty subject installed with authz.WithSubject. +func (contextSubjectResolver) Resolve(ctx context.Context) (authz.Subject, error) { + subject, ok := authz.SubjectFromContext(ctx) + if !ok || subject.ID == "" { + return authz.Subject{}, codemode.ErrUnauthenticated + } + return subject, nil +} + +// StaticSubject returns a resolver that uses subject for every invocation. +// +// StaticSubject is suitable only when process ownership is the authentication +// boundary, such as a single-user stdio server. Multi-user hosts must resolve a +// distinct authenticated subject for each request and must not use +// StaticSubject. +func StaticSubject(subject authz.Subject) InvocationResolver { + return staticSubjectResolver{subject: subject} +} + +// ContextSubject returns a resolver for subjects installed with authz.WithSubject. +// +// Authentication middleware remains responsible for validating credentials +// and installing the subject. Client-controlled tool arguments, program source, +// and MCP request metadata cannot set or replace the stored subject. +func ContextSubject() InvocationResolver { + return contextSubjectResolver{} +} diff --git a/mcpserver/resolver_test.go b/mcpserver/resolver_test.go index 1614e63..df971c1 100644 --- a/mcpserver/resolver_test.go +++ b/mcpserver/resolver_test.go @@ -1,6 +1,7 @@ package mcpserver_test import ( + "context" "errors" "testing" @@ -11,9 +12,76 @@ import ( "github.com/meigma/codemode" "github.com/meigma/codemode/authz" + "github.com/meigma/codemode/mcpserver" "github.com/meigma/codemode/mcpserver/mocks" ) +// TestStockResolversResolveTrustedSubjects proves fixed and context-owned +// identities succeed while missing or empty identities fail closed. +func TestStockResolversResolveTrustedSubjects(t *testing.T) { + subject := authz.Subject{ID: "subject-1"} + tests := []struct { + // name identifies the resolver behavior. + name string + + // resolver is the stock resolver under test. + resolver mcpserver.InvocationResolver + + // ctx is the host-owned invocation context. + ctx context.Context + + // want is the expected trusted identity. + want authz.Subject + + // wantError reports whether resolution must fail closed. + wantError bool + }{ + { + name: "static subject", + resolver: mcpserver.StaticSubject(subject), + ctx: context.Background(), + want: subject, + }, + { + name: "empty static subject", + resolver: mcpserver.StaticSubject(authz.Subject{}), + ctx: context.Background(), + wantError: true, + }, + { + name: "context subject", + resolver: mcpserver.ContextSubject(), + ctx: authz.WithSubject(context.Background(), subject), + want: subject, + }, + { + name: "missing context subject", + resolver: mcpserver.ContextSubject(), + ctx: context.Background(), + wantError: true, + }, + { + name: "empty context subject", + resolver: mcpserver.ContextSubject(), + ctx: authz.WithSubject(context.Background(), authz.Subject{}), + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.resolver.Resolve(tt.ctx) + if tt.wantError { + require.ErrorIs(t, err, codemode.ErrUnauthenticated) + assert.Empty(t, got) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + // TestResolverFailureStopsEveryToolBeforeServiceWork proves authentication precedes all three operations. func TestResolverFailureStopsEveryToolBeforeServiceWork(t *testing.T) { tools := []struct { @@ -52,8 +120,7 @@ func TestResolverFailureStopsEveryToolBeforeServiceWork(t *testing.T) { // 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() + resolver := mcpserver.StaticSubject(authz.Subject{}) session := newTestSession(t, service, resolver) result, err := session.client.CallTool(t.Context(), &mcp.CallToolParams{ diff --git a/mcpserver/server_test.go b/mcpserver/server_test.go index a432809..49cdfbc 100644 --- a/mcpserver/server_test.go +++ b/mcpserver/server_test.go @@ -876,7 +876,7 @@ func buildRegoPolicyServer(t *testing.T, module string, handlerCalls *atomic.Int t.Helper() authorizer := mustRegoAuthorizer(t, module) builder := codemode.New(codemode.Options{Authorizer: authorizer, Limits: codemode.DefaultLimits()}) - require.NoError(t, codemode.Register(builder, codemode.Capability[policyLookupInput, policyLookupResult]{ + codemode.Register(builder, codemode.Capability[policyLookupInput, policyLookupResult]{ ID: "cap.lookup", Name: "records.lookup", Summary: "Return one record.", @@ -885,7 +885,7 @@ func buildRegoPolicyServer(t *testing.T, module string, handlerCalls *atomic.Int handlerCalls.Add(1) return policyLookupResult{}, nil }, - })) + }) server, err := builder.Build() require.NoError(t, err) return server diff --git a/server_test.go b/server_test.go index d094d0f..975823b 100644 --- a/server_test.go +++ b/server_test.go @@ -26,8 +26,8 @@ func TestServerSearchAndDescribeExposeOnlyEnabledCapabilities(t *testing.T) { DisabledCapabilities: []codemode.CapabilityID{"cap.disabled"}, Limits: codemode.DefaultLimits(), }) - require.NoError(t, codemode.Register(builder, validBuilderCapability("cap.alpha", "records.alpha"))) - require.NoError(t, codemode.Register(builder, validBuilderCapability("cap.disabled", "records.disabled"))) + codemode.Register(builder, validBuilderCapability("cap.alpha", "records.alpha")) + codemode.Register(builder, validBuilderCapability("cap.disabled", "records.disabled")) server, err := builder.Build() require.NoError(t, err) @@ -42,6 +42,51 @@ func TestServerSearchAndDescribeExposeOnlyEnabledCapabilities(t *testing.T) { require.ErrorIs(t, err, codemode.ErrNotFound) } +// TestCapabilityIDDefaultsToName proves derived policy identity participates in +// deployment filtering and authorization. +func TestCapabilityIDDefaultsToName(t *testing.T) { + t.Run("deployment filter", func(t *testing.T) { + builder := codemode.New(codemode.Options{ + Authorizer: authz.AllowAll(), + DisabledCapabilities: []codemode.CapabilityID{"records.lookup"}, + }) + codemode.Register(builder, validBuilderCapability("", "records.lookup")) + + server, err := builder.Build() + + require.NoError(t, err) + results, err := server.Search("") + require.NoError(t, err) + assert.Empty(t, results) + }) + + t.Run("authorization", func(t *testing.T) { + authorizer := authzmocks.NewMockAuthorizer(t) + authorizer.EXPECT().Authorize( + mock.Anything, + mock.MatchedBy(func(input authz.AuthorizationInput) bool { + return input.CapabilityID == "records.lookup" && + input.CapabilityName == "records.lookup" + }), + ).Return(nil).Once() + capability := validBuilderCapability("", "records.lookup") + capability.Description = "" + server := buildTestServer(t, authorizer, codemode.Limits{}, capability) + + description, err := server.Describe("records.lookup") + require.NoError(t, err) + assert.Equal(t, capability.Summary, description.Description) + + result, err := server.Execute(t.Context(), authz.Subject{ID: "subject-1"}, ` +def main(): + return records.lookup(value="alpha") +`) + + require.NoError(t, err) + assert.Equal(t, map[string]any{"value": "alpha"}, result) + }) +} + // TestServerSearchProjectsQueryLimits proves internal discovery details do not escape the public taxonomy. func TestServerSearchProjectsQueryLimits(t *testing.T) { limits := codemode.DefaultLimits() @@ -433,13 +478,13 @@ func TestServerExecuteProjectsCompositeOutputFailures(t *testing.T) { capabilityName string // register retains one capability that returns an invalid composite value. - register func(*codemode.Builder) error + register func(*codemode.Builder) }{ { name: "nested NaN", capabilityName: "records.nan", - register: func(builder *codemode.Builder) error { - return codemode.Register(builder, codemode.Capability[compositeProjectionInput, nanProjectionOutput]{ + register: func(builder *codemode.Builder) { + codemode.Register(builder, codemode.Capability[compositeProjectionInput, nanProjectionOutput]{ ID: "cap.nan", Name: "records.nan", Summary: "Return a NaN score.", @@ -453,8 +498,8 @@ func TestServerExecuteProjectsCompositeOutputFailures(t *testing.T) { { name: "nested Inf", capabilityName: "records.inf", - register: func(builder *codemode.Builder) error { - return codemode.Register(builder, codemode.Capability[compositeProjectionInput, nanProjectionOutput]{ + register: func(builder *codemode.Builder) { + codemode.Register(builder, codemode.Capability[compositeProjectionInput, nanProjectionOutput]{ ID: "cap.inf", Name: "records.inf", Summary: "Return an infinite score.", @@ -468,9 +513,8 @@ func TestServerExecuteProjectsCompositeOutputFailures(t *testing.T) { { name: "uint overflow", capabilityName: "records.overflow", - register: func(builder *codemode.Builder) error { - return codemode.Register( - builder, + register: func(builder *codemode.Builder) { + codemode.Register(builder, codemode.Capability[compositeProjectionInput, overflowProjectionOutput]{ ID: "cap.overflow", Name: "records.overflow", @@ -479,8 +523,7 @@ func TestServerExecuteProjectsCompositeOutputFailures(t *testing.T) { Handler: func(context.Context, authz.Subject, compositeProjectionInput) (overflowProjectionOutput, error) { return overflowProjectionOutput{Count: uint64(1) << 63}, nil }, - }, - ) + }) }, }, } @@ -488,7 +531,7 @@ func TestServerExecuteProjectsCompositeOutputFailures(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll(), Limits: codemode.DefaultLimits()}) - require.NoError(t, tt.register(builder)) + tt.register(builder) server, err := builder.Build() require.NoError(t, err) @@ -512,18 +555,15 @@ func TestServerExecuteProjectsCompositeValueLimits(t *testing.T) { limits.MaxValueBytes = 1024 limits.MaxIntermediateValueBytes = 1024 builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll(), Limits: limits}) - require.NoError( - t, - codemode.Register(builder, codemode.Capability[compositeProjectionInput, nestedProjectionOutput]{ - ID: "cap.depth", - Name: "records.depth", - Summary: "Return nested items.", - Description: "Projects composite depth exhaustion as a resource limit.", - Handler: func(context.Context, authz.Subject, compositeProjectionInput) (nestedProjectionOutput, error) { - return nestedProjectionOutput{Items: []nestedProjectionItem{{ID: "a"}}}, nil - }, - }), - ) + codemode.Register(builder, codemode.Capability[compositeProjectionInput, nestedProjectionOutput]{ + ID: "cap.depth", + Name: "records.depth", + Summary: "Return nested items.", + Description: "Projects composite depth exhaustion as a resource limit.", + Handler: func(context.Context, authz.Subject, compositeProjectionInput) (nestedProjectionOutput, error) { + return nestedProjectionOutput{Items: []nestedProjectionItem{{ID: "a"}}}, nil + }, + }) server, err := builder.Build() require.NoError(t, err) @@ -543,19 +583,16 @@ def main(): limits.MaxIntermediateValueBytes = 1024 var handlerCalls atomic.Int64 builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll(), Limits: limits}) - require.NoError( - t, - codemode.Register(builder, codemode.Capability[compositeProjectionInput, nestedProjectionOutput]{ - ID: "cap.pervalue", - Name: "records.pervalue", - Summary: "Return one nested item.", - Description: "Projects one oversized composite result independently of the aggregate budget.", - Handler: func(context.Context, authz.Subject, compositeProjectionInput) (nestedProjectionOutput, error) { - handlerCalls.Add(1) - return nestedProjectionOutput{Items: []nestedProjectionItem{{ID: "xx"}}}, nil - }, - }), - ) + codemode.Register(builder, codemode.Capability[compositeProjectionInput, nestedProjectionOutput]{ + ID: "cap.pervalue", + Name: "records.pervalue", + Summary: "Return one nested item.", + Description: "Projects one oversized composite result independently of the aggregate budget.", + Handler: func(context.Context, authz.Subject, compositeProjectionInput) (nestedProjectionOutput, error) { + handlerCalls.Add(1) + return nestedProjectionOutput{Items: []nestedProjectionItem{{ID: "xx"}}}, nil + }, + }) server, err := builder.Build() require.NoError(t, err) @@ -576,19 +613,16 @@ def main(): limits.MaxIntermediateValueBytes = len(body)*2 - 1 var handlerCalls atomic.Int64 builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll(), Limits: limits}) - require.NoError( - t, - codemode.Register(builder, codemode.Capability[compositeProjectionInput, nestedProjectionOutput]{ - ID: "cap.aggregate", - Name: "records.aggregate", - Summary: "Return one nested item.", - Description: "Projects composite aggregate exhaustion independently of MaxValueBytes.", - Handler: func(context.Context, authz.Subject, compositeProjectionInput) (nestedProjectionOutput, error) { - handlerCalls.Add(1) - return nestedProjectionOutput{Items: []nestedProjectionItem{{ID: "xx"}}}, nil - }, - }), - ) + codemode.Register(builder, codemode.Capability[compositeProjectionInput, nestedProjectionOutput]{ + ID: "cap.aggregate", + Name: "records.aggregate", + Summary: "Return one nested item.", + Description: "Projects composite aggregate exhaustion independently of MaxValueBytes.", + Handler: func(context.Context, authz.Subject, compositeProjectionInput) (nestedProjectionOutput, error) { + handlerCalls.Add(1) + return nestedProjectionOutput{Items: []nestedProjectionItem{{ID: "xx"}}}, nil + }, + }) server, err := builder.Build() require.NoError(t, err) @@ -728,7 +762,7 @@ func buildTestServer( ) *codemode.Server { t.Helper() builder := codemode.New(codemode.Options{Authorizer: authorizer, Limits: limits}) - require.NoError(t, codemode.Register(builder, capability)) + codemode.Register(builder, capability) server, err := builder.Build() require.NoError(t, err) return server