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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 39 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions authz/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
85 changes: 66 additions & 19 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package codemode

import (
"context"
"errors"
"fmt"
"reflect"
"slices"
Expand All @@ -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
}

Expand All @@ -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
}
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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)
}
Expand Down
Loading