Table of Contents
- Module 1: Advanced Server Features
- Course Scope and Prerequisites
- Server Utilities Overview
- Filters as Middleware
- Demo: Filters via Function Composition
- Demo: Filters via a Custom Middleware Pipeline
- Completions for Prompt and Resource Arguments
- Demo: Completions for CSS Color Names
- Notifications Overview
- Demo: Dynamic Tool List Changes
- Demo: Resource Update Subscriptions
- Demo: Progress Notifications for Long-Running Tools
- Module 2: Advanced Client Features
- Stateless vs. Stateful MCP Servers
- Roots: Defining File System Boundaries
- Demo: Scanning Project Root Directories
- Demo: Reacting to Root List Changes
- Demo: Building a Stateful HTTP MCP Server with Cloudflare
- Elicitations: Human-in-the-Loop Workflows
- Demo: Form-Based Elicitation
- Demo: URL-Based Elicitation
- Sampling: Letting the Server Reason
- Demo: Sampling Without Tools
- Demo: Sampling With Tools
- Module 3: Custom MCP Clients
- Module 4: Agentic Workflows with MCP
- Summary
Module 1: Advanced Server Features
Course Scope and Prerequisites
This material builds directly on foundational MCP concepts: tools, prompts, resources, and how servers and clients communicate over standard I/O and HTTP transports both locally and remotely. You should already be comfortable with those basics before working through the content below.
The demos throughout this course are deliberately polyglot. You’ll see the same advanced feature implemented across several MCP SDKs and languages, primarily Node.js with TypeScript, Python with FastMCP, and Cloudflare Workers/Agents for stateful, remotely-hosted servers. There are two reasons for this variety:
- It shows a range of tools and implementation patterns so you can pick whichever fits your stack.
- It reflects reality: most MCP SDKs and clients implement the foundational parts of the specification well, but advanced features are where you’ll run into gaps, inconsistent support, or SDK-specific workarounds.
The overall structure of the material is:
- Individual advanced server features, demoed in isolation with both TypeScript and Python.
- Individual advanced client features (roots, elicitation, sampling), which require a stateful server, demoed with TypeScript and Cloudflare.
- Building a custom MCP client from scratch using FastMCP to exercise tools, resources, prompts, and sampling programmatically.
- Tying everything together into an agentic workflow using Cloudflare Agents, Workflows, and a wrapped third-party REST API.
| Technology | Role in the course |
|---|---|
| TypeScript MCP SDK | Server and client demos over standard I/O and HTTP; filters, completions, notifications, roots, elicitation, sampling |
| FastMCP (Python) | Server-side component visibility, progress reporting, and a fully custom Python MCP client |
| Cloudflare Agents SDK | Stateful, serverless MCP servers backed by Durable Objects; chat-based agents; Code Mode |
| Cloudflare Workflows | Deterministic, retryable multi-step business processes triggered by an agent |
| Anthropic / OpenAI APIs | Model providers used for client-side sampling handlers |
Server Utilities Overview
Tools, resources, and prompts make up the bulk of any MCP server, but the specification and SDKs expose several smaller utilities that matter for production servers:
- Filters — not formally part of the MCP specification, but supported by SDKs as a way to intercept and modify the list of tools, prompts, and resources exposed to a client, or to wrap message handling with cross-cutting logic. Conceptually this is middleware.
- Completions — a way for a server to expose valid or suggested values for a prompt or resource argument so a client can offer autocomplete or search-as-you-type behavior to a user.
- Notifications — one-way messages that let the server tell the client (or vice versa) that something important happened, such as a list of tools changing or a long-running operation making progress.
flowchart TB
subgraph "MCP Server Utilities"
A[Filters] -->|Cross-cutting logic: logging, auth, caching| A1["Wrap tools/prompts/resources request-response"]
B[Completions] -->|Argument autocomplete| B1["Prompt/resource argument suggestions"]
C[Notifications] -->|One-way, no response| C1["List changed, resource updated, progress"]
end
| Utility | Part of official spec? | Typical use cases |
|---|---|---|
| Filters | No (SDK-specific) | Logging, authN/authZ, caching, conditionally hiding tools |
| Completions | Yes | Autocomplete for prompt/resource arguments backed by lookup data |
| Notifications | Yes | List-changed events, resource subscriptions, progress reporting |
Filters as Middleware
Filters sit between an incoming request (for example, tools/list, or a tool call) and the response the server sends back. They are ideal for cross-cutting concerns: logging every request, authenticating or authorizing a caller, or caching results — anything that applies uniformly across multiple kinds of messages rather than being specific to a single tool.
A simple illustration: a client sends tools/list. Normally the server just builds tool metadata and returns it. But what if the list of tools should depend on context — the environment, the caller’s identity, or some other server state? That’s where a filter (middleware) is inserted into the pipeline.
Filter support is not part of the MCP specification — it’s entirely SDK-specific. Three examples show how differently this is handled:
C# SDK — a builder method, WithMessageFilters, lets you register incoming or outgoing message filters that operate on the raw JSON-RPC message:
var builder = McpServerBuilder.Create()
.WithMessageFilters(filters =>
{
filters.AddIncomingMessageFilter(async (context, next) =>
{
var message = context.Message; // raw JsonRpcMessage
logger.LogInformation("Incoming: {Method}", message.Method);
await next(context);
});
});
The C# SDK also supports more granular request filters that target specific request types (for example, only tool call requests), in addition to these low-level message filters.
FastMCP (Python) — an add_middleware method wraps requests with custom logic in a similar shape:
from fastmcp import FastMCP
mcp = FastMCP("demo-server")
@mcp.add_middleware
async def logging_middleware(request, call_next):
print(f"Incoming request: {request.method}")
response = await call_next(request)
print(f"Outgoing response for: {request.method}")
return response
TypeScript SDK — there is currently no built-in middleware or filtering mechanism. The SDK can be plugged into frameworks like Express or Hono, but that only helps for the HTTP transport — it does nothing for standard I/O. Two workarounds exist:
- Function composition — plain JavaScript functions wrap a single handler with extra logic. Simple, but limited: it only covers handlers you remember to wrap, and it can’t intercept things like the
tools/listresponse. - A custom request handler — replacing the underlying server’s
setRequestHandlermethod so every request, of any kind, flows through your own pipeline.
Demo: Filters via Function Composition
A simple TypeScript MCP server registers a single status tool that reports the current environment and timestamp, using the standard I/O transport:
server.registerTool(
"status",
{ description: "Returns current environment and timestamp" },
async () => ({
content: [
{ type: "text", text: `${process.env.NODE_ENV} @ ${new Date().toISOString()}` },
],
})
);
Running it (npm run demo1) and issuing a tools/call JSON-RPC request over standard I/O confirms it works. To add logging around every tool call, a withLogging higher-order function wraps a handler:
type ToolCallback = (params: unknown, extra: unknown) => Promise<CallToolResult>;
function withLogging(handler: ToolCallback): ToolCallback {
return async (params, extra) => {
console.log("Before tool call", params);
const result = await handler(params, extra);
console.log("After tool call", result);
return result;
};
}
server.registerTool(
"status",
{ description: "Returns current environment and timestamp" },
withLogging(async () => ({
content: [
{ type: "text", text: `${process.env.NODE_ENV} @ ${new Date().toISOString()}` },
],
}))
);
Restarting the server and calling the tool now shows two log lines — one before and one after the call. This is plain JavaScript function composition, and while it works, it has real gaps:
- You must remember to wrap every handler; forgetting means that call goes unlogged.
- It only applies to registered tool/prompt/resource callbacks. It cannot intercept the
tools/list(orprompts/list,resources/list) request itself, because that’s handled internally by the SDK, not by your callback.
To solve both problems, you need to go one level deeper than function composition.
Demo: Filters via a Custom Middleware Pipeline
Writing a hand-rolled middleware pipeline is a useful exercise for understanding how an MCP server actually works under the hood. The server used here adds a second tool, debug_logs, which simulates collecting debug logs and should only be available in development.
The high-level McpServer object (the server variable used in registerTool) is a friendly wrapper around a low-level server API, accessible as server.server. That underlying server exposes setRequestHandler(method, handler), letting you register a handler for any MCP method (e.g. tools/list). The problem: this API provides no way to retrieve the original handler once you overwrite it — calling setRequestHandler again simply replaces it.
Because JavaScript is dynamic, the underlying setRequestHandler function itself can be monkey-patched: capture the original method, then replace it with a wrapper that captures whichever handler is being registered and wraps that with custom logic before delegating to it. This produces a small building block that intercepts every method registered after the wrapper is installed — not just tool calls.
type MiddlewareHandler = (
request: JsonRpcRequest,
extra: RequestExtra,
next: () => Promise<unknown>
) => Promise<unknown>;
function withMiddleware(server: McpServer) {
const middlewares: MiddlewareHandler[] = [];
const underlyingServer = server.server;
const originalSetRequestHandler = underlyingServer.setRequestHandler.bind(underlyingServer);
underlyingServer.setRequestHandler = (method: string, handler: (...args: unknown[]) => unknown) => {
originalSetRequestHandler(method, async (request: JsonRpcRequest, extra: RequestExtra) => {
let index = -1;
const dispatch = async (i: number): Promise<unknown> => {
if (i <= index) throw new Error("next() called multiple times");
index = i;
const middleware = middlewares[i];
return middleware
? middleware(request, extra, () => dispatch(i + 1))
: handler(request, extra);
};
return dispatch(0);
});
};
return {
use(handler: MiddlewareHandler) {
middlewares.push(handler);
return this;
},
};
}
With this building block, an Express-like .use() API lets you compose a pipeline of independent middleware functions. Three are demoed:
Logging middleware — logs the duration of every request, of any method, not just tool calls:
const loggingMiddleware: MiddlewareHandler = async (request, extra, next) => {
const start = Date.now();
const result = await next();
console.log(`${request.method} took ${Date.now() - start}ms`);
return result;
};
Caching middleware — caches tool results for a fixed TTL, taking advantage of JavaScript closures to keep a Map alive across requests:
function cachingMiddleware(ttlMs = 5000): MiddlewareHandler {
const cache = new Map<string, { value: unknown; expires: number }>();
return async (request, extra, next) => {
const key = JSON.stringify(request.params);
const cached = cache.get(key);
if (cached && cached.expires > Date.now()) {
return cached.value;
}
const result = await next();
cache.set(key, { value: result, expires: Date.now() + ttlMs });
return result;
};
}
allowedEnvs middleware — reads server metadata on initialization and disables tools that aren’t allowed to run in the current environment. This relies on an onInitialized hook and an internal _meta object attached to each tool:
server.registerTool(
"debug_logs",
{
description: "Collects debug logs from the server (development only)",
_meta: { allowedEnvs: ["development"] },
},
async () => ({ content: [{ type: "text", text: "...debug output..." }] })
);
function allowedEnvsMiddleware(server: McpServer) {
server.server.oninitialized = () => {
const env = process.env.NODE_ENV ?? "development";
for (const tool of server.server.getRegisteredTools()) {
const allowedEnvs = tool._meta?.allowedEnvs as string[] | undefined;
if (allowedEnvs && !allowedEnvs.includes(env)) {
tool.disable();
}
}
};
}
Assembling the pipeline:
const middleware = withMiddleware(server);
middleware.use(loggingMiddleware);
middleware.use(cachingMiddleware());
allowedEnvsMiddleware(server);
Running the server through the MCP Inspector (npm run inspect2) now shows logging output even for a List Tools click, not just tool calls — proof the pipeline intercepts every message after initialization. By default the server runs in development mode, so debug_logs appears in the tool list. Setting NODE_ENV=production in the Inspector’s environment variables and restarting the server, then refetching the tool list, confirms debug_logs is no longer returned — it was disabled during onInitialized.
sequenceDiagram
participant C as Client
participant P as Middleware Pipeline
participant H as Original Handler
C->>P: tools/list or tools/call
P->>P: loggingMiddleware (start timer)
P->>P: cachingMiddleware (check cache)
P->>H: next() reaches original handler
H-->>P: result
P->>P: cachingMiddleware (store result)
P->>P: loggingMiddleware (log duration)
P-->>C: response
While the TypeScript SDK doesn’t ship a middleware pipeline out of the box, this demonstrates that — with a bit of JavaScript ingenuity — you can build one yourself.
Completions for Prompt and Resource Arguments
Completions solve a specific UX problem: a prompt or resource argument needs a value from a known, possibly large or backend-driven set, and an LLM (or human) filling in that argument might not know valid values without help. Completions let the client request autocomplete suggestions.
Completions are part of the official specification and are declared as a server capability during initialization. The request/response shape:
{
"method": "completion/complete",
"params": {
"ref": { "type": "ref/prompt", "name": "code_review" },
"argument": { "name": "language", "value": "py" }
}
}
The spec doesn’t mandate matching semantics — a server might do a prefix match (values starting with “py”) or a substring match. The response can also indicate a total count and whether more results exist beyond what was returned:
{
"result": {
"completion": {
"values": ["python", "pypy"],
"total": 2,
"hasMore": false
}
}
}
When a prompt or resource has more than one argument, the client includes previously provided argument values as context, so the server can offer dependent suggestions — for example, once language is python, suggesting Python-specific frameworks like flask:
{
"method": "completion/complete",
"params": {
"ref": { "type": "ref/prompt", "name": "code_review" },
"argument": { "name": "framework", "value": "" },
"context": { "arguments": { "language": "python" } }
}
}
Because completions are part of the spec, every SDK supports them, but with different shapes:
| SDK | API surface |
|---|---|
| C# | WithCompleteHandler — a generic handler checking the ref type, applicable to prompts or resources |
| Python (FastMCP) | @mcp.completion() decorator receiving ref, argument, and optional context |
| TypeScript | completable() wrapper applied directly to a Zod schema field when defining prompt/resource arguments |
.WithCompleteHandler(async (request, cancellationToken) =>
{
if (request.Ref is PromptReference { Name: "code_review" } && request.Argument.Name == "language")
{
var matches = KnownLanguages.Where(l => l.StartsWith(request.Argument.Value));
return new CompleteResult { Completion = new Completion { Values = matches.ToArray() } };
}
return new CompleteResult { Completion = new Completion { Values = [] } };
});
@mcp.completion()
async def handle_completion(ref, argument, context):
if ref.name == "code_review" and argument.name == "language":
return Completion(values=[l for l in KNOWN_LANGUAGES if l.startswith(argument.value)])
import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
import { z } from "zod";
server.registerPrompt("greeting", {
argsSchema: {
name: completable(z.string(), async (value) => {
const names = ["Alice", "Bob", "Charlie"];
return names.filter((n) => n.toLowerCase().startsWith(value.toLowerCase()));
}),
},
});
With C# and Python, you write one generic handler that could apply to a prompt or a resource. With TypeScript, completion logic lives directly on the argument’s schema definition.
Demo: Completions for CSS Color Names
A TypeScript server exposes a single color_haiku prompt. It accepts a color (by name, hex code, or other CSS format) and an optional mood, using a color-parsing library to normalize the input before instructing the client’s LLM to write a haiku. Without completions, a user has no guidance on which named colors are valid.
Wrapping the color argument’s schema with completable fixes that:
import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
import { z } from "zod";
import * as ColorJS from "colorjs.io";
server.registerPrompt(
"color_haiku",
{
argsSchema: {
color: completable(z.string(), async (value) => {
const colorNames = Object.keys(ColorJS.keywords ?? {});
if (!value) return colorNames;
return colorNames.filter((name) => name.toLowerCase().startsWith(value.toLowerCase()));
}),
mood: z.string().optional(),
},
},
async ({ color, mood }) => {
const parsed = new ColorJS(color);
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Write a haiku about the color ${parsed.toString()}${mood ? ` with a ${mood} mood` : ""}.`,
},
},
],
};
}
);
Restarting the server and opening the color input now lists every valid CSS color keyword. The Inspector’s history panel shows the completion/complete messages being exchanged as the user types. Providing a mood like “happy” and clicking Get Prompt correctly parses the color into the outgoing prompt.
An important caveat: the underlying schema still only enforces that the value is a string. Completions are purely a UX aid — the server is still responsible for validating or parsing whatever the user actually submits, since nothing prevents them from ignoring the suggestions.
Notifications Overview
Notifications are one-way messages — from server to client or client to server — with no ID field, meaning the recipient must not (and structurally cannot meaningfully) respond. They exist to inform the other party that something happened, even though the receiver is free to take its own action afterward.
Notifications underpin a lot of MCP’s plumbing:
- The very first message in a session — the
initializednotification — is one. - Logging messages are notifications.
- A client can send a cancellation notification for an in-flight request.
- A server can notify that its tools, prompts, or resources list changed.
- A client can subscribe to a resource and be notified when it updates.
- A server can send progress updates for long-running operations.
mindmap
root((Notifications))
Initialization
initialized
Logging
logging/message
Cancellation
notifications/cancelled
List changes
tools/list_changed
resources/list_changed
prompts/list_changed
Resource subscriptions
resources/updated
Progress
notifications/progress
The last three categories — list changes, resource subscriptions, and progress — are where notifications most directly improve the end-user experience, and each gets its own demo below.
Demo: Dynamic Tool List Changes
FastMCP is used here rather than TypeScript because the TypeScript SDK has no clean way to reconfigure tool availability after the server starts (the workaround is described earlier as “ugly”). FastMCP instead offers component visibility: tools can be tagged and then enabled/disabled as a group, either globally or per client session.
from fastmcp import FastMCP, Context
from fastmcp.tools import ToolAnnotations
mcp = FastMCP("admin-demo")
@mcp.tool(tags={"admin"}, annotations=ToolAnnotations(destructiveHint=True))
async def restart_server() -> str:
return "Server restarted"
@mcp.tool(tags={"admin"}, annotations=ToolAnnotations(destructiveHint=True))
async def delete_server() -> str:
return "Server deleted"
@mcp.tool()
async def get_service_status() -> str:
return "OK"
@mcp.tool()
async def modify_admin_settings(ctx: Context) -> str:
# Disables components for the CURRENT client session only,
# and automatically sends list-changed notifications for
# tools, resources, and prompts.
await ctx.disable_components(tags={"admin"})
return "Admin tools disabled for this session"
Note the distinction between two disabling mechanisms:
mcp.disable(tags={"admin"})— a top-level call that disables matching components across the whole server for all future client connections. It does not send a notification to already-connected clients.ctx.disable_components(tags={"admin"})— called from within a tool using the requestContext, this disables components for the current client only and does sendlist_changednotifications.
Running the server through the Inspector, tools/list initially shows all four tools, including the admin-tagged restart_server, which also displays the destructiveHint metadata. Calling modify_admin_settings triggers list_changed notifications for tools, resources, and prompts simultaneously. The Inspector automatically clears its cached tool list in response, and refetching shows only the two non-admin tools remain visible.
Demo: Resource Update Subscriptions
Clients can subscribe to a specific resource and be told when it changes. Before allowing subscriptions, the server must declare the subscribe capability under resources — this is not enabled by default in the TypeScript SDK because it requires additional bookkeeping (tracking which clients are subscribed to which resource URIs):
const server = new McpServer(
{ name: "subscriptions-demo", version: "1.0.0" },
{ capabilities: { resources: { subscribe: true } } }
);
const subscriptions = new Set<string>();
let systemState = { status: "ok" };
server.server.setRequestHandler(SubscribeRequestSchema, async (request) => {
subscriptions.add(request.params.uri);
return {};
});
server.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
subscriptions.delete(request.params.uri);
return {};
});
server.registerResource("system-status", "system://status", async () => ({
contents: [{ uri: "system://status", text: JSON.stringify(systemState) }],
}));
server.registerTool(
"update_status",
{ description: "Updates the global system status" },
async ({ status }) => {
systemState.status = status;
if (subscriptions.has("system://status")) {
await server.server.notification({
method: "notifications/resources/updated",
params: { uri: "system://status" },
});
}
return { content: [{ type: "text", text: "Status updated" }] };
}
);
In this demo, subscriptions are tracked in a simple in-memory Set — production servers would likely back this with a database so subscriptions survive process restarts and scale across instances.
sequenceDiagram
participant Client
participant Server
Client->>Server: resources/subscribe {uri: "system://status"}
Server-->>Client: {} (ack)
Client->>Server: tools/call update_status {status: "falling over"}
Server-->>Client: tool result
Server-->>Client: notifications/resources/updated {uri: "system://status"}
Client->>Server: resources/read {uri: "system://status"} (manual refresh)
Server-->>Client: updated contents
Running the demo through the Inspector: listing resources shows the current system status, subscribing sends a resources/subscribe request, and calling update_status with a new value (“falling over,” for example) succeeds and triggers a resources/updated notification. At the time of this recording, the Inspector has a gap — it does not automatically refetch a subscribed resource in response to that notification, even though the message is correctly sent. Manually clicking Refresh on the resources tab confirms the new status is in fact reflected server-side; only the client-side auto-refresh behavior is missing in this particular tool.
Demo: Progress Notifications for Long-Running Tools
For tools, resources, or prompts (most commonly tools) that take a while or perform multiple steps, a client can opt into progress notifications by including a progressToken in its request — any unique value used purely for correlation. The server can then send notifications/progress messages containing the current progress, an optional total, and an optional message. Only progress and progressToken are required.
{
"method": "notifications/progress",
"params": {
"progressToken": "abc-123",
"progress": 2,
"total": 4,
"message": "Running tests"
}
}
FastMCP makes this simple via the tool’s Context object:
from fastmcp import FastMCP, Context
import asyncio
mcp = FastMCP("deployment-demo")
_DEPLOYMENT_STEPS = ["Building", "Running tests", "Deploying", "Verifying"]
@mcp.tool()
async def run_deployment(environment: str, ctx: Context) -> str:
total = len(_DEPLOYMENT_STEPS)
for i, step in enumerate(_DEPLOYMENT_STEPS, start=1):
await ctx.report_progress(progress=i, total=total, message=step)
await asyncio.sleep(1) # simulated work
await ctx.report_progress(progress=total, total=total, message="Deployment complete")
return f"Deployed to {environment}"
Running run_deployment with environment="staging" through the Inspector shows the Run Tool button switch to a progress indicator, and each simulated step produces a distinct progress notification in the notifications panel with the expected progress, total, and message values.
sequenceDiagram
participant Client
participant Server
Client->>Server: tools/call run_deployment {progressToken: "abc-123"}
loop for each deployment step
Server-->>Client: notifications/progress {progress, total, message}
end
Server-->>Client: tool result (deployment complete)
Module 2: Advanced Client Features
Stateless vs. Stateful MCP Servers
Three client-driven features are covered in this module: roots (the simplest), elicitations, and sampling (the most conceptually involved). Before diving into them, it’s essential to understand a prerequisite: some of these features require the server to be stateful.
- A stateless MCP server keeps no state between requests. Each request is isolated; after it completes, the connection (and any associated state) is torn down. This is simple, memory-efficient, and ideal for serverless hosting, since the whole server can spin up and down per request — but it limits capabilities to essentially one-way communication.
- A stateful MCP server maintains session state between requests from the same client, over a persistent connection.
If you’re running a standard I/O server, or an HTTP server locally, it can be stateful with relatively little effort. If it’s hosted remotely, the hosting provider needs to explicitly support stateful HTTP communication. The two client capabilities that specifically require statefulness are elicitations and sampling — both need the server to be able to reach back out to an already-connected client mid-operation.
One nuance worth internalizing: if you’re using the standard I/O transport, your server is already stateful, because it’s spawned directly by the client as a long-running process. With HTTP, the default is stateless unless you explicitly add support for tracking a session ID per client connection, persisted somewhere — in memory, or in a backing store like SQLite for durability and scale-out.
flowchart LR
subgraph "Stateless HTTP Server"
A1[Request 1] --> S1[New server instance] --> R1[Response]
A2[Request 2] --> S2[New server instance] --> R2[Response]
end
subgraph "Stateful HTTP Server"
B1[Request 1] --> P[Persistent server + session store]
B2[Request 2, same session] --> P
P --> R3[Elicitation / Sampling possible]
end
| Aspect | Stateless server | Stateful server |
|---|---|---|
| Connection lifecycle | Torn down per request | Persists across requests for a session |
| Memory footprint | Minimal, spins up/down per request | Requires the process (or session store) to stay alive |
| Hosting fit | Ideal for serverless | Requires always-on hosting or a serverless platform with persistent state (e.g. Durable Objects) |
| Supports elicitation/sampling? | No | Yes |
| Standard I/O transport | Always stateful by nature | N/A |
| HTTP transport | Default unless explicitly upgraded | Requires session ID tracking + storage |
An HTTP-based stateful requirement also matters for authentication, since tracking auth state needs a persistent session. Interestingly, the MCP server itself can remain stateless if a separate networking layer in front of it — an NGINX reverse proxy, load balancer, or authentication gateway — handles the stateful session tracking instead. Even in that setup, though, the MCP server behind it still cannot support elicitations or sampling, because those specifically require the server process itself to hold an active connection back to the client. This kind of layered, gateway-fronted deployment is a more advanced topic in its own right; the focus here stays on building a stateful MCP server that tracks client sessions directly.
Roots: Defining File System Boundaries
Roots let a client expose file system boundaries to a server, so the server knows which directories and files it may operate on. This only makes sense for local MCP servers — a remote server has no access to the user’s local file system at all.
Unlike tools, resources, and prompts (declared by the server), roots support is declared by the client during initialization. If the client advertises the roots capability, it can also indicate whether it will send notifications when the root list changes.
// Server -> Client
{ "method": "roots/list" }
// Client -> Server response
{
"result": {
"roots": [
{ "uri": "file:///Users/dev/project", "name": "project" }
]
}
}
// Client -> Server notification when the list changes
{ "method": "notifications/roots/list_changed" }
Roots are especially useful when a server is talking to developer tools or coding agents that need file system context. Standard I/O has a natural advantage here since it’s inherently stateful (a long-running process), which makes it easier to cache root information — though caching isn’t strictly required.
Demo: Scanning Project Root Directories
A TypeScript server exposes a single scan-roots tool that finds the five largest files across whatever root directories the client provides. During initialization, if the client’s capabilities include roots, the server requests the list and stores it:
let roots: { uri: string; name?: string }[] = [];
const server = new McpServer({ name: "roots-demo", version: "1.0.0" });
server.server.oninitialized = async () => {
if (server.server.getClientCapabilities()?.roots) {
const response = await server.server.listRoots();
roots = response.roots;
}
};
server.registerTool(
"scan-roots",
{ description: "Scans configured roots for the 5 largest files" },
async () => {
if (roots.length === 0) {
return { content: [{ type: "text", text: "No roots configured." }] };
}
const files = await findLargestFiles(roots, 5);
return {
content: [
{ type: "text", text: files.map((f) => `${f.path} (${f.size} bytes)`).join("\n") },
],
};
}
);
Running the server (npm run inspect1) and opening the Inspector, no roots are configured by default. Adding one requires copying a local file path into the Inspector’s Roots panel, formatted as a file:// URI, then clicking Save Changes. That triggers the client to send notifications/roots/list_changed — but since this first version of the demo isn’t listening for that notification, running scan-roots still reports no roots. Restarting the server, however, picks up the roots correctly during initialization, and running the tool again returns the five largest files found.
Demo: Reacting to Root List Changes
The previous demo left the server’s understanding of its roots stale until a restart. A second version fixes this by registering a notification handler for notifications/roots/list_changed, which refetches the root list and also propagates a tools/list_changed notification of its own (since the tool’s behavior effectively depends on the current roots):
server.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
const response = await server.server.listRoots();
roots = response.roots;
lastUpdated = new Date();
await server.server.notification({ method: "notifications/tools/list_changed" });
});
server.registerTool(
"describe-scope",
{ description: "Reports the current roots and when they were last updated" },
async () => ({
content: [
{
type: "text",
text: `Last updated: ${lastUpdated?.toISOString() ?? "never"}\nRoots:\n${roots
.map((r) => r.uri)
.join("\n")}`,
},
],
})
);
Rather than a file scanner, this server exposes a describe-scope debug tool that reports the last-updated timestamp and current root list. Running it before any roots are added shows a timestamp but no roots. Adding a root now results in a server-side log confirming the notification was received and roots were updated — and calling describe-scope again, without restarting anything, immediately reflects the new root.
sequenceDiagram
participant Client
participant Server
Note over Client,Server: Initialization
Server->>Client: roots/list
Client-->>Server: [] (empty initially)
Client->>Client: User adds a root in the Inspector UI
Client-->>Server: notifications/roots/list_changed
Server->>Client: roots/list (re-fetch)
Client-->>Server: [{ uri, name }]
Server-->>Client: notifications/tools/list_changed
Demo: Building a Stateful HTTP MCP Server with Cloudflare
Elicitations and sampling both require a stateful server, so before demoing them, the course builds one using Cloudflare. Starting point: a basic MCP server exposing a hello tool, hosted via Cloudflare’s Agents SDK using createMcpHandler. By default this is stateless — a new server instance is created per request:
import { createMcpHandler } from "agents/mcp";
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
return createMcpHandler((server) => {
server.registerTool("hello", { description: "Say hello" }, async (_params, extra) => ({
content: [{ type: "text", text: `Hello! Session: ${extra.sessionId ?? "undefined"}` }],
}));
return server;
})(request, env, ctx);
},
};
Calling the tool through the Inspector confirms sessionId comes back undefined — proof there’s no session tracking happening.
To make an HTTP server stateful, the transport layer (specifically the streamable HTTP transport) needs to generate and track session IDs. The TypeScript SDK’s example stateful server keeps an in-memory map of session ID to a dedicated transport connection per session:
const transports: Record<string, StreamableHTTPServerTransport> = {};
app.post("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
transports[id] = transport;
},
});
transport.onclose = () => {
if (transport.sessionId) delete transports[transport.sessionId];
};
const server = createServer();
await server.connect(transport);
} else if (sessionId && !transports[sessionId]) {
res.status(404).send("Session not found");
return;
} else {
res.status(400).send("Bad request");
return;
}
await transport.handleRequest(req, res, req.body);
});
Walking through the logic: if a request arrives with a known session ID, its existing transport is reused. If there’s no session ID but it’s a valid initialize request, a brand new transport is created, given a generated session ID, and registered in the map; it’s cleaned up from the map when the connection closes. If a session ID is present but unrecognized, the session was lost — the server returns 404. Anything else is a bad request.
This requires the server process to stay running continuously, since the in-memory map would be wiped on every request if the server were recreated each time — and this is still all in-memory, so a load-balanced deployment with multiple instances would need the session map backed by a shared database instead.
Cloudflare’s Durable Objects solve this cleanly. A Durable Object is a way to build a stateful, serverless application — the “secret ingredient” that lets Cloudflare support statefulness even in a serverless hosting model, since most serverless providers can’t persist state across requests at all. Cloudflare’s McpAgent class abstracts away all of the session-management plumbing shown above:
import { McpAgent } from "agents/mcp";
export class MyMcp extends McpAgent {
server = new McpServer({ name: "stateful-demo", version: "1.0.0" });
async init() {
this.server.registerTool("hello", { description: "Say hello" }, async (_params, extra) => ({
content: [{ type: "text", text: `Hello! Session: ${extra.sessionId}` }],
}));
}
}
export default {
fetch: MyMcp.serve("/mcp"),
};
Two additions are needed in the Cloudflare wrangler.toml config:
[[durable_objects.bindings]]
name = "MCP_OBJECT"
class_name = "MyMcp"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["MyMcp"]
The Durable Object binding wires the class into Cloudflare’s runtime; the SQLite migration is needed because Durable Object storage is backed by SQLite, and McpAgent automatically creates the tables it needs to persist session state.
flowchart TB
Client -->|HTTP request + session ID| Worker["Cloudflare Worker (createMcpHandler / McpAgent.serve)"]
Worker -->|idFromName / get| DO["Durable Object instance (MyMcp)"]
DO -->|persisted via| SQLite[(SQLite-backed storage)]
DO -->|same instance for repeat requests| Worker
Restarting the server with these changes and reconnecting the Inspector: calling the tool now returns a real session ID, and repeated calls return the same ID — session tracking works. Inspecting the browser’s Network tab confirms the same connection ID is reused across requests, and the EventStream tab shows every message exchanged on the wire, useful for debugging. With a stateful server in place, elicitations and sampling become possible.
Elicitations: Human-in-the-Loop Workflows
Normally a tool call runs to completion and returns a result. Elicitation lets a server pause a tool call (or any other operation) to ask the user for input mid-flight — for example, confirming a destructive action, or providing extra information the server needs before proceeding. This is a human-in-the-loop pattern.
The specification defines two elicitation modes, declared by the client during initialization if it supports the elicitation capability:
form— the client displays form inputs based on a JSON schema to collect information (e.g. a confirmation dialog or a text box). This should never be used to collect sensitive data like passwords.url— the server tells the client to have the user visit a URL to perform some action tracked server-side and associated with that user (e.g. a payment link, or authorizing a third-party application). This is the correct mode for sensitive information, since none of it passes through the LLM context or the MCP connection itself.
Form-based elicitation request:
{
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "What should we call you?",
"requestedSchema": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
}
}
}
form is the default mode when none is specified. URL-based elicitation requires more properties: a unique elicitationId so the server can track it, the destination url, and an accompanying message.
{
"method": "elicitation/create",
"params": {
"mode": "url",
"elicitationId": "eula-accept",
"url": "https://example.com/eula?session=abc123",
"message": "Please review and accept the End User License Agreement."
}
}
Since URL-based elicitation requires the user to take an out-of-band action on their device, the server can optionally notify the client once that action completes:
{
"method": "notifications/elicitation/complete",
"params": { "elicitationId": "eula-accept" }
}
This notification is optional, and clients may choose to ignore it. Where it matters is when a URL-based elicitation must be completed before anything else can proceed: a tool, prompt, or resource can return a specific error indicating that a URL elicitation is required (including the pending elicitationId), so the client can prompt the user again if they missed it the first time. The completion notification is what later closes that loop.
Elicitations can be sent at essentially any point — when a user first connects, or in the middle of a tool call — depending entirely on what the server needs the user to do.
| Mode | Use case | Data sensitivity | Response shape |
|---|---|---|---|
form | Structured input collection, confirmations | Non-sensitive only | Content matching requestedSchema, plus action: accept / decline / cancel |
url | Out-of-band actions (payments, auth, sensitive input) | Safe for sensitive data (bypasses LLM/MCP entirely) | Optional elicitation/complete notification; server tracks completion via its own state |
Demo: Form-Based Elicitation
Both the MCP Inspector and VS Code Copilot support form-based elicitation. The TypeScript SDK’s McpAgent class exposes an elicitInput method that abstracts the underlying protocol call (including generating the request ID):
server.registerTool(
"greeting",
{ description: "Greets the user by nickname" },
async () => {
const requestedSchema = zodToJsonSchema(z.object({ nickname: z.string() }));
const result = await this.elicitInput({
message: "What nickname should I call you?",
requestedSchema,
});
if (result.action !== "accept") {
return { content: [{ type: "text", text: "No nickname provided." }] };
}
const { nickname } = result.content;
return { content: [{ type: "text", text: `Hello, ${nickname}!` }] };
}
);
The requested schema is defined with Zod and converted to JSON schema. If the user accepts, result.action is "accept" and the collected value is available on result.content; otherwise it’s "decline" or "cancel", and both should be handled explicitly.
Running the server (npm run demo1) and the Inspector (npm run inspect) side by side: calling the greeting tool switches the Inspector to its Elicitations tab, showing the request on the left and an auto-generated form UI on the right. Submitting a nickname returns it directly in the tool output.
The same server, wired up via an MCP configuration file in VS Code, works identically through Copilot Chat: asking Copilot to run the tool triggers an elicitation pop-up asking for the nickname, and submitting it returns the value in the conversation.
sequenceDiagram
participant User
participant Client as MCP Client (Inspector / Copilot)
participant Server
Client->>Server: tools/call greeting
Server->>Client: elicitation/create {mode: "form", requestedSchema}
Client->>User: Render form UI
User-->>Client: Submit nickname
Client-->>Server: {action: "accept", content: {nickname}}
Server-->>Client: tool result: "Hello, <nickname>!"
Demo: URL-Based Elicitation
This demo builds a EULA acceptance flow: no tool calls are allowed until the user visits an external URL and accepts an end-user license agreement.
Server state is modeled as a small TypeScript type with a Boolean flag:
interface State {
acceptedEULA: boolean;
}
With Cloudflare Agents, each client session gets its own persistent state through a Durable Object — reconnecting resumes the previous session, or gets fresh state if it was disconnected/expired. This removes the need to manually track per-client state leakage.
Since URL-based elicitation is a newer part of the spec, not every client supports it, so the server checks client capabilities during an onInitialized hook and falls back gracefully (in this demo, just a warning log) if unsupported:
export class MyMcp extends McpAgent<Env, State> {
initialState: State = { acceptedEULA: false };
async onInitialized() {
const caps = this.server.server.getClientCapabilities();
if (!caps?.elicitation?.url) {
console.warn("Client does not support URL-based elicitation");
return;
}
await this.server.server.elicitInput({
mode: "url",
elicitationId: "eula-accept",
url: `https://example.com/eula?session=${this.name}`,
message: "Please accept the End User License Agreement before continuing.",
});
}
async acceptEula() {
this.setState({ ...this.state, acceptedEULA: true });
await this.server.server.notification({
method: "notifications/elicitation/complete",
params: { elicitationId: "eula-accept" },
});
}
protectedTool = async () => {
if (!this.state.acceptedEULA) {
throw new UrlElicitationRequiredError({
elicitationId: "eula-accept",
url: `https://example.com/eula?session=${this.name}`,
message: "Please accept the EULA first.",
});
}
return { content: [{ type: "text", text: "Action completed successfully." }] };
};
}
The elicitation URL includes the current session ID in the query string, since it’s specific to the Durable Object instance tied to that particular client. The /eula endpoint (a completely separate route from the MCP endpoint itself, representing what in real life would live on the company’s own website) is handled by the worker’s fetch function:
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
if (url.pathname === "/eula") {
const sessionId = url.searchParams.get("session")!;
const id = env.MCP_OBJECT.idFromName(sessionId);
const stub = env.MCP_OBJECT.get(id) as DurableObjectStub<MyMcp>;
await stub.acceptEula();
return new Response("EULA accepted, you may close this window.");
}
return MyMcp.serve("/mcp").fetch(request, env);
},
};
This is where Durable Objects get interesting: because the MCP server is backed by a Durable Object, the Agents SDK can fetch a reference (a stub) to the running instance tied to a given session ID, and call public methods on it as if they were local — even though the call is actually routed internally within Cloudflare’s worker system to the live Durable Object instance and its actual method implementation. This is effectively an RPC binding: the stub is a pointer, not the object itself.
acceptEula flips the Boolean state and sends the elicitation/complete notification. The protected tool checks that flag first — if the EULA hasn’t been accepted, it throws a UrlElicitationRequiredError carrying the same elicitation details, ensuring the user is prompted again if they missed the initial notification.
Since the MCP Inspector doesn’t yet support URL-mode elicitation, this is demoed in VS Code instead. Starting the server and enabling it in VS Code’s MCP configuration immediately produces a toast notification in the corner — with no chat window even open — illustrating that elicitations can be sent “out of band” any time after initialization, not just during a tool call. Clicking Open URL, visiting the EULA page (which shows the session ID in the URL), and closing the browser window, then asking Copilot to call the protected tool, succeeds because the state flag was already updated.
Testing the failure path: restarting the connection and dismissing the initial toast without visiting the URL, then asking Copilot to call the protected tool again, correctly surfaces the EULA requirement mid-conversation (thanks to UrlElicitationRequiredError). Visiting the URL and returning to the conversation, the completion notification informs the client the elicitation was resolved.
sequenceDiagram
participant User
participant Client as MCP Client (VS Code)
participant Server as MyMcp (Durable Object)
participant Web as External /eula endpoint
Server->>Client: elicitation/create {mode: "url", elicitationId, url}
Client->>User: Toast notification with link
User->>Web: Opens /eula?session=<id>
Web->>Server: stub.acceptEula() (RPC to Durable Object)
Server->>Server: state.acceptedEULA = true
Server-->>Client: notifications/elicitation/complete {elicitationId}
User->>Client: Ask Copilot to call protected tool
Client->>Server: tools/call protectedTool
Server-->>Client: Success (EULA already accepted)
Sampling: Letting the Server Reason
Sampling lets the server ask the client’s own model to perform a generative task, then forwards the result back to the server for further processing. It’s effectively a way to delegate an LLM call to whatever model the user is already using, so the server doesn’t need its own backend model integration. Users bring their own model; the server can also suggest tools it wants the client’s model to use during that sampling call.
Clients declare support for the sampling capability, and separately whether they support tool use during sampling. Even without tool use, sampling is valuable for summarizing server-provided context, generating non-text content (some models can generate images or video), or delegating work to other MCP servers the user has connected. With tool use, sampling can drive a multi-step workflow, effectively offloading reasoning-based tasks to the connected agent rather than implementing that reasoning on the server itself.
A basic sampling request:
{
"method": "sampling/createMessage",
"params": {
"messages": [
{ "role": "user", "content": { "type": "text", "text": "What is the capital of France?" } }
],
"modelPreferences": {
"hints": [{ "name": "claude-3-sonnet" }],
"intelligencePriority": 0.8,
"costPriority": 0.2
},
"maxTokens": 200
}
}
Since the server doesn’t know what model the client uses, it can only hint or express priorities (e.g. favoring intelligence over cost) — the client always makes the final decision on which model actually runs.
With tool use, a request can offer the model a tool definition and either let it decide automatically whether to call it, or force it to:
{
"method": "sampling/createMessage",
"params": {
"messages": [
{ "role": "user", "content": { "type": "text", "text": "What's the weather in Paris?" } }
],
"tools": [
{
"name": "get_weather",
"inputSchema": { "type": "object", "properties": { "city": { "type": "string" } } }
}
],
"toolChoice": "auto"
}
}
If the server already exposes a weather tool directly, the user could simply call it without needing sampling at all — sampling shines when the server itself needs to reason during a tool call and act on the sampled result, not merely forward it straight back to the client.
Demo: Sampling Without Tools
A trip-planning server holds curated destination data the client’s LLM has no access to (representing, for example, proprietary data a travel company might maintain) and exposes a single plan_trip tool taking a destination and number of days:
const destinationData: Record<string, { spots: string[] }> = {
Tokyo: { spots: ["Senso-ji Temple", "Shibuya Crossing", "Tsukiji Outer Market"] },
Lisbon: { spots: ["Belem Tower", "Alfama District", "Sao Jorge Castle"] },
};
server.registerTool(
"plan_trip",
{ description: "Plans a trip itinerary using curated destination data" },
async ({ destination, days }) => {
const data = destinationData[destination];
const response = await server.server.createMessage({
messages: [
{
role: "user",
content: {
type: "text",
text: `Create a ${days}-day itinerary for ${destination} using these curated spots: ${data.spots.join(
", "
)}.`,
},
},
],
maxTokens: 500,
});
return { content: [{ type: "text", text: response.content.text }] };
}
);
Rather than returning the destination data directly, the tool sends a sampling request — a crafted prompt asking the client’s LLM to build an itinerary from the curated data. Testing with Tokyo and 5 days in the Inspector switches to the Sampling tab, where the tool’s hard-coded test harness lets you supply and accept a canned response, which is then reflected in the tool result.
Trying the same thing through an actual chat client shows a permission prompt first — the model asks whether it’s allowed to issue an LLM call on the tool’s behalf, since a user can always decline a sampling request and the server needs a fallback for that case. Allowing it produces a real generated itinerary — but with a subtlety: the text shown in the conversation is not identical to what the sampled model actually returned to the tool. The client’s outer model re-interpreted (and trimmed) the sampled response before presenting it to the user, effectively reprocessing the tool’s own output.
This raises an honest question about the value of sampling in this exact scenario: if the outer model is just going to reinterpret the result anyway, returning the raw curated data directly (and letting the client’s model build the itinerary once) would have worked just as well. The rule of thumb: if the experience is fine with the server returning data and a suggestion, just return the data. Sampling earns its complexity when the server itself needs to reason during the tool call and act on the sampled result — not merely pass it straight through.
sequenceDiagram
participant User
participant Client as Client LLM
participant Server
User->>Client: "Plan a 5-day trip to Tokyo"
Client->>Server: tools/call plan_trip {destination: "Tokyo", days: 5}
Server->>Client: sampling/createMessage {crafted itinerary prompt}
Client-->>User: "Allow this tool to use the model?"
User-->>Client: Allow (in this session)
Client-->>Server: sampled itinerary text
Server-->>Client: tool result: sampled text
Client->>User: (Re-synthesized) itinerary response
Demo: Sampling With Tools
Tool sampling is more involved: it can require multiple round trips within a single tool call, because the client can respond with a request for the server to call additional tools and forward their results back before producing a final answer.
The itinerary prompt is extended, asking the model to use a get_place_details tool to enrich each stop with an address and opening hours:
const getPlaceDetailsSchema = z.object({ place: z.string() });
class TripAgent extends McpAgent {
async getPlaceDetails({ place }: z.infer<typeof getPlaceDetailsSchema>) {
// Looked up from an in-memory object in this demo; a real server
// would likely call a database or external API here.
const details = placeDetailsData[place];
return { structuredContent: details };
}
async planTrip({ destination, days }: { destination: string; days: number }) {
const data = destinationData[destination];
const initialResponse = await this.server.server.createMessage({
messages: [
{
role: "user",
content: {
type: "text",
text: `Create a ${days}-day itinerary for ${destination} using: ${data.spots.join(
", "
)}. Use the get_place_details tool to enrich each itinerary item with address and opening hours.`,
},
},
],
tools: [
{
name: "get_place_details",
inputSchema: zodToJsonSchema(getPlaceDetailsSchema),
},
],
maxTokens: 800,
});
const toolResults = [];
for (const block of initialResponse.content) {
if (block.type === "tool_use") {
const result = await this.getPlaceDetails(block.input);
toolResults.push({
type: "tool_result",
toolUseId: block.id,
content: [{ type: "text", text: JSON.stringify(result.structuredContent) }],
});
}
}
const followUp = await this.server.server.createMessage({
messages: [
...initialResponse.originalMessages,
{ role: "assistant", content: initialResponse.content },
{ role: "user", content: toolResults },
{
role: "user",
content: {
type: "text",
text: "Return the final itinerary as-is, do not synthesize it again.",
annotations: { audience: ["assistant"] },
},
},
],
maxTokens: 800,
});
return { content: [{ type: "text", text: followUp.content.text }] };
}
}
Key implementation points worth calling out:
- The
get_place_detailstool metadata sent in the sampling request’stoolsarray looks just like a regular registered tool definition — a name and an input schema in JSON Schema format. - When the sampling response comes back, the server scans its content blocks for
tool_useentries — each representing the client asking the server to invoke a specific tool with specific input. - The tool function is implemented as a method directly on the agent class, so the server can invoke it programmatically without going through a full MCP tool call round trip — the same function backs both the MCP-exposed tool and this internal invocation.
- The tool’s result uses
structuredContent, letting the calling code grab a plain JavaScript object straight off the response rather than parsing text. - The follow-up sampling request must include, per the specification: all original messages from the initial request, the assistant’s own tool-use content, and a single message containing every tool result in the same order the client asked for them — each one correlated back to its
tool_useblock viatoolUseId. - The final instruction message uses the
audience: ["assistant"]annotation to hint that this message is meant only for the model’s internal reasoning, not to be shown directly to the end user.
sequenceDiagram
participant Client as Client LLM
participant Server
Client->>Server: tools/call plan_trip
Server->>Client: sampling/createMessage #1 {prompt + get_place_details tool}
Client-->>Server: response with tool_use blocks
loop for each tool_use block
Server->>Server: invoke getPlaceDetails(input) directly
end
Server->>Client: sampling/createMessage #2 {original msgs + tool_result blocks}
Client-->>Server: final itinerary text (audience: assistant)
Server-->>Client: tool result: enriched itinerary
At the time of this recording, most MCP clients — including the MCP Inspector and VS Code — do not yet support tool-based sampling capabilities, since this part of the spec is very new; attempting to run plan_trip in either produces an explicit error that the client doesn’t support sampling tools. Demonstrating this fully requires a custom MCP client, which is exactly what the next module builds.
Module 3: Custom MCP Clients
Demo: Scaffolding a FastMCP Client
MCP allows building your own clients, not just servers — every SDK ships both client and server capabilities. This module uses FastMCP to build a custom Python client from scratch against an existing server.
The example server is a small Call for Papers (CFP) management system — a stand-in for the kind of workflow used when submitting and reviewing conference talk proposals:
from fastmcp import FastMCP
from pydantic import BaseModel
from enum import Enum
from uuid import uuid4
import json
mcp = FastMCP("cfp-server")
class ProposalStatus(str, Enum):
submitted = "submitted"
accepted = "accepted"
rejected = "rejected"
class Proposal(BaseModel):
proposal_id: str
title: str
speaker: str
topic: str
abstract: str
status: ProposalStatus = ProposalStatus.submitted
_proposals: dict[str, Proposal] = {} # pre-seeded with a few fake talks
_ACCEPTED_TOPICS = ["AI Engineering", "Cloud Architecture", "Developer Productivity"]
@mcp.tool()
def submit_proposal(title: str, speaker: str, topic: str, abstract: str) -> Proposal:
proposal = Proposal(
proposal_id=str(uuid4()), title=title, speaker=speaker, topic=topic, abstract=abstract
)
_proposals[proposal.proposal_id] = proposal
return proposal
@mcp.tool()
def update_proposal_status(proposal_id: str, status: ProposalStatus) -> Proposal:
proposal = _proposals[proposal_id]
proposal.status = status
return proposal
@mcp.tool()
def get_proposal(proposal_id: str) -> Proposal:
return _proposals[proposal_id]
@mcp.resource("cfp://proposals")
def list_proposals() -> str:
return json.dumps([p.model_dump() for p in _proposals.values()])
@mcp.resource("cfp://topics")
def list_topics() -> str:
return json.dumps(_ACCEPTED_TOPICS)
@mcp.prompt()
def review_proposal(proposal_id: str) -> str:
return f"Review proposal {proposal_id} and recommend accept or reject with reasoning."
if __name__ == "__main__":
mcp.run()
A real-world CFP server would need to model multiple roles (speaker vs. organizer); this one is a simplified approximation focused on demonstrating client interaction patterns, exposing three tools, two resources, and one prompt.
Setup follows a standard Python workflow:
python -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -r requirements.txt # fastmcp, pydantic
The FastMCP CLI is useful for quick inspection without writing any client code at all:
python server.py # run the server directly (standard I/O by default)
fastmcp inspect server.py # summary of what the server exposes
fastmcp run server.py -- get_proposal --proposal_id 1
Demo: Pinging the Server
The FastMCP Client constructor can take a server script’s filename directly and knows how to spawn and manage it as a subprocess. Since the client is an async context manager, working with it means using async with inside an async def main() driven by asyncio.run:
import asyncio
from fastmcp import Client
async def main():
async with Client("server.py") as client:
result = await client.ping()
print(f"Connected: {result}")
if __name__ == "__main__":
asyncio.run(main())
Running python client.py confirms the connection succeeds — enough to validate the plumbing before building out real functionality.
Demo: Calling Tools from a Custom Client
A demo_tools helper first lists available tools, then exercises a full create → read → update flow:
async def demo_tools(client: Client):
tools = await client.list_tools()
for tool in tools:
print(f"{tool.name}: {tool.description}")
result = await client.call_tool(
"submit_proposal",
{
"title": "Advanced MCP Patterns",
"speaker": "Jane Doe",
"topic": "AI Engineering",
"abstract": "A deep dive into MCP sampling and elicitation.",
},
)
proposal = result.data
print(proposal)
result = await client.call_tool("get_proposal", {"proposal_id": proposal.proposal_id})
print(result.data)
result = await client.call_tool(
"update_proposal_status",
{"proposal_id": proposal.proposal_id, "status": "accepted"},
)
print(result.data)
Before calling a tool, it helps to know its exact input schema — the FastMCP CLI can print this directly:
fastmcp list server.py --input-schema
fastmcp list server.py --output-schema
The --input-schema output confirms submit_proposal takes title, speaker, topic, and abstract, all strings. The --output-schema output shows the tool returns a CFP-shaped (Proposal) object.
A FastMCP tool call result exposes three properties:
| Property | Meaning |
|---|---|
content | The array of MCP content blocks (text, image, audio, or video) |
structuredContent | The raw JSON object returned by the server |
data | A fully populated, typed Python object built from structuredContent (FastMCP’s Pydantic integration) |
Running the client executes submit_proposal, get_proposal, and update_proposal_status in sequence, printing the fully-typed Proposal object at each step. Writing a deterministic workflow like this — calling tools directly in a fixed sequence, no LLM involved — is exactly the kind of thing a custom client is good for.
Demo: Reading Resources from a Custom Client
Resources follow the same list-then-fetch pattern as tools, but with an important difference: resources can only return text or blob content, never a typed data object like tool calls can.
import json
async def demo_resources(client: Client):
resources = await client.list_resources()
for resource in resources:
print(resource.uri)
result = await client.read_resource("cfp://proposals")
proposals = json.loads(result[0].text)
for p in proposals:
print(p["title"], p["status"])
result = await client.read_resource("cfp://topics")
topics = json.loads(result[0].text)
print(topics)
Since the resource content comes back as a text block, the client has to parse it as JSON manually to get at the structured proposal list. Running this prints both resources: the proposals list — which reflects the seeded talks plus the one submitted programmatically earlier — and the accepted topic categories.
Demo: Reading Prompts from a Custom Client
The last basic capability is reading a prompt, following the same list/get pattern already established:
async def demo_prompts(client: Client):
prompts = await client.list_prompts()
for prompt in prompts:
print(prompt.name)
result = await client.get_prompt("review_proposal", {"proposal_id": "1"})
for message in result.messages:
assert message.content.type == "text"
print(f"[{message.role}] {message.content.text}")
Prompt results return a list of messages, each with a role (which could be user, assistant, or system, since a prompt message can be targeted to a specific audience). What a client does with that role is up to its own design — printing [user] ... in this example simply reflects the role that was returned. Running the client shows the review_proposal prompt message with its [user] role label, completing the basic client feature set: tools, resources, and prompts.
Demo: Client-Side Sampling with Tool Use
The final client feature is handling sampling requests initiated by the server — something neither the Inspector nor VS Code support for tool-based sampling, which is exactly why a custom client is needed here. FastMCP does support it directly.
Since sampling requires an actual model, you’ll need your own API key with a provider such as Anthropic or OpenAI. The main client script accepts a server URL, a model name, and a mode (probe or plan):
# client/.env
ANTHROPIC_API_KEY=sk-ant-...
from fastmcp import Client
from fastmcp.client.sampling import AnthropicSamplingHandler
class LoggingSamplingHandler(AnthropicSamplingHandler):
"""Adds debug logging around sampling calls."""
async def handle(self, messages, params):
print(f"Sampling request with {len(messages)} message(s)")
return await super().handle(messages, params)
async def main():
args = parse_args() # --server-url, --model, and mode: probe | plan
handler = LoggingSamplingHandler(model=args.model)
async with Client(
args.server_url,
sampling_handler=handler,
client_info={"capabilities": {"sampling": {"tools": True}}},
) as client:
if args.mode == "probe":
await client.ping()
tools = await client.list_tools()
print(tools)
else:
result = await client.call_tool(
"plan_trip", {"destination": args.destination, "days": args.days}
)
print(result.data)
if __name__ == "__main__":
asyncio.run(main())
FastMCP ships built-in sampling handlers, including an Anthropic handler (used here) and an OpenAI one — or you can write your own if you need a different model provider. The handler manages the message exchange between client and model and abstracts away the provider-specific API. Subclassing it here purely adds logging around each sampling call (logging the number of messages; individual message content could also be logged for deeper tracing), which is optional but useful for understanding what’s actually happening.
Declaring "tools": True under the client’s sampling capabilities is what unlocks tool-based sampling — the capability the Inspector and VS Code currently lack.
Running the demo requires two terminals:
# Terminal 1 — start the sampling-capable MCP server
cd server
npm start
# Terminal 2 — activate the Python client environment
source .venv/bin/activate
python sampling_client.py probe
Running in probe mode confirms connectivity and lists the server’s tools. Running in plan mode exercises the full sampling round trip:
python sampling_client.py plan --destination tokyo --days 3
The output shows the logged sampling call counts, followed by the actual response from Anthropic — an itinerary that correctly includes place details (address and opening hours), confirming the server successfully drove the multi-round-trip tool-sampling flow from Module 2. Server-side logs corroborate this: they show the client requesting three separate tool calls, one per itinerary stop, each returning place details.
sequenceDiagram
participant CLI as sampling_client.py
participant Handler as AnthropicSamplingHandler
participant Anthropic
participant Server as MCP Server (plan_trip)
CLI->>Server: tools/call plan_trip {destination, days}
Server->>CLI: sampling/createMessage #1 (with get_place_details tool)
CLI->>Handler: handle(messages, params)
Handler->>Anthropic: forward messages + tools
Anthropic-->>Handler: response with tool_use blocks
Handler-->>CLI: tool_use results
CLI-->>Server: tool_use blocks
Server->>Server: invoke get_place_details x3
Server->>CLI: sampling/createMessage #2 (with tool_result blocks)
CLI->>Handler: handle(messages, params)
Handler->>Anthropic: forward follow-up
Anthropic-->>Handler: final itinerary text
Handler-->>CLI: final text
CLI-->>Server: final text
Server-->>CLI: tool result: enriched itinerary
Module 4: Agentic Workflows with MCP
What Makes a Workflow Agentic
“Agentic” is a term that gets used loosely, but a useful working definition is: a workflow becomes agentic the moment an LLM is injected into its execution flow. That could be as small as a decision router evaluating human input against conditions, or as large as a full agent — with its own tools — acting as a single step in a bigger workflow.
An agent, in turn, is built from three core capabilities:
- Context or knowledge — the things the agent needs to know about, such as documents, files, or other data sources.
- Reasoning or planning — an ability to plan a set of tasks based on a request, typically driven by a model.
- Tool use / integration — the ability to take action by calling into other applications and APIs.
The key relationship to MCP: an MCP server exposes tools, and an agent calls those tools. If an agent defines its own tools, those tools can just as easily be exposed as an MCP server. In other words, context plus tool use alone gets you an MCP server — it’s the LLM-driven reasoning process layered on top that turns it into an agent.
mindmap
root((Agent))
Context / Knowledge
Documents
Files
Proprietary data
Reasoning / Planning
LLM-driven task planning
Tool Use / Action
MCP tools
External APIs
Low-code and no-code platforms can also call MCP servers directly as part of a workflow, but this course focuses on a fully code-based implementation using Cloudflare Agents and Cloudflare Workflows. The demo built out across this module is a travel agent: instead of hard-coded location data (as in the earlier sampling demos), it integrates with the real Tripadvisor API, wraps that API as an MCP server, and exposes it to a chat-based agent reachable over WebSockets. Users can ask for destination recommendations and check on their next flight — the latter kicking off a long-running, progress-reporting workflow.
Integrating a Traditional REST API
Agentic workflows frequently need to talk to a traditional REST API that has no native MCP support. Two integration approaches exist:
- Manual HTTP calls — using a provider’s client SDK or raw HTTP requests directly from the agent’s own codebase.
- Wrap the API with an MCP server — exposing it as a set of tools, making the integration reusable across multiple agents and tools.
The general recommendation is to design a purpose-built MCP server rather than mechanically wrap an API, because naive wrapping produces worse agent behavior. The Tripadvisor API illustrates why: it’s a traditional REST API with, among others, a Location Search and a Location Details endpoint. Tripadvisor doesn’t expose an MCP server itself, so integrating it means either writing manual HTTP calls or generating an MCP server.
A typical generator, given those two endpoints, creates one tool per endpoint — and this is where the trouble starts. Consider asking an agent to “find restaurants in Minneapolis and tell me the highest rated one.” The agent first calls the search-locations tool, getting back up to ten locations without ratings, since the search endpoint doesn’t return them. It then has to call the location-details tool once per result — ten separate tool calls — because that endpoint only accepts a single location ID at a time, and the generated tool mirrors that one-to-one.
There’s a second, broader problem: tool fatigue. Agents get overwhelmed by large numbers of similarly-named tools and struggle to reason about which one to use. Tripadvisor’s API surface is small, but real-world APIs with dozens of endpoints make this much worse if every endpoint becomes its own tool.
Cloudflare’s Code Mode addresses both problems. Regardless of how many endpoints an underlying API has, Code Mode exposes just two tools to the agent:
search— lets the agent discover what’s available and how to call it, using JavaScript.execute— takes JavaScript code from the agent and runs it in a safe, sandboxed environment.
| Approach | Tools exposed to agent | Round trips for “find + enrich 10 items” |
|---|---|---|
| Naive one-tool-per-endpoint generator | N (one per endpoint) | ~11 (1 search + 10 detail calls) |
| Code Mode (search + execute) | 2 | ~2 (1 execute for search, 1 execute for parallel detail fetch) |
Code Mode effectively lets the agent do what a human developer would do when facing an API: write code to call it however the problem requires. Rather than ten sequential detail calls, the agent can generate something like this and have it executed server-side:
const searchResults = await codemode.request({
path: "/location/search",
query: { searchQuery: "Minneapolis restaurants" },
});
const locations = searchResults.data;
const details = await Promise.all(
locations.map((loc) => codemode.request({ path: `/location/${loc.location_id}/details` }))
);
The executor sandbox is isolated from the actual API call — it never talks to Tripadvisor directly. The MCP server owns that call and exposes it only as an RPC binding the sandboxed code can invoke. The net result: finding and enriching ten restaurants collapses to about two tool calls (one execute for the search, one for the parallel detail fetch) instead of eleven.
To make this work for Tripadvisor specifically, Code Mode can take an OpenAPI schema and turn it directly into an MCP server — which requires first obtaining that schema.
flowchart TB
subgraph "Without Code Mode"
A1[Agent] -->|calls| T1[search_location tool]
A1 -->|calls x10| T2[location_details tool]
end
subgraph "With Code Mode"
A2[Agent] -->|search| S[search tool: discover API shape]
A2 -->|execute JS: search + Promise.all details| E[execute tool: sandboxed runner]
E -->|RPC binding, server-side only| API[Tripadvisor API]
end
Demo: Turning an OpenAPI Schema into an MCP Server
Tripadvisor’s OpenAPI schema is available, but only shown one endpoint at a time via a “View as Markdown” page. Combining both endpoints (search and details) into a single JSON file next to the worker code gives Code Mode what it needs.
The worker itself is small — roughly 50 lines:
import { createMcpHandler } from "agents/mcp";
import { createOpenApiMcpServer } from "@cloudflare/codemode";
import openApiSchema from "./tripadvisor-openapi.json";
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const apiKey = env.TRIPADVISOR_API_KEY;
const executor = env.CODE_EXECUTOR; // dynamic worker executor binding
const mcpServer = createOpenApiMcpServer({
schema: openApiSchema,
executor,
name: "tripadvisor",
instructions:
"Use codemode.request({ path, query }) to call the Tripadvisor API. " +
"For details on multiple locations, call it in parallel with Promise.all.",
async handleRequest({ path, query }) {
const url = new URL(`https://api.content.tripadvisor.com/api/v1${path}`);
for (const [key, value] of Object.entries(query ?? {})) {
url.searchParams.set(key, String(value));
}
url.searchParams.set("key", apiKey);
const response = await fetch(url);
return response.json();
},
});
return createMcpHandler(mcpServer)(request, env, ctx);
},
};
Wiring:
- The Tripadvisor API key is loaded from the environment.
- A dynamic worker executor — configured via a
wrangler.tomlbinding — gives the agent’s generated code a coding sandbox (Cloudflare calls this an isolate, essentially a very thin VM/container) with no access to the file system, secrets, or (optionally) even the network. - The Code Mode SDK’s
createOpenApiMcpServerutility takes the schema, the executor, a name, and instructions, and produces the MCP server — the agent’s own instructions literally reference callingcodemode.request(...), matching thehandleRequestimplementation. - The
handleRequesthandler is what actually executes on the server: it prepends the API base URL to the requested path, forwards query parameters, and appends the Tripadvisor API key — all invisible to the agent, which never sees the secret key. createMcpHandlerat the end is the same stateless MCP handler used earlier in the course, showing that Code Mode integrates seamlessly with existing MCP infrastructure.
[[unsafe.bindings]]
name = "CODE_EXECUTOR"
type = "dynamic_worker_executor"
Running this in the Inspector shows exactly two tools — search and execute — never the raw location search/details endpoints. These tools aren’t directly usable by a human tester; they require writing code to invoke, which is the whole point. Enabling the server in VS Code’s Copilot agent mode and asking it to “find some of the top rated restaurants in Minneapolis” shows Copilot discovering it can use the Tripadvisor MCP server, using search to learn the API shape, and then generating and executing JavaScript via execute to get real restaurant data back from Tripadvisor.
Demo: Building the Trip Planner Agent
An MCP server is a set of tools; giving those tools to a model with instructions is what turns it into an agent. With the Tripadvisor MCP server running locally, the next step scaffolds both the agent itself and a chat interface to interact with it over WebSockets.
This uses Cloudflare’s AI ChatAgent API, built on top of the same Agent SDK already used earlier in the course, adding the ability to send/receive chat messages between client and model. (The client-side chat UI, written in React, isn’t covered in detail here — the focus stays on the agent’s server-side code.)
flowchart LR
UI["Chat UI (React, WebSocket client)"] <-->|WebSocket| Agent["TravelAgent (AIChatAgent, Durable Object)"]
Agent -->|SQLite-backed| Store[(Persisted chat messages)]
Agent -->|MCP tools| MCP["Tripadvisor MCP Server (Code Mode)"]
Agent -->|Workers AI model| Model[LLM]
The client connects over WebSockets; messages are persisted through the Durable Object’s SQLite storage. When the agent receives a message, it streams the response back to the client, and it can also broadcast messages to connected clients independent of a direct request/response (used later for workflow progress updates).
import { AIChatAgent } from "agents/ai-chat-agent";
import { createWorkersAI } from "workers-ai-provider";
import { streamText } from "ai";
export class TravelAgent extends AIChatAgent<Env> {
async onStart() {
// The MCP server is a separate Cloudflare worker; it must already
// be running before the agent starts.
await this.mcp.connect("http://localhost:8788/mcp");
}
async onChatMessage(onFinish: (message: unknown) => void) {
await this.waitForMcpConnections();
const workersai = createWorkersAI({ binding: this.env.AI });
// Turns every tool from every connected MCP server into the
// function-calling format the model expects.
const tools = this.mcp.getAITools();
const result = streamText({
model: workersai("@cf/qwen/qwen3-32b"),
system: "You are a travel assistant. Use the available tools to answer questions.",
messages: this.messages,
tools,
onFinish,
});
return result.toDataStreamResponse();
}
}
The onStart method hard-codes the Tripadvisor MCP server’s URL — in this demo everything runs locally, though in production this would point at a real, remotely hosted MCP server. Inside onChatMessage, this.mcp.getAITools() is the key integration point: it collects every tool from every connected MCP server and converts them into the function-calling format the chosen model expects. Code Mode’s effectiveness depends heavily on both the quality of the instructions passed to createOpenApiMcpServer and the capability of the model used — it needs solid reasoning and tool-calling support. The demo uses a Qwen3 model via Workers AI, though any Workers AI model, or an external provider like Anthropic or OpenAI, would work equally well through the same Vercel AI SDK-compatible interface.
Running npm start boots both the client UI and the agent server. Note that even though everything runs locally, Workers AI itself is a hosted model — it incurs (small) usage charges beyond a free tier, worth being mindful of if you replicate this yourself.
Asking the chat interface “What is the top rated restaurant in Minneapolis?” triggers the same underlying flow demonstrated earlier through VS Code: the agent recognizes the connected Tripadvisor MCP server (with its two Code Mode tools), performs a Code Mode search request, notices the results lack ratings, and issues a second Code Mode request using Promise.all to fetch details in parallel — exactly as instructed in the OpenAPI MCP server’s setup. The agent then synthesizes and returns a top-rated restaurant recommendation, which matches Tripadvisor’s actual listing when checked manually.
sequenceDiagram
participant User
participant UI as Chat UI
participant Agent as TravelAgent
participant MCP as Tripadvisor MCP Server
participant TA as Tripadvisor API
User->>UI: "Top rated restaurant in Minneapolis?"
UI->>Agent: WebSocket chat message
Agent->>Agent: streamText() with mcp.getAITools()
Agent->>MCP: execute: codemode.request(search)
MCP->>TA: GET /location/search
TA-->>MCP: locations (no ratings)
MCP-->>Agent: search results
Agent->>MCP: execute: codemode.request(details) x N in parallel
MCP->>TA: GET /location/{id}/details (parallel)
TA-->>MCP: details incl. ratings
MCP-->>Agent: enriched results
Agent-->>UI: streamed answer
UI-->>User: Top-rated restaurant recommendation
Crucially, the agent itself never executes the generated code and never sees the Tripadvisor API key — the code runs, and the real API call happens, entirely inside the MCP server via the RPC binding set up in the previous demo. This is a working example of a genuinely agentic workflow: it performs multiple reasoning-driven steps in sequence to arrive at an answer, though as with any LLM-driven process, it isn’t guaranteed to behave identically every time.
Demo: Adding a Deterministic Workflow
For a longer, multi-step business process that needs retries and deterministic recovery from failure, Cloudflare Workflows integrates directly with the Agents SDK via an AgentWorkflow class, creating two-way communication between the workflow and the agent that triggered it — letting the agent both own and observe the workflow’s execution (via progress and completion events).
This demo simulates finding the user’s next flight as a four-step workflow:
export class FlightSearchWorkflow extends AgentWorkflow<Env> {
async run(event: WorkflowEvent, step: WorkflowStep) {
await step.do("get-calendar-events", async () => {
this.reportProgress({ step: 1, total: 4, message: "Checking calendar" });
await sleep(3000); // simulated work
return getCalendarEvents();
});
const nextFlight = await step.do("find-next-flight", async () => {
this.reportProgress({ step: 2, total: 4, message: "Finding next flight" });
await sleep(3000);
return findNextFlight();
});
const status = await step.do("check-flight-status", async () => {
this.reportProgress({ step: 3, total: 4, message: "Checking flight status" });
await sleep(3000);
return getFlightStatus(nextFlight);
});
return step.do("synthesize-result", async () => {
this.reportProgress({ step: 4, total: 4, message: "Finalizing" });
await sleep(3000);
return { destination: "Boston", ...status };
});
}
}
Conceptually, the four steps are: fetch the user’s calendar events (e.g. via a calendar API or tool), determine the next flight (deterministically, or handed off to a model to synthesize), look up real-time flight status (e.g. via a flights API), and finally synthesize the result. Each step here is simulated with a 3-second delay and reports progress back to the owning agent as it goes — in a real system, a workflow like this might legitimately take much longer (even hours) and still benefit from the same durability guarantees.
On the agent side, three integration points matter:
export class TravelAgent extends AIChatAgent<Env> {
// ...previous onStart / onChatMessage from the prior demo...
async startFlightSearch() {
const instance = await this.env.FLIGHT_SEARCH_WORKFLOW.create();
this.setState({ ...this.state, flightSearchInstanceId: instance.id });
return instance.id;
}
async getFlightSearchStatus() {
const instance = await this.env.FLIGHT_SEARCH_WORKFLOW.get(this.state.flightSearchInstanceId);
return instance.status();
}
onWorkflowProgress(progress: { step: number; total: number; message: string }) {
this.broadcast(JSON.stringify({ type: "workflow-progress", ...progress }));
}
async onWorkflowComplete(result: unknown) {
this.messages.push({
role: "system",
content: `Flight search complete: ${JSON.stringify(
result
)}. Summarize this for the user and suggest next actions.`,
});
await this.continueChat();
}
}
startFlightSearch and getFlightSearchStatus are ordinary methods on the agent class — but since a Cloudflare agent is itself a Durable Object, defining them as methods automatically makes them available as RPC bindings callable from the worker’s fetch handler, not just from within the agent’s own tool-calling flow. instanceId uniquely identifies the running workflow so its status can be checked later. These two methods aren’t tools the model can call directly; instead, two wrapper tools (one calling startFlightSearch, one calling getFlightSearchStatus) are added alongside the Tripadvisor MCP tools already spread into the model’s tool list.
onWorkflowProgress broadcasts progress events over the WebSocket connection so the chat UI can render real-time updates. onWorkflowComplete demonstrates an interesting pattern: it pushes a system-role message into the conversation summarizing the workflow’s result and prompting the agent to synthesize a natural-language response and suggest follow-up actions, then continues the chat — effectively letting the workflow “speak” back through the same conversational interface.
The worker’s fetch handler also allows triggering the workflow manually, outside of any chat interaction — for example from a UI button or an external system:
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
const agentId = env.TravelAgent.idFromName("default");
const stub = env.TravelAgent.get(agentId);
if (request.method === "POST" && url.pathname === "/flight-search") {
const instanceId = await stub.startFlightSearch();
return Response.json({ instanceId });
}
if (request.method === "GET" && url.pathname === "/flight-search/status") {
const status = await stub.getFlightSearchStatus();
return Response.json(status);
}
return routeToAgent(request, env);
},
};
Asking the agent in chat “When is my next flight?” causes it to recognize a getUserNextFlight-style tool and call it, kicking off the workflow. Because the agent listens to workflow events, it broadcasts live progress over WebSockets, letting the UI display real-time status as each step completes. Once finished, the onWorkflowComplete handler’s system message causes the agent to summarize the result and suggest next actions directly in the conversation.
Triggering the same workflow manually — for example by clicking a “Flight Search” button wired to the POST endpoint — starts an equivalent run and shows the same progress UI. Checking status either through the GET endpoint or by asking the agent directly returns consistent results, since there’s only ever one running workflow instance tracked per agent.
sequenceDiagram
participant User
participant UI as Chat UI
participant Agent as TravelAgent
participant WF as FlightSearchWorkflow
alt Triggered via chat
User->>UI: "When is my next flight?"
UI->>Agent: chat message
Agent->>Agent: model calls getUserNextFlight tool
Agent->>WF: startFlightSearch()
else Triggered manually
User->>UI: Click "Flight Search"
UI->>Agent: POST /flight-search
Agent->>WF: startFlightSearch()
end
loop each workflow step
WF-->>Agent: onWorkflowProgress
Agent-->>UI: broadcast progress (WebSocket)
end
WF-->>Agent: onWorkflowComplete(result)
Agent->>Agent: push system message, continueChat()
Agent-->>UI: streamed summary response
This ties every advanced feature covered in the course into a single working example: a stateful Cloudflare-hosted MCP server (Module 2), an API wrapped through Code Mode instead of a naive one-tool-per-endpoint generator (Module 4), a chat agent consuming that server’s tools, and a deterministic, progress-reporting workflow layered on top for the parts of the process that need reliability guarantees an LLM alone can’t provide.
Summary
This course extended foundational MCP knowledge into the advanced corners of the specification and its SDKs:
- Server utilities — filters (SDK-specific middleware for cross-cutting concerns), completions (spec-defined autocomplete for prompt/resource arguments), and notifications (one-way messages covering list changes, resource subscriptions, and progress reporting).
- Statefulness as a prerequisite — standard I/O servers are inherently stateful; HTTP servers require explicit session tracking (in memory, or via something like Cloudflare Durable Objects) to support the client features that follow.
- Advanced client features — roots (client-declared file system boundaries for local servers), elicitations (human-in-the-loop form or URL-based interruptions), and sampling (delegating generative reasoning to the client’s own model, optionally with tool use and multi-round-trip tool result exchange).
- Custom MCP clients — every SDK supports building clients, not just servers; a FastMCP client can call tools, read resources and prompts, and implement its own sampling handler to unlock capabilities not yet supported by mainstream clients like the Inspector or VS Code.
- Agentic workflows — an MCP server becomes part of an agent once reasoning is layered on top of its context and tool-use capabilities; wrapping traditional REST APIs works far better through an approach like Cloudflare’s Code Mode (two generic tools plus sandboxed code execution) than through naive one-tool-per-endpoint generation; and deterministic Cloudflare Workflows complement agent reasoning for business processes that need retries and reliability guarantees.
Quick Reference
| Feature | Declared by | Requires stateful server? | Typical use case |
|---|---|---|---|
| Filters | N/A (SDK-specific) | No | Logging, auth, caching, conditional tool visibility |
| Completions | Server capability | No | Autocomplete for prompt/resource arguments |
| List-changed notifications | Server or client | No (but benefits from it) | Signal tools/resources/prompts changed |
| Resource subscriptions | Server capability (resources.subscribe) | No, but needs subscriber tracking | Notify clients when a specific resource updates |
| Progress notifications | Client (progressToken) | No | UX feedback for long-running tool calls |
| Roots | Client capability | Helpful, not required | Bound a local server to specific file system directories |
| Elicitation | Client capability (form / url) | Yes | Human-in-the-loop confirmation or out-of-band sensitive actions |
| Sampling | Client capability (optionally with tool use) | Yes | Delegate reasoning/generation to the client’s model |
Checklist for Building Advanced MCP Servers
- Decide whether cross-cutting concerns (logging, auth, caching) warrant a filter/middleware layer, and pick the right mechanism for your SDK.
- Add completions to any prompt/resource argument that has a bounded, lookup-driven, or otherwise suggestible set of values.
- Use list-changed notifications whenever server-side state changes affect which tools, resources, or prompts a client should see.
- Declare and implement the
resources.subscribecapability if clients need to react to resource updates in real time. - Add progress reporting to any tool that may take more than a second or two, or that executes multiple discrete steps.
- Confirm whether your transport is inherently stateful (standard I/O) or needs explicit session tracking (HTTP), and choose a hosting model that supports it if you need elicitation or sampling.
- Request roots when your server needs local file system context, and react to
roots/list_changedrather than requiring a restart. - Use form-based elicitation for confirmations and simple input; use URL-based elicitation for anything sensitive or requiring out-of-band user action.
- Reserve sampling for cases where the server itself needs to reason over the result, not merely forward data back to the client.
- When integrating a traditional REST API, prefer a purpose-built MCP server (or a Code Mode-style search/execute wrapper) over a naive one-tool-per-endpoint generator, especially for APIs with many endpoints.
- Layer deterministic workflows (e.g. Cloudflare Workflows) underneath agent reasoning for any business process that needs retries and reliable step-by-step execution.
Search Terms
model · context · protocol · features · ai · agents · orchestration · artificial · intelligence · generative · mcp · server · client · custom · sampling · tools · filters · agentic · changes · completions · elicitation · list · middleware · notifications