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 responsesGET /mcp: 2025-11-25 resumable SSE replay viaLast-Event-ID, or a short-lived keepalive SSE response whenLast-Event-IDis absentDELETE /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 /mcprequirescontent-type: application/jsonPOST /mcprequiresacceptsupport for bothapplication/jsonandtext/event-stream- session-ful
GET /mcprequiresacceptsupport fortext/event-stream - in the 2025-11-25 shape,
server/discoveris available beforeinitialize;initializeis the only request that creates a session and returnsmcp-session-id, and subsequent POST/GET/DELETE calls require it - missing 2025-11-25 session headers return
400; unknown or expired sessions return404 - 2026-07-28 requests never create or require
mcp-session-id; a client-posted JSON-RPC response is rejected with HTTP400because modern clients may only post requests and notifications - every 2026-07-28 request or notification requires both
params._meta["io.modelcontextprotocol/protocolVersion"]andparams._meta["io.modelcontextprotocol/clientCapabilities"]; missing or invalid values return-32602 - every 2026-07-28 request requires one unambiguous
mcp-protocol-versionvalue; conflicting duplicates fail closed with-32020before the runtime selects a protocol shape - every 2026-07-28 JSON-RPC request or notification requires one unambiguous
mcp-methodvalue equal to the bodymethod; conflicting duplicate routing-header values fail closed - 2026-07-28
tools/call,prompts/get, andresources/readadditionally requiremcp-nameequal toparams.name,params.name, orparams.uri, respectively - an
mcp-namevalue using the exact case-sensitive=?base64?{value}?=sentinel is decoded before comparison; malformed Base64 fails closed with-32020 GET /mcpandDELETE /mcpwith the 2026-07-28 protocol header return405- for session-ful clients,
mcp-protocol-versionis optional after initialization. Header precedence applies per request:2026-07-28routes that request through the stateless shape before session validation, even whenmcp-session-idnames 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 HTTP404with JSON-RPC code-32601, while modern protocol/header/capability validation errors (-32020,-32021, and-32022) return their JSON-RPC envelope with HTTP400 - 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-182025-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.aihttps://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:
- Canary clients must send
content-type: application/jsonon everyPOST /mcp. - Canary clients must send
accept: application/json, text/event-streamon everyPOST /mcp. - Canary clients must send
accept: text/event-streamon everyGET /mcp. - After initialization, session-ful clients should either omit
mcp-protocol-versionor send the exact negotiated version. Sending2026-07-28deliberately routes that request through the stateless shape instead. - Streaming clients must tolerate the initial empty-data priming SSE event and store its
idfor reconnect. - Reconnect with
GET /mcpplus the latestlast-event-id; do not assume dropped TCP connections cancel work. - Stateless clients must send the exact
mcp-methodrouting value and, fortools/call,prompts/get, andresources/read, the exactmcp-namevalue. - Stateless clients must send
mcp-protocol-version: 2026-07-28on every POST and include both required per-request_metafields. Metadata never substitutes for the HTTP header. - Stateless clients must read
result.resultTypeand retry aninput_requiredtool call with the returnedrequestStateand collectedinputResponses.
Compatibility risks to check during canary:
- older clients that send
Accept: application/jsononly onPOST /mcpnow receive HTTP400 - clients that omit
Content-Typeor send non-JSON content types now receive HTTP400 - clients that pin a different session-ful protocol header receive HTTP
400; a2026-07-28header 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-IDfrom 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:
initializeserver/discoverpingtools/listtools/callresources/listresources/readresources/subscriberesources/unsubscribelogging/setLevelcompletion/completetasks/gettasks/resulttasks/listtasks/cancelprompts/listprompts/get
Accepted notification methods:
notifications/initializednotifications/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 Acceptedwith no body; 2026-07-28 rejects them with HTTP400 - notifications also return
202 Acceptedwith no body - JSON-RPC batch requests are only supported for legacy
2025-03-26callers; after a session is established, batch dispatch uses the session’s negotiated protocol version when the request omitsmcp-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, and2025-03-26, in preference ordercapabilities: the enabled surfaces that the server can actually serve from its registries and configured hooks_meta["io.modelcontextprotocol/serverInfo"]: the name and version passed tomcp.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 returnedinputRequestsand 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/discovertools/listprompts/listresources/listresources/templates/listresources/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/callpanics are recovered as sanitized JSON-RPC internal errors; panic values are logged server-side and are not returned to clients DynamoSessionStore.Putis 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() > 0and tools are enabled ->"tools": {} - if
srv.Resources().Len() > 0and resources are enabled ->"resources": {} - if
srv.Prompts().Len() > 0and 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(...), butresources.subscribeis not advertised until AppTheory has a first-class outboundnotifications/resources/updatedcontract logging/setLevelis accepted only on the session-ful surface whenmcp.WithLoggingLevelHook(...)is configured, butloggingis not advertised until AppTheory has a first-class outboundnotifications/messagecontractcompletionsis advertised only whenmcp.WithCompletionHooks(...)has at least one prompt or resource hooktasksis advertised only whenmcp.WithTaskRuntime(...)supplies a store and a tool explicitly opts into task executionnotifications/cancelledis 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
listChangedremain 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:
- the session negotiates protocol
2025-11-25 - the server is created with
mcp.WithTaskRuntime(...)and a concreteTaskStore - at least one registered tool declares
ToolExecution.TaskSupportasoptionalorrequired
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-augmentedtools/callTaskSupportOptionalallows both synchronous and task-augmentedtools/callTaskSupportRequiredrejects synchronoustools/calland 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/getto inspect statustasks/listto list tasks for the current MCP sessiontasks/resultto retrieve terminal results, with related-task metadata injected into_metatasks/cancelto 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 normalapp.Use(...)chain that protectsPOST /mcp,GET /mcp, andDELETE /mcp - back the middleware with
pkg/limitedwhen rate-limit state must survive Lambda concurrency and cold starts - use
RateLimitConfig.ExtractIdentifier,ExtractResource, andExtractOperationto 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 telemetryNoArgs: accepts omitted,null, or{}arguments and rejects extra fieldsStrictJSON: rejects unknown fields and trailing JSON values for typed tool argumentsValidate: maps validation failures to JSON-RPC invalid params with a sanitized messageHandleError: maps expected product errors to a safeToolResult{IsError:true}when appropriateTimeout: derives a per-tool context timeout and maps deadline expiry to AppTheory’s existing safe timeout errorTelemetry: emits start/finish hooks with name, timestamps, duration, outcome, JSON-RPC code, andisErrorstatusClock: 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[]ContentBlockStructuredContent: serialized asstructuredContentIsError: serialized asisErrorResultType,InputRequests, andRequestState: the2026-07-28multi-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
idand an emptydata: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.progressTokenfrom the originaltools/call progressTokenmay 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
SSEStreamResponseis supported by the API Gateway REST API v1 adapter (ServeAPIGatewayProxyviaHandleLambda) - 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 /mcpwithlast-event-id: <id>resumes or replays that streamlast-event-idmust 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 withGET /mcpandlast-event-idafter any disconnect, and treat disconnect as transport loss rather than tool cancellation - cancellation remains explicit: send
notifications/cancelledinstead of relying on a dropped connection GET /mcpwithoutlast-event-idemits 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 /mcpwithoutlast-event-id - replay/resume
GET /mcprequests withlast-event-idkeep their existing behavior - when Lambda
RemainingMSis available, AppTheory subtractsSafetyBufferfrom the remaining time and caps the listener withMaxDuration - when
RemainingMSis unavailable, the configured budget does not cap the listener; use this option only on Lambda-backed deployments whereRemainingMSis 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
TextorBlob Blobis 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,DescriptionArgumentsas[]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.
initializecreates the session and returnsmcp-session-idon the HTTP response- session TTL is controlled by
MCP_SESSION_TTL_MINUTES(default60) - session TTL is refreshed on access (sliding window)
notifications/initializedpersists an"initialized": "true"marker in the session dataDELETE /mcpreturns202 Acceptedand 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_TABLEwhen set, otherwisemcp-sessions
Stream persistence note:
- default stream store: in-memory
- persistent stream replay:
mcp.WithStreamStore(mcp.NewDynamoStreamStore(db)) - default Dynamo table name:
MCP_STREAM_TABLEwhen set, otherwisemcp-streams - stream/event retention is controlled by
MCP_STREAM_TTL_MINUTES(default60); 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_MINUTESis the runtime replay window.DynamoStreamStoretreats event records withexpiresAt <= nowas 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_BUCKETis set, events larger thanMCP_STREAM_SPILL_INLINE_MAX_BYTES(default32768, clamped to the DynamoDB-safe inline ceiling of358400) 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
expiresAtvalue before reading inline or spilled event data. DynamoStreamStoregets its strongestDeleteSession/Appendrace protection from a TableTheory DB that implementsTransactWrite; the standard production TableTheory DB provides that path. Test doubles or customtablecore.DBimplementations withoutTransactWritestill get active-session guards, but they cannot make the final event create atomic with session deletion.MCP_STREAM_MAX_EVENT_BYTES(default10485760) 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:
InitializeListToolsCallToolListResourcesReadResourceListPromptsGetPromptRawRawStreamResumeStream
Low-level JSON-RPC request builders:
InitializeRequestListToolsRequestCallToolRequestListResourcesRequestReadResourceRequestListPromptsRequestGetPromptRequest
SSE helpers and assertions:
Stream.ResponseStream.CancelStream.NextStream.ReadAllReadSSEMessageAssertErrorAssertHasToolsAssertToolResult
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(...).