<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Going Further

Going Further

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

Nothing so far tells a client that your tool list grew or that a resource it read has changed. A client asks to be told by sending subscriptions/listen with a filter naming what it wants: toolsListChanged, promptsListChanged, resourcesListChanged, or resourceSubscriptions for specific URIs. That request stays open as a stream instead of answering once.

Client Server subscriptions/listen notifications/subscriptions/acknowledged stream stays open notifications/* (tagged with subscriptionId)
A subscription stays open. The server acknowledges it first, then pushes notifications as things change.

Your server must acknowledge the subscription before sending anything on it, and must never send a type the client didn't request. The SDK handles both, and it handles most of the sending too: registerTool and its siblings return a handle, and updating, disabling, or removing through that handle emits the matching list-changed notification by itself. You only send explicitly when something changes that the registration API can't see.

await server.sendResourceUpdated({ uri: "demo://tasks" });
await server.sendToolListChanged();

On stdio those go straight onto the open subscription stream. Over HTTP the server instance is per-request, so there you publish through the handler instead, as handler.notify.resourceUpdated(uri). Either way only the streams that opted in receive it, and per-resource updates additionally need the server to advertise resources: { subscribe: true }.

Note: client support for subscriptions is uneven today. Both VS Code and Claude Code largely rediscover a changed tool or resource list on reconnect rather than through a live subscription, which is why every lesson in this course tells you to restart the server after an edit. Emit the notifications anyway, they're correct and cheap, just don't assume a given client acts on them.

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 scopes requiredScopes lists. 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: 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