MCP

MCP (Model Context Protocol) Method Surface

This document describes AppTheory’s fixture-backed MCP server method surface: transport behavior, JSON-RPC methods, registries, sessions, streaming, and test helpers. Go implementation package paths such as github.com/theory-cloud/apptheory/v4/runtime/mcp are listed where operators need source-level details; TypeScript and Python expose the matching runtime/test surfaces through their package API snapshots.

If you’re specifically integrating with Bedrock AgentCore, start with docs/integrations/agentcore-mcp.md.

For the Claude-first Remote MCP deployment guide, see:

  • docs/integrations/remote-mcp.md

For v1.0 fail-closed migration notes that affect MCP transport and session behavior, see:

  • docs/migration/v1-security.md

For the additive 2026-07-28 stateless transport and client migration checklist, see:

  • docs/migration/mcp-2026-07-28.md

This milestone closes the audited 2026-07-28 conformance gap list; it does not claim complete coverage of the draft. Mcp-Param-* mirroring, subscriptions/listen, per-request io.modelcontextprotocol/logLevel, DiscoverResult.instructions, and trace context remain deferred.

OAuth helper surfaces used by Remote MCP deployments and Autheory are in:

  • github.com/theory-cloud/apptheory/v4/runtime/oauth

Transport + endpoint

AppTheory implements both supported MCP transport shapes on one /mcp path:

  • POST /mcp: JSON-RPC requests and notifications; the session-ful shape also accepts client responses
  • GET /mcp: 2025-11-25 resumable SSE replay via Last-Event-ID, or a short-lived keepalive SSE response when Last-Event-ID is absent
  • DELETE /mcp: 2025-11-25 session termination

The runtime selects one shape per request:

Protocol shape Selection Session behavior
2025-11-25 Existing initialize/session negotiation server/discover is available before the handshake; initialize mints mcp-session-id; later POST/GET/DELETE requests require it
2026-07-28 Every HTTP POST sends mcp-protocol-version: 2026-07-28 and the matching params._meta["io.modelcontextprotocol/protocolVersion"] Stateless POST; no initialize handshake or session id, and GET/DELETE are not routed

mcp-protocol-version has strict precedence during shape detection. For a 2026-07-28 HTTP request, the standard header is mandatory and must agree with request metadata; a missing header or disagreement fails closed with JSON-RPC code -32020. Parsed-message detection still accepts metadata-only selection for non-HTTP bindings such as stdio. This lets one server concurrently accept established session-ful clients and new stateless clients without a server-wide mode flag while preventing intermediaries and payloads from naming different modern protocol versions.

Header names are case-insensitive on the wire. The examples in this doc use lowercase HTTP headers.

  • Session header: mcp-session-id
  • Protocol header: mcp-protocol-version
  • Modern routing method header: mcp-method
  • Modern routing name header: mcp-name
  • Resume header: last-event-id

Important transport behavior:

  • POST /mcp requires content-type: application/json
  • POST /mcp requires accept support for both application/json and text/event-stream
  • session-ful GET /mcp requires accept support for text/event-stream
  • in the 2025-11-25 shape, server/discover is available before initialize; initialize is the only request that creates a session and returns mcp-session-id, and subsequent POST/GET/DELETE calls require it
  • missing 2025-11-25 session headers return 400; unknown or expired sessions return 404
  • 2026-07-28 requests never create or require mcp-session-id; a client-posted JSON-RPC response is rejected with HTTP 400 because modern clients may only post requests and notifications
  • every 2026-07-28 request or notification requires both params._meta["io.modelcontextprotocol/protocolVersion"] and params._meta["io.modelcontextprotocol/clientCapabilities"]; missing or invalid values return -32602
  • every 2026-07-28 request requires one unambiguous mcp-protocol-version value; conflicting duplicates fail closed with -32020 before the runtime selects a protocol shape
  • every 2026-07-28 JSON-RPC request or notification requires one unambiguous mcp-method value equal to the body method; conflicting duplicate routing-header values fail closed
  • 2026-07-28 tools/call, prompts/get, and resources/read additionally require mcp-name equal to params.name, params.name, or params.uri, respectively
  • an mcp-name value using the exact case-sensitive =?base64?{value}?= sentinel is decoded before comparison; malformed Base64 fails closed with -32020
  • GET /mcp and DELETE /mcp with the 2026-07-28 protocol header return 405
  • for session-ful clients, mcp-protocol-version is optional after initialization. Header precedence applies per request: 2026-07-28 routes that request through the stateless shape before session validation, even when mcp-session-id names a live 2025-11-25 session. Headers that select the session-ful shape must be supported and match the session’s negotiated protocol version
  • ordinary session-ful JSON-RPC results and method errors return HTTP 200; a 2026-07-28 method-not-found error returns HTTP 404 with JSON-RPC code -32601, while modern protocol/header/capability validation errors (-32020, -32021, and -32022) return their JSON-RPC envelope with HTTP 400
  • other transport-level failures such as missing sessions, rejected origins, or missing replay events return HTTP 4xx / 5xx

The audited 2026-07-28 surface is supported through the stateless request shape and is not negotiated with initialize. Session-ful versions negotiated on initialize remain:

  • 2025-11-25 (latest session-ful)
  • 2025-06-18
  • 2025-03-26 (legacy compatibility / batch mode)

If the client requests an unsupported protocol version during initialize, AppTheory counter-proposes the latest supported version instead of failing the request.

If a request includes an Origin header, AppTheory validates it fail-closed. The default allowlist is:

  • https://claude.ai
  • https://claude.com

Use mcp.WithOriginValidator(...) to replace that policy for other browser-based callers.

Strict transport compatibility rollout

Roll strict Streamable HTTP behavior out with a client canary before making it the only production path:

  1. Canary clients must send content-type: application/json on every POST /mcp.
  2. Canary clients must send accept: application/json, text/event-stream on every POST /mcp.
  3. Canary clients must send accept: text/event-stream on every GET /mcp.
  4. After initialization, session-ful clients should either omit mcp-protocol-version or send the exact negotiated version. Sending 2026-07-28 deliberately routes that request through the stateless shape instead.
  5. Streaming clients must tolerate the initial empty-data priming SSE event and store its id for reconnect.
  6. Reconnect with GET /mcp plus the latest last-event-id; do not assume dropped TCP connections cancel work.
  7. Stateless clients must send the exact mcp-method routing value and, for tools/call, prompts/get, and resources/read, the exact mcp-name value.
  8. Stateless clients must send mcp-protocol-version: 2026-07-28 on every POST and include both required per-request _meta fields. Metadata never substitutes for the HTTP header.
  9. Stateless clients must read result.resultType and retry an input_required tool call with the returned requestState and collected inputResponses.

Compatibility risks to check during canary:

  • older clients that send Accept: application/json only on POST /mcp now receive HTTP 400
  • clients that omit Content-Type or send non-JSON content types now receive HTTP 400
  • clients that pin a different session-ful protocol header receive HTTP 400; a 2026-07-28 header instead selects the stateless shape for that request
  • stateless HTTP clients that previously selected 2026-07-28 only through request metadata now receive HTTP 400 / -32020
  • SSE parsers that assume the first frame is JSON-RPC must skip or record the empty priming frame
  • replay clients that reuse a Last-Event-ID from another stream now fail closed instead of receiving unrelated events

Mounting the handler is still just normal AppTheory routing:

srv := mcp.NewServer("my-mcp-server", "dev")

app := apptheory.New()
h := srv.Handler()
app.Post("/mcp", h)
app.Get("/mcp", h)
app.Delete("/mcp", h)

Supported JSON-RPC surface

The 2025-11-25 session-ful shape dispatches these MCP request methods:

  • initialize
  • server/discover
  • ping
  • tools/list
  • tools/call
  • resources/list
  • resources/read
  • resources/subscribe
  • resources/unsubscribe
  • logging/setLevel
  • completion/complete
  • tasks/get
  • tasks/result
  • tasks/list
  • tasks/cancel
  • prompts/list
  • prompts/get

Accepted notification methods:

  • notifications/initialized
  • notifications/cancelled

The 2026-07-28 stateless shape dispatches the session-independent subset: server/discover, tools/list, tools/call, resources/list, resources/read, resources/templates/list, completion/complete, prompts/list, and prompts/get. The draft removed ping and logging/setLevel; AppTheory returns HTTP 404 with -32601 for those methods and any other unavailable modern method. Stateless tools/call runs to a buffered JSON response even when the registered tool also supports the session-ful SSE path. Task-augmented tool calls, task methods, resource subscriptions, and initialize fail closed as unavailable in this shape. Stateless notifications return 202 Accepted without creating or mutating a session.

Other transport notes:

  • posted client responses are accepted only in the session-ful shape and return 202 Accepted with no body; 2026-07-28 rejects them with HTTP 400
  • notifications also return 202 Accepted with no body
  • JSON-RPC batch requests are only supported for legacy 2025-03-26 callers; after a session is established, batch dispatch uses the session’s negotiated protocol version when the request omits mcp-protocol-version

Server discovery

server/discover is implemented at the routing layer, so every AppTheory-hosted MCP server returns the same shape in both transports. It is reachable before initialize in the session-ful shape and does not require Mcp-Session-Id. Its result contains:

  • supportedVersions: 2026-07-28, 2025-11-25, 2025-06-18, and 2025-03-26, in preference order
  • capabilities: the enabled surfaces that the server can actually serve from its registries and configured hooks
  • _meta["io.modelcontextprotocol/serverInfo"]: the name and version passed to mcp.NewServer(...) or the equivalent TypeScript/Python constructor

Discovery capability construction is version-aware. A 2025-11-25 discovery result may include "tasks": {...} when the task runtime and a task-capable tool are configured. A 2026-07-28 discovery result omits tasks because AppTheory does not implement the modern task extension. Configured extension declarations are advertised under capabilities.extensions; extension-gated input fails with -32021 unless the client declared the matching extension capability.

The advertisement never includes a subscriptions capability. AppTheory does not implement the 2026-07-28 subscriptions/listen transport, and applications must not add a wrapper that advertises it.

2026-07-28 result types and multi-round input

Every successful 2026-07-28 JSON-RPC result contains a resultType:

  • "complete" means the request is finished
  • "input_required" means the client must fulfill the returned inputRequests and retry the original request

An input_required tool result includes at least one of inputRequests or requestState. On retry, the client sends the returned requestState and its named inputResponses in the original request parameters. Tool handlers read that round-trip state through ToolInputFromContext(...) in Go or the matching McpToolContext fields in TypeScript and Python.

The client must advertise each capability needed by an input request under params._meta["io.modelcontextprotocol/clientCapabilities"]. For example, an elicitation/create input request requires "elicitation": {}. If the capability is missing, AppTheory rejects the result with -32021 instead of returning an input request the client cannot satisfy.

Session-ful results are unchanged: AppTheory removes a handler-supplied "complete" marker from the 2025-11-25 response shape, and input_required is unavailable there.

2026-07-28 server identity metadata

By default, every successful modern result, including both "complete" and "input_required", includes _meta["io.modelcontextprotocol/serverInfo"] with the server constructor’s name and version. The runtime owns that reserved key and preserves other _meta entries.

Identity metadata is enabled by default. The explicit server-level opt-out is mcp.WithServerInfoMetadata(false) in Go, includeServerInfoMetadata: false in TypeScript, or include_server_info_metadata=False in Python. The opt-out applies only to modern result injection; it does not change the established 2025-11-25 discovery response.

2026-07-28 cacheable results

AppTheory adds the required ttlMs and cacheScope fields to every "complete" result from these modern methods:

  • server/discover
  • tools/list
  • prompts/list
  • resources/list
  • resources/templates/list
  • resources/read

The fail-closed default for every surface is ttlMs: 0 (immediately stale) and cacheScope: "private". A surface is marked "public" only when the server configures it explicitly; do not use public scope for results that vary by tenant, identity, authorization context, or request filtering. Negative/non-finite TTL configuration is normalized to zero.

Go configures the six surfaces with mcp.WithCacheableResultConfig(mcp.CacheableResultConfig{...}) and mcp.CacheHint. TypeScript uses McpServerOptions.cacheableResults; Python uses McpServerOptions.cacheable_results. The TypeScript/Python field names are the language-idiomatic equivalents of ServerDiscover, ToolsList, PromptsList, ResourcesList, ResourceTemplatesList, and ResourcesRead.

srv := mcp.NewServer(
    "my-mcp-server",
    "dev",
    mcp.WithCacheableResultConfig(mcp.CacheableResultConfig{
        ToolsList: mcp.CacheHint{
            TTL:   5 * time.Minute,
            Scope: mcp.CacheScopePublic,
        },
        ResourcesRead: mcp.CacheHint{
            TTL:   30 * time.Second,
            Scope: mcp.CacheScopePrivate,
        },
    }),
)

input_required results never carry caching hints. A completed multi-round retry that supplies inputResponses or requestState remains schema-valid but is forced to ttlMs: 0 and cacheScope: "private", regardless of the configured surface hint, because those inputs are not part of the protocol cache key. All 2025-11-25 responses remain byte-compatible and omit the modern cache fields.

2026-07-28 protocol errors

Modern transport validation fails closed with these exported codes in Go, TypeScript, and Python:

Code Meaning Pinned cases
-32020 Header mismatch the required protocol header is absent, conflicts with a duplicate, or disagrees with _meta; Mcp-Method is absent/wrong; required Mcp-Name is absent/wrong/malformed Base64; or either routing header has conflicting duplicate values
-32021 Missing required client capability an input_required result needs a capability omitted from per-request client metadata
-32022 Unsupported protocol version a sessionless request names an unsupported/future protocol version

These errors use a JSON-RPC error envelope and HTTP 400. The -32022 data includes supported and requested; -32021 data includes requiredCapabilities. None of this changes the established 2025-11-25 session validation contract. If a name-routed method omits params.name / params.uri or supplies a non-string value, the body is invalid and AppTheory returns -32602 rather than misreporting a routing-header mismatch.

Missing or invalid required modern _meta fields also return HTTP 400 with standard JSON-RPC code -32602. Unavailable modern methods return HTTP 404 with -32601; the same method error remains HTTP 200 in the session-ful shape for probe compatibility.

AppTheory deliberately returns -32602 for a missing resource in both protocol shapes. The 2025-11-25 specification names -32002, but this repository has never emitted that code; preserving -32602 avoids changing the byte-pinned legacy contract during the modern transport milestone.

Runtime hardening guarantees

The MCP runtime fails closed around tool execution and durable replay:

  • buffered and streaming tools/call panics are recovered as sanitized JSON-RPC internal errors; panic values are logged server-side and are not returned to clients
  • DynamoSessionStore.Put is an upsert, so sliding-session refreshes update the existing session data and TTL instead of failing when a session row already exists
  • S3-spilled stream events are read through AppTheory’s private object-store helper with bounded reads before replay validation; the read cap uses the recorded event byte count and the configured maximum event size before size/hash validation

Capabilities advertisement (server/discover and initialize)

The server/discover and initialize results advertise only surfaces that are both enabled in mcp.CapabilityConfig and actually registered on the server:

  • if srv.Registry().Len() > 0 and tools are enabled -> "tools": {}
  • if srv.Resources().Len() > 0 and resources are enabled -> "resources": {}
  • if srv.Prompts().Len() > 0 and prompts are enabled -> "prompts": {}
  • if mcp.WithCompletionHooks(...) has at least one hook and completions are enabled -> "completions": {}
  • if mcp.WithExtensionCapabilities(...) supplies valid mandatory-prefixed identifiers -> "extensions": {...}
  • if mcp.WithTaskRuntime(...) supplies a store, at least one registered tool declares task support, and tasks are enabled -> "tasks": {...} in 2025-11-25 discovery/initialize results only

The default capability policy enables the implemented surfaces, but registration is still required before they are advertised. Use mcp.WithCapabilityConfig(...) to withhold an implemented surface for a product rollout.

Optional MCP utility capabilities are fail-closed:

  • resource subscription hooks are accepted only when both hooks are configured with mcp.WithResourceSubscriptionHooks(...), but resources.subscribe is not advertised until AppTheory has a first-class outbound notifications/resources/updated contract
  • logging/setLevel is accepted only on the session-ful surface when mcp.WithLoggingLevelHook(...) is configured, but logging is not advertised until AppTheory has a first-class outbound notifications/message contract
  • completions is advertised only when mcp.WithCompletionHooks(...) has at least one prompt or resource hook
  • tasks is advertised only when mcp.WithTaskRuntime(...) supplies a store and a tool explicitly opts into task execution
  • notifications/cancelled is accepted for every initialized session, but it only cancels AppTheory-tracked in-flight requests for that session and safely ignores unknown or completed request ids
  • unsupported utility surfaces such as listChanged remain omitted until their concrete AppTheory contract exists

Capability construction is also protocol-aware; if a future supported protocol version removes or changes a capability, AppTheory omits that capability for sessions negotiated to that version.

Products should not advertise these optional utility capabilities outside AppTheory’s initialize response and should not enable the hooks for downstream services until product authorization, tenant policy, audit logging, and abuse controls are wired. The single path is: configure the AppTheory hook, let AppTheory advertise only capabilities it can deliver end-to-end, and handle the request through the hook. Do not hard-code capabilities in a product-specific wrapper.

Task runtime

MCP task support is explicit opt-in. AppTheory does not advertise tasks just because a product has long-running tools. All three conditions must hold:

  1. the session negotiates protocol 2025-11-25
  2. the server is created with mcp.WithTaskRuntime(...) and a concrete TaskStore
  3. at least one registered tool declares ToolExecution.TaskSupport as optional or required

Example:

type slowReportArgs struct {
  ReportID string `json:"reportId"`
}

srv := mcp.NewServer("my-mcp-server", "dev",
  mcp.WithTaskRuntime(mcp.TaskRuntimeOptions{
    Store: mcp.NewDynamoTaskStore(db),
  }),
)

_ = srv.Registry().RegisterTool(mcp.ToolDef{
  Name:        "slow-report",
  Description: "Generate a report asynchronously.",
  Execution:   &mcp.ToolExecution{TaskSupport: mcp.TaskSupportOptional},
  InputSchema: json.RawMessage(`{
    "type":"object",
    "properties":{"reportId":{"type":"string"}},
    "required":["reportId"]
  }`),
}, mcp.WrapTool(mcp.ToolLifecycleOptions[slowReportArgs]{
  Name:       "slow-report",
  StrictJSON: true,
}, runSlowReport))

Tool support is fail-closed:

  • TaskSupportForbidden (or omitted) rejects task-augmented tools/call
  • TaskSupportOptional allows both synchronous and task-augmented tools/call
  • TaskSupportRequired rejects synchronous tools/call and requires task augmentation

When a task-capable tools/call includes a task parameter, AppTheory creates a session-scoped task record, returns a CreateTaskResult, and runs the tool on a background context detached from the request connection. The final tool result or JSON-RPC error is stored in the configured TaskStore. Clients then use:

  • tasks/get to inspect status
  • tasks/list to list tasks for the current MCP session
  • tasks/result to retrieve terminal results, with related-task metadata injected into _meta
  • tasks/cancel to mark the task canceled and cancel the in-flight tool context when it is still running

Task state is always bound to the active MCP session id. A store must never broaden lookup, list, cancel, or delete operations outside the supplied session scope. Product deployments should bind that session to the same principal, tenant, route bundle, and entitlement policy used by their OAuth/token validation layer; missing or ambiguous policy must withhold task capability rather than falling back to broader access.

TTL is part of the task contract. TaskRuntimeOptions.DefaultTTL defaults to MCP_TASK_TTL_MINUTES when that environment variable is set, otherwise 10 minutes. TaskRuntimeOptions.MaxTTL defaults to 1 hour. Client-supplied task.ttl values are milliseconds, must be positive, and fail closed when they exceed the configured maximum. DynamoDB TTL and table cleanup are storage backstops; the runtime checks task expiry before returning stored task state.

Products should not enable or advertise task support until authorization, tenant policy, quota/rate limits, audit logging, and abuse controls are wired for asynchronous work. If a rollout needs to provision storage before exposing tasks, keep WithTaskRuntime unset or disable the Tasks capability in mcp.WithCapabilityConfig(...) until the policy path is ready. Do not hard-code tasks in a wrapper around AppTheory’s initialize response.

Rate limiting stance

MCP rate limiting is product wiring over AppTheory’s existing HTTP middleware and pkg/limited primitives. AppTheory does not expose a separate mcp.WithRateLimiter(...), task-rate limiter, or Remote MCP construct flag, because that would create a second rate-limit path outside the normal middleware contract.

The single path is:

  • validate auth and tenant/actor policy first when the limiter key depends on those claims
  • mount runtime.RateLimitMiddleware(...) in the normal app.Use(...) chain that protects POST /mcp, GET /mcp, and DELETE /mcp
  • back the middleware with pkg/limited when rate-limit state must survive Lambda concurrency and cold starts
  • use RateLimitConfig.ExtractIdentifier, ExtractResource, and ExtractOperation to build product-specific buckets such as principal, tenant, actor route, JSON-RPC method, or tool name

If a product cannot derive the required principal, tenant, actor, method, or tool bucket, it should reject the request or withhold the affected tool/task capability rather than broaden to a shared bucket. AppTheory does not advertise rate limits in initialize; rate-limit policy is enforced by the HTTP middleware around the MCP handler.

Optional utility hooks

Resource subscription hooks:

srv := mcp.NewServer("my-mcp-server", "dev",
  mcp.WithResourceSubscriptionHooks(
    func(ctx context.Context, sub mcp.ResourceSubscription) error {
      // Persist session-scoped interest in sub.URI.
      return nil
    },
    func(ctx context.Context, sub mcp.ResourceSubscription) error {
      // Remove session-scoped interest in sub.URI.
      return nil
    },
  ),
)

resources/subscribe and resources/unsubscribe fail closed with JSON-RPC method not found unless both hooks are configured. The hook receives the negotiated MCP session id and the target resource URI.

Logging hooks:

srv := mcp.NewServer("my-mcp-server", "dev",
  mcp.WithLoggingLevelHook(func(ctx context.Context, req mcp.LoggingLevelRequest) error {
    // Store the per-session logging threshold.
    return nil
  }),
)

On the 2025-11-25 session-ful surface, logging/setLevel validates MCP logging levels (debug, info, notice, warning, error, critical, alert, emergency) before invoking the hook. The method is unavailable in 2026-07-28 regardless of hook configuration.

Completion hooks:

srv := mcp.NewServer("my-mcp-server", "dev",
  mcp.WithCompletionHooks(
    func(ctx context.Context, req mcp.CompletionRequest) (*mcp.CompletionResult, error) {
      return &mcp.CompletionResult{
        Completion: mcp.Completion{Values: []string{"python"}},
      }, nil
    },
    nil,
  ),
)

completion/complete routes prompt references to the first hook and resource references to the second hook. If a specific reference type has no configured hook, AppTheory returns JSON-RPC invalid params instead of broadening to a fallback hook.

Tool context hook:

srv := mcp.NewServer("my-mcp-server", "dev",
  mcp.WithToolContextHook(func(c *apptheory.Context, ctx context.Context) context.Context {
    return context.WithValue(ctx, principalKey{}, c.Get("principal"))
  }),
)

Tool handlers only receive a context.Context, so authenticated middleware has no supported way to make the request principal visible to them through apptheory.Context alone. WithToolContextHook installs an opt-in hook that derives the stdlib context handed to method handlers from the request’s *apptheory.Context. It runs once per POST /mcp request, after origin and header validation, and reaches both the buffered tools/call path and the streaming tools/call path, plus task-augmented and batch invocations. A nil hook result keeps the original context, and without the option handlers receive the unmodified request context.


Tools

Register tools on the tool registry. Production tools should use the lifecycle wrapper and then register the wrapped handler; the wrapper is not a second registry or dispatcher.

type echoArgs struct {
  Message string `json:"message"`
}

_ = srv.Registry().RegisterTool(mcp.ToolDef{
  Name:        "echo",
  Description: "Echo back the provided message.",
  InputSchema: json.RawMessage(`{
    "type":"object",
    "properties": { "message": { "type":"string" } },
    "required": ["message"]
  }`),
}, mcp.WrapTool(mcp.ToolLifecycleOptions[echoArgs]{
  Name:       "echo",
  StrictJSON: true,
  Validate: func(ctx context.Context, in echoArgs) error {
    if strings.TrimSpace(in.Message) == "" {
      return errors.New("message is required")
    }
    return nil
  },
}, func(ctx context.Context, in echoArgs) (*mcp.ToolResult, error) {
  return &mcp.ToolResult{
    Content: []mcp.ContentBlock{{Type: "text", Text: in.Message}},
  }, nil
}))

Tool lifecycle wrapper

mcp.WrapTool[Args] and mcp.WrapStreamingTool[Args] are the blessed lifecycle adapters for product MCP tools. They compose over RegisterTool and RegisterStreamingTool; buffered JSON calls, Streamable HTTP SSE calls, and task execution still route through ToolRegistry.Call / ToolRegistry.CallStreaming.

Use mcp.ToolLifecycleOptions[Args] to keep lifecycle behavior in one place:

  • Name: safe tool name used in lifecycle telemetry
  • NoArgs: accepts omitted, null, or {} arguments and rejects extra fields
  • StrictJSON: rejects unknown fields and trailing JSON values for typed tool arguments
  • Validate: maps validation failures to JSON-RPC invalid params with a sanitized message
  • HandleError: maps expected product errors to a safe ToolResult{IsError:true} when appropriate
  • Timeout: derives a per-tool context timeout and maps deadline expiry to AppTheory’s existing safe timeout error
  • Telemetry: emits start/finish hooks with name, timestamps, duration, outcome, JSON-RPC code, and isError status
  • Clock: supplies deterministic time for telemetry tests

Telemetry payloads intentionally exclude raw arguments, bearer tokens, raw unhandled errors, and panic values. Validation and no-arg failures return JSON-RPC CodeInvalidParams; unhandled errors and panics return sanitized internal errors. Handled product failures should be converted to safe tool results through HandleError.

ToolDef exposes more than the minimal name + schema shape:

  • required: Name, InputSchema
  • optional: Title, Description, OutputSchema
  • optional annotations: Title, ReadOnlyHint, DestructiveHint, IdempotentHint, OpenWorldHint
  • optional icons: Src, MimeType, Sizes, Theme
  • optional execution metadata: TaskSupport ("forbidden", "optional", "required")

Tool results

ToolResult supports:

  • Content: ordered []ContentBlock
  • StructuredContent: serialized as structuredContent
  • IsError: serialized as isError
  • ResultType, InputRequests, and RequestState: the 2026-07-28 multi-round result contract described above

ContentBlock shapes:

  • text: { "type": "text", "text": "..." }
  • image: { "type": "image", "data": "<base64>", "mimeType": "image/png" }
  • audio: { "type": "audio", "data": "<base64>", "mimeType": "audio/wav" }
  • resource link: { "type": "resource_link", "uri": "file://...", "name": "...", "title": "...", "description": "...", "size": 123 }
  • embedded resource: { "type": "resource", "resource": { "uri": "file://...", "text": "...", "mimeType": "text/plain" } }

Streaming tool progress (SSE)

Strict Streamable HTTP clients send Accept: application/json, text/event-stream on every POST /mcp. AppTheory still returns SSE only for a tools/call targeting a tool registered with RegisterStreamingTool; ordinary tools return buffered JSON even though the client advertises SSE support.

For streaming tools, AppTheory responds as SSE:

  • the first frame is a replay priming event with an id and an empty data: field
  • after the priming event, each application frame is event: message
  • application frame data: values are always a single JSON-RPC message
  • progress is emitted as JSON-RPC notifications/progress
  • the progress notification is correlated with params._meta.progressToken from the original tools/call
  • progressToken may be a string or an integer

Register a streaming tool with RegisterStreamingTool:

type longTaskArgs struct {
  Steps int `json:"steps"`
}

_ = srv.Registry().RegisterStreamingTool(mcp.ToolDef{
  Name:        "long_task",
  Description: "Example long-running task with progress.",
  InputSchema: json.RawMessage(`{"type":"object"}`),
}, mcp.WrapStreamingTool(mcp.ToolLifecycleOptions[longTaskArgs]{
  Name:       "long_task",
  StrictJSON: true,
}, func(ctx context.Context, args longTaskArgs, emit func(mcp.SSEEvent)) (*mcp.ToolResult, error) {
  emit(mcp.SSEEvent{Data: map[string]any{"progress": 1, "total": 10, "message": "started"}})
  // ... do work ...
  emit(mcp.SSEEvent{Data: map[string]any{"progress": 10, "total": 10, "message": "done"}})
  return &mcp.ToolResult{Content: []mcp.ContentBlock{{Type: "text", Text: "ok"}}}, nil
}))

Important deployment note:

  • true incremental SSE delivery requires a response-streaming adapter
  • AppTheory’s SSEStreamResponse is supported by the API Gateway REST API v1 adapter (ServeAPIGatewayProxy via HandleLambda)
  • the HTTP API v2 adapter cannot stream: it drains a terminating streaming body into the buffered response (bounded to 4 MiB / 5 seconds) and fails closed with HTTP 500 and a JSON error body when the stream does not terminate in time, instead of returning a silent empty 200

Resumability

For streaming tool calls, AppTheory assigns SSE event ids and persists them in the active StreamStore.

  • each SSE stream starts with a persisted empty-data priming event so a client can reconnect before any JSON-RPC progress or result message has been produced
  • GET /mcp with last-event-id: <id> resumes or replays that stream
  • last-event-id must belong to the stream being resumed; AppTheory fails closed instead of replaying events from a different stream
  • clients must reuse the same mcp-session-id
  • clients should store the latest SSE id, reconnect with GET /mcp and last-event-id after any disconnect, and treat disconnect as transport loss rather than tool cancellation
  • cancellation remains explicit: send notifications/cancelled instead of relying on a dropped connection
  • GET /mcp without last-event-id emits one keepalive comment and closes by default so idle callers do not hold Lambda concurrency indefinitely
  • if you want that path to stay open for a bounded window before EOF, opt in with WithInitialSessionListenerBudget(...)

Keeping the initial keepalive path open for a bounded window on Lambda

If you want that initial GET /mcp keepalive path to stay open for a bounded window before the Lambda deadline, opt in explicitly:

srv := mcp.NewServer("my-mcp-server", "dev",
  mcp.WithInitialSessionListenerBudget(mcp.InitialSessionListenerBudgetOptions{
    SafetyBuffer: 5 * time.Second,
    MaxDuration:  25 * time.Second,
  }),
)

Important scope notes:

  • this is explicit opt-in; without the option, AppTheory emits one keepalive comment and closes
  • it applies only to GET /mcp without last-event-id
  • replay/resume GET /mcp requests with last-event-id keep their existing behavior
  • when Lambda RemainingMS is available, AppTheory subtracts SafetyBuffer from the remaining time and caps the listener with MaxDuration
  • when RemainingMS is unavailable, the configured budget does not cap the listener; use this option only on Lambda-backed deployments where RemainingMS is available
  • early termination simply ends the listener; AppTheory does not emit a special final SSE event or comment

Resources

Resources are URI-addressable things the server can read.

Register resources on the resource registry:

_ = srv.Resources().RegisterResource(mcp.ResourceDef{
  URI:      "file://hello.txt",
  Name:     "hello",
  MimeType: "text/plain",
}, func(ctx context.Context) ([]mcp.ResourceContent, error) {
  return []mcp.ResourceContent{
    {URI: "file://hello.txt", MimeType: "text/plain", Text: "hello world"},
  }, nil
})

ResourceDef fields:

  • required: URI, Name
  • optional: Title, Description, MimeType, Size

ResourceContent fields:

  • required: URI
  • optional: MimeType
  • exactly one of Text or Blob
  • Blob is expected to be base64-encoded content

Supported methods:

  • resources/list -> { "resources": []ResourceDef }
  • resources/read -> { "contents": []ResourceContent }

Prompts

Prompts are named templates that return a sequence of messages for the client or LLM.

Register prompts on the prompt registry:

_ = srv.Prompts().RegisterPrompt(mcp.PromptDef{
  Name:        "greet",
  Description: "Return a greeting message.",
  Arguments: []mcp.PromptArgument{
    {Name: "name", Required: true},
  },
}, func(ctx context.Context, args json.RawMessage) (*mcp.PromptResult, error) {
  var in struct{ Name string `json:"name"` }
  _ = json.Unmarshal(args, &in)
  return &mcp.PromptResult{
    Messages: []mcp.PromptMessage{
      {Role: "user", Content: mcp.ContentBlock{Type: "text", Text: "hello " + in.Name}},
    },
  }, nil
})

PromptDef fields:

  • Name, Title, Description
  • Arguments as []PromptArgument

PromptArgument fields:

  • Name
  • optional Title, Description
  • optional Required

PromptResult fields:

  • optional Description
  • required Messages

Supported methods:

  • prompts/list -> { "prompts": []PromptDef }
  • prompts/get -> PromptResult

Sessions + persistence

Sessions are tracked with the mcp-session-id header.

  • initialize creates the session and returns mcp-session-id on the HTTP response
  • session TTL is controlled by MCP_SESSION_TTL_MINUTES (default 60)
  • session TTL is refreshed on access (sliding window)
  • notifications/initialized persists an "initialized": "true" marker in the session data
  • DELETE /mcp returns 202 Accepted and deletes the session plus best-effort stream state for that session

Persistence options:

  • default session store: in-memory
  • persistent sessions: mcp.WithSessionStore(mcp.NewDynamoSessionStore(db))
  • default Dynamo table name: MCP_SESSION_TABLE when set, otherwise mcp-sessions

Stream persistence note:

  • default stream store: in-memory
  • persistent stream replay: mcp.WithStreamStore(mcp.NewDynamoStreamStore(db))
  • default Dynamo table name: MCP_STREAM_TABLE when set, otherwise mcp-streams
  • stream/event retention is controlled by MCP_STREAM_TTL_MINUTES (default 60); event records get per-append TTLs and stream metadata is refreshed on create, append, and close so replay state survives reconnects within the configured retention window
  • MCP_STREAM_TTL_MINUTES is the runtime replay window. DynamoStreamStore treats event records with expiresAt <= now as unreplayable even if DynamoDB TTL has not physically removed the item yet.
  • large logical stream events use the same MCP client contract: when MCP_STREAM_SPILL_BUCKET is set, events larger than MCP_STREAM_SPILL_INLINE_MAX_BYTES (default 32768, clamped to the DynamoDB-safe inline ceiling of 358400) are stored as S3-managed encrypted private S3 objects through AppTheory’s object-store helper while DynamoDB keeps the logical event id, stream id, object pointer, byte count, and SHA-256 hash; replay rehydrates the payload before emitting the same JSON-RPC SSE message
  • S3 lifecycle expiration is a best-effort cleanup backstop for spilled payload objects, not minute-level replay access enforcement; the runtime enforces replay access from the DynamoDB expiresAt value before reading inline or spilled event data.
  • DynamoStreamStore gets its strongest DeleteSession/Append race protection from a TableTheory DB that implements TransactWrite; the standard production TableTheory DB provides that path. Test doubles or custom tablecore.DB implementations without TransactWrite still get active-session guards, but they cannot make the final event create atomic with session deletion.
  • MCP_STREAM_MAX_EVENT_BYTES (default 10485760) is the hard maximum for one logical stream event. Events over that limit fail closed with a stable JSON-RPC stream delivery error instead of timing out after a failed append.
  • the CDK Remote MCP stream table only provisions storage and env vars; the application still must wire mcp.WithStreamStore(...)

Local testing (no AWS required)

Use the deterministic testkit/mcp client for in-process tests:

env := testkit.New()
client := mcptest.NewClient(buildMcpServer(), env)

_, _ = client.Initialize(context.Background())

tools, _ := client.ListTools(context.Background())
_ = tools

High-level helpers on Client:

  • Initialize
  • ListTools
  • CallTool
  • ListResources
  • ReadResource
  • ListPrompts
  • GetPrompt
  • Raw
  • RawStream
  • ResumeStream

Low-level JSON-RPC request builders:

  • InitializeRequest
  • ListToolsRequest
  • CallToolRequest
  • ListResourcesRequest
  • ReadResourceRequest
  • ListPromptsRequest
  • GetPromptRequest

SSE helpers and assertions:

  • Stream.Response
  • Stream.Cancel
  • Stream.Next
  • Stream.ReadAll
  • ReadSSEMessage
  • AssertError
  • AssertHasTools
  • AssertToolResult

Use Stream.Response() to assert the initial HTTP status, headers, and negotiated mcp-session-id. Use Stream.Cancel() to simulate a client disconnect before calling ResumeStream(...).