Re-verified against 3b541fc after the fresh-process worker cutover (#26–#31). All four items remain applicable and unimplemented; the target example and acceptance are amended for the new mandatory ServeWorkerAndExit worker entry.
Problem
The first-touch developer experience is heavy. The API front-loads several concepts before hello-world:
- Dual identity (
ID + Name) on every capability (ValidateRegistration rejects an empty ID)
- Explicit
Limits — a zero value fails Build, and the struct is now nine fields (MaxValueBytes, MaxConcurrentExecutions joined it in the worker cutover)
- The authorizer
- The
InvocationResolver contract — every host hand-writes the identical context-key + resolver-struct + withSubject triple (~25 lines)
- Per-call error handling on
Register
ServeWorkerAndExit() as the first statement of main (new; load-bearing, stays — see below)
Only the authorizer and the worker entry deserve to be mandatory. The worker cutover made first contact heavier, which raises the value of trimming the removable ceremony. The current tutorial also doubles its size by embedding an in-memory MCP client — the first-touch experience never produces the moment where a real agent calls your capability.
Target
A complete, real stdio MCP server a developer can paste into an agent's MCP config:
package main
import (
"context"
"log"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/meigma/codemode"
"github.com/meigma/codemode/authz"
"github.com/meigma/codemode/mcpserver"
)
type lookupInput struct {
Key string `json:"key"`
Limit *int64 `json:"limit,omitempty"`
}
type lookupOutput struct {
Key string `json:"key"`
Count int64 `json:"count"`
}
func lookup(_ context.Context, _ authz.Subject, in lookupInput) (lookupOutput, error) {
count := int64(0)
if in.Limit != nil {
count = *in.Limit
}
return lookupOutput{Key: in.Key, Count: count}, nil
}
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{}))
}
Roughly 55 lines including imports, versus ~180 today — and it is a real server, so the first touch becomes your model calling your capability.
Changes (four, independently small, one PR)
1. Stock resolvers in mcpserver (biggest win)
mcpserver.StaticSubject(subject authz.Subject) InvocationResolver — returns the fixed subject for every invocation. This is the honest identity model for single-user transports (stdio is the dominant real MCP deployment; the process is the user). Godoc and docs must state plainly that multi-user hosts must not use it.
mcpserver.ContextSubject() InvocationResolver paired with exported authz.WithSubject(ctx, subject) context.Context and authz.SubjectFromContext(ctx) (authz.Subject, bool) — the exact pattern the tutorial currently hand-rolls, now library-owned with a private key type. Hosts still control what enters context via their auth middleware; the trust boundary does not move. Client-controlled data (arguments, source, MCP _meta) remains unusable as identity.
2. Zero-value Limits fields default to DefaultLimits() values
Zero never means unlimited in this design, so a zero field is unambiguous: default it per-field at Build. Options{Authorizer: authz.AllowAll()} becomes valid, and overriding one budget (e.g. MaxExecutionTime) no longer requires restating the other eight. Explicit negative values still fail validation. No safety cost — the defaults are the bounded ones. The nine-field post-worker Limits (including MaxConcurrentExecutions and the spawn-inclusive MaxExecutionTime semantics) makes per-field defaulting more valuable than when this issue was first filed.
3. Register stops returning an error; Build reports all registration failures
Registration failures are programmer errors and Build already re-validates the full set (and now also runs the worker probe — one more reason construction failures consolidate there). Accumulate per-registration errors (with the capability name in each) and return them joined from Build. N capabilities lose N if err != nil blocks and diagnostics improve: all bad registrations surface at once instead of one per compile-run cycle. Pre-release, this is a clean signature cutover — migrate every caller, no deprecated path.
4. ID defaults to Name when empty
The ID/Name split earns its keep only when someone writes authorization policy or deployment filters — exactly the users who read far enough to set it. Tradeoff to record: a host that renames a capability without ever setting an ID silently changes its policy identity. Mitigation: Capability.ID godoc states "set ID before writing policy or deployment filters against this capability." If review finds this too sharp, drop this item alone — it saves one line, not twenty.
What deliberately stays
- Explicit
authz.AllowAll(). Fail-closed authorization is the product; forcing the developer to type the word that turns it off is the cheapest possible security education. No default authorizer, ever.
ServeWorkerAndExit() as the first statement of main. It is the worker-process security boundary and cannot be defaulted away from inside a library. Its cost is one self-explanatory line; the docs burden is explaining why, which the worker docs already carry.
- No
codemode.Serve(...) / fluent assembly. Host ownership of transport, auth, lifecycle is a load-bearing boundary, and the SDK's srv.Run(ctx, transport) already makes the last mile one line.
- No alternate registration sugar (e.g. a type-inferring
Cap(name, summary, handler) constructor). One way to register; the struct literal reads fine.
Invariants that must not change
- Fail-closed: nil authorizer still fails
Build.
InvocationResolver runs before every tool operation and ignores arguments, source, and MCP _meta; the new stock resolvers must satisfy the existing resolver contract and tests.
- Builder remains single-threaded and one-shot;
Build still closes it and still runs the worker probe.
- Canonical binding -> authorization -> dispatch ordering unaffected.
Acceptance sketch
- The target program above compiles and runs as a real stdio server; exercised end-to-end with an MCP client (
search_api, describe_api, execute round-trip through a real worker child).
Options with only an authorizer builds with default limits; a partial Limits override keeps defaults for zero fields; negative values still fail.
- Two invalid registrations produce one
Build error naming both capabilities.
- A capability registered without
ID is authorizable and disableable by its Name acting as ID.
StaticSubject and ContextSubject covered by resolver tests including the empty-subject rejection path; test binaries call ServeWorkerAndExit in TestMain per the existing convention.
- Tutorial (
docs/docs/tutorials/first-server.md), README snippet, and both example_test.go files updated to the new shape; mcp-tools.md unchanged (client-facing surface is untouched).
Origin
Product/UX review at 8b5302b; re-verified and amended at 3b541fc after the worker cutover (#26–#31). Third outcome after #23 (binding matrix) and #24 (error diagnostics). This issue targets host-side ergonomics only; the model-facing MCP contract is unchanged.
Problem
The first-touch developer experience is heavy. The API front-loads several concepts before hello-world:
ID+Name) on every capability (ValidateRegistrationrejects an empty ID)Limits— a zero value failsBuild, and the struct is now nine fields (MaxValueBytes,MaxConcurrentExecutionsjoined it in the worker cutover)InvocationResolvercontract — every host hand-writes the identical context-key + resolver-struct +withSubjecttriple (~25 lines)RegisterServeWorkerAndExit()as the first statement ofmain(new; load-bearing, stays — see below)Only the authorizer and the worker entry deserve to be mandatory. The worker cutover made first contact heavier, which raises the value of trimming the removable ceremony. The current tutorial also doubles its size by embedding an in-memory MCP client — the first-touch experience never produces the moment where a real agent calls your capability.
Target
A complete, real stdio MCP server a developer can paste into an agent's MCP config:
Roughly 55 lines including imports, versus ~180 today — and it is a real server, so the first touch becomes your model calling your capability.
Changes (four, independently small, one PR)
1. Stock resolvers in
mcpserver(biggest win)mcpserver.StaticSubject(subject authz.Subject) InvocationResolver— returns the fixed subject for every invocation. This is the honest identity model for single-user transports (stdio is the dominant real MCP deployment; the process is the user). Godoc and docs must state plainly that multi-user hosts must not use it.mcpserver.ContextSubject() InvocationResolverpaired with exportedauthz.WithSubject(ctx, subject) context.Contextandauthz.SubjectFromContext(ctx) (authz.Subject, bool)— the exact pattern the tutorial currently hand-rolls, now library-owned with a private key type. Hosts still control what enters context via their auth middleware; the trust boundary does not move. Client-controlled data (arguments, source, MCP_meta) remains unusable as identity.2. Zero-value
Limitsfields default toDefaultLimits()valuesZero never means unlimited in this design, so a zero field is unambiguous: default it per-field at
Build.Options{Authorizer: authz.AllowAll()}becomes valid, and overriding one budget (e.g.MaxExecutionTime) no longer requires restating the other eight. Explicit negative values still fail validation. No safety cost — the defaults are the bounded ones. The nine-field post-workerLimits(includingMaxConcurrentExecutionsand the spawn-inclusiveMaxExecutionTimesemantics) makes per-field defaulting more valuable than when this issue was first filed.3.
Registerstops returning an error;Buildreports all registration failuresRegistration failures are programmer errors and
Buildalready re-validates the full set (and now also runs the worker probe — one more reason construction failures consolidate there). Accumulate per-registration errors (with the capability name in each) and return them joined fromBuild. N capabilities lose Nif err != nilblocks and diagnostics improve: all bad registrations surface at once instead of one per compile-run cycle. Pre-release, this is a clean signature cutover — migrate every caller, no deprecated path.4.
IDdefaults toNamewhen emptyThe ID/Name split earns its keep only when someone writes authorization policy or deployment filters — exactly the users who read far enough to set it. Tradeoff to record: a host that renames a capability without ever setting an ID silently changes its policy identity. Mitigation:
Capability.IDgodoc states "set ID before writing policy or deployment filters against this capability." If review finds this too sharp, drop this item alone — it saves one line, not twenty.What deliberately stays
authz.AllowAll(). Fail-closed authorization is the product; forcing the developer to type the word that turns it off is the cheapest possible security education. No default authorizer, ever.ServeWorkerAndExit()as the first statement ofmain. It is the worker-process security boundary and cannot be defaulted away from inside a library. Its cost is one self-explanatory line; the docs burden is explaining why, which the worker docs already carry.codemode.Serve(...)/ fluent assembly. Host ownership of transport, auth, lifecycle is a load-bearing boundary, and the SDK'ssrv.Run(ctx, transport)already makes the last mile one line.Cap(name, summary, handler)constructor). One way to register; the struct literal reads fine.Invariants that must not change
Build.InvocationResolverruns before every tool operation and ignores arguments, source, and MCP_meta; the new stock resolvers must satisfy the existing resolver contract and tests.Buildstill closes it and still runs the worker probe.Acceptance sketch
search_api,describe_api,executeround-trip through a real worker child).Optionswith only an authorizer builds with default limits; a partialLimitsoverride keeps defaults for zero fields; negative values still fail.Builderror naming both capabilities.IDis authorizable and disableable by itsNameacting as ID.StaticSubjectandContextSubjectcovered by resolver tests including the empty-subject rejection path; test binaries callServeWorkerAndExitinTestMainper the existing convention.docs/docs/tutorials/first-server.md), README snippet, and bothexample_test.gofiles updated to the new shape;mcp-tools.mdunchanged (client-facing surface is untouched).Origin
Product/UX review at 8b5302b; re-verified and amended at 3b541fc after the worker cutover (#26–#31). Third outcome after #23 (binding matrix) and #24 (error diagnostics). This issue targets host-side ergonomics only; the model-facing MCP contract is unchanged.