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/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
OAuth helper surfaces used by Remote MCP deployments and Autheory are in:
github.com/theory-cloud/apptheory/runtime/oauth
Transport + endpoint
AppTheory implements MCP Streamable HTTP on a single path:
POST /mcp: JSON-RPC requests, notifications, and client responsesGET /mcp: resumable SSE replay viaLast-Event-ID, or a short-lived keepalive SSE response whenLast-Event-IDis absentDELETE /mcp: session termination
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 - Resume header:
last-event-id
Important transport behavior:
POST /mcprequirescontent-type: application/jsonPOST /mcprequiresacceptsupport for bothapplication/jsonandtext/event-streamGET /mcprequiresacceptsupport fortext/event-streaminitializeis the only request that creates a session and returnsmcp-session-id- subsequent
POST /mcp,GET /mcp, andDELETE /mcpcalls requiremcp-session-id - missing session header returns
400 - unknown or expired sessions return
404 mcp-protocol-versionis optional after initialization; when present it must be supported and must match the session’s negotiated protocol version- JSON-RPC success and error payloads return HTTP
200; transport-level failures such as missing sessions, bad protocol headers, rejected origins, or missing replay events return HTTP4xx/5xx
Supported protocol versions negotiated on initialize:
2025-11-25(latest)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, clients should either omit
mcp-protocol-versionor send the exact negotiated version. - 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.
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 protocol header different from the negotiated session version now receive HTTP
400 - 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
AppTheory currently dispatches these MCP request methods:
initializepingtools/listtools/callresources/listresources/readresources/subscriberesources/unsubscribelogging/setLevelcompletion/completetasks/gettasks/resulttasks/listtasks/cancelprompts/listprompts/get
Accepted notification methods:
notifications/initializednotifications/cancelled
Other transport notes:
- posted client responses are accepted for Streamable HTTP compliance and return
202 Acceptedwith no body - 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
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 (initialize)
The initialize result advertises 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.WithTaskRuntime(...)supplies a store, at least one registered tool declares task support, and tasks are enabled ->"tasks": {...}for protocol2025-11-25sessions
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 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
}),
)
logging/setLevel validates MCP logging levels (debug, info, notice, warning, error, critical, alert,
emergency) before invoking the hook.
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.
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 asisError
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) - if you’re behind an adapter that buffers responses, clients may receive progress only after completion
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(...).