Nothing to build here. Each section introduces one capability you'll want eventually, names it, and points at its documentation.
Cancellation
ctx.mcpReq.signal is an AbortSignal, aborted when the client cancels the
request and when the connection closes. Check it between units of work, and pass it to your own I/O
so a cancelled call stops costing you something.
for (const page of pages) {
if (ctx.mcpReq.signal.aborted) break;
await scan(page, { signal: ctx.mcpReq.signal });
} Subscriptions
Clients cache what your server told them. A change notification says that cache is stale: the tool
list is different now, or a resource they read has new content. Clients that want to hear about it
open a long-lived stream with subscriptions/listen, so a notification only ever reaches
a client that asked for one.
Most servers never send one. The handle registerTool returns notifies
on its own:
const report = server.registerTool("run-report", config, handler);
report.update({ description: "Run the weekly report and email it" }); // notifies
report.disable(); // notifies
report.remove(); // notifies registerResource and registerPrompt hand back handles that behave the same
way, and McpServer advertises the listChanged capability as you register,
so there is nothing to declare either.
You send one yourself only when something changes where the registration API cannot see it. Then the one decision is which object to send from, because that differs by transport:
// stdio: the instance lives as long as the connection.
await server.sendToolListChanged();
await server.sendResourceUpdated({ uri: "demo://tasks" });
// HTTP: createMcpHandler builds an instance per request, so the handler owns the streams.
handler.notify.toolsChanged();
handler.notify.resourceUpdated("demo://tasks"); Authorization
This applies to HTTP transports only. A stdio server reads credentials from the environment instead.
The split that matters: your MCP server never logs anyone in. A separate authorization server , an identity provider you either run or point at, does that and hands out access tokens. Your server's only job is checking that a token presented to it is genuine before doing anything else. That's why the SDK calls your side a resource server : it verifies tokens, and never issues them.
A client with no token yet discovers where your authorization server is, authenticates the user there, and comes back with an access token. From then on every request to your server carries that token in an ordinary bearer token header:
Authorization: Bearer <token> requireBearerAuth is the middleware that checks it, mounted in front of your
/mcp route:
const auth = requireBearerAuth({
verifier,
requiredScopes: ["mcp"],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
});
app.all("/mcp", auth, (req, res) => void node(req, res, req.body)); verifier is the one function you write: take the raw token string, return who it belongs
to and what it's allowed to do. resourceMetadataUrl is how an unauthenticated client
finds your authorization server in the first place, it's included in the 401 challenge below and
points at a discovery document requireBearerAuth publishes for you.
Two outcomes, both settled before your tool code ever runs:
-
401 invalid_token: no token, or one that's missing, expired, or malformed. The caller doesn't have valid credentials yet. -
403 insufficient_scope: a real, valid token, just missing one of the scopesrequiredScopeslists. The caller is who they say they are, they're just not allowed to do this yet.
requiredScopes covers the whole endpoint. A scope only one tool needs, not every caller,
is checked inside that tool's own handler instead, where ctx.http.authInfo carries the
verified caller and its scopes:
if (!ctx.http?.authInfo?.scopes.includes("notes:write")) {
return { content: [{ type: "text", text: "insufficient_scope: requires notes:write" }], isError: true };
}
That comes back as an ordinary tool result with isError: true, not an HTTP-level
rejection, so the model reads the refusal the way it reads any other tool output and can react to it
instead of the call just failing.
This is also what replaces the mock middleware from lesson 9 (Serving over HTTP): swap it for a real
requireBearerAuth and the per-caller state keyed on
authInfo.clientId starts keying on a verified identity instead of a hardcoded string,
with no change to the factory or the Map.
Where to go next
- MCP Specification 2026-07-28, the revision this course targets
- TypeScript SDK documentation, including the serving, notifications, and authorization guides
- MCP Inspector
- MCP Tasks extension, for work that outlives a single request