Skip to content

Reduce first-touch API ceremony: stock resolvers, default limits, deferred registration errors, ID default #25

Description

@jmgilman

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:

  1. Dual identity (ID + Name) on every capability (ValidateRegistration rejects an empty ID)
  2. Explicit Limits — a zero value fails Build, and the struct is now nine fields (MaxValueBytes, MaxConcurrentExecutions joined it in the worker cutover)
  3. The authorizer
  4. The InvocationResolver contract — every host hand-writes the identical context-key + resolver-struct + withSubject triple (~25 lines)
  5. Per-call error handling on Register
  6. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions