docs: correctness and coverage pass for the 2026-07-28 revision (#2389)
This commit is contained in:
parent
36055d5bbd
commit
199a28e9e2
8 changed files with 1335 additions and 390 deletions
5
examples/client-quickstart/README.md
Normal file
5
examples/client-quickstart/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# client-quickstart
|
||||
|
||||
Source for the [Client Quickstart](../../docs/client-quickstart.md) tutorial: an LLM-powered chatbot that connects to an MCP server over stdio and calls its tools. The tutorial walks through `src/index.ts` end to end.
|
||||
|
||||
The `package.json` and `tsconfig.json` here are monorepo-internal (`workspace:`/`catalog:` protocols; typecheck-only in CI). To build the client yourself, use the standalone manifests from the tutorial.
|
||||
|
|
@ -1,11 +1,8 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/examples-client-quickstart",
|
||||
"name": "@mcp-examples/client-quickstart",
|
||||
"private": true,
|
||||
"version": "2.0.0-alpha.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"mcp-client-cli": "./build/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
|
|
|||
|
|
@ -4,12 +4,11 @@ import { Client } from '@modelcontextprotocol/client';
|
|||
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
||||
import readline from 'readline/promises';
|
||||
|
||||
const ANTHROPIC_MODEL = 'claude-sonnet-4-5';
|
||||
const ANTHROPIC_MODEL = 'claude-sonnet-4-6';
|
||||
|
||||
class MCPClient {
|
||||
private mcp: Client;
|
||||
private _anthropic: Anthropic | null = null;
|
||||
private transport: StdioClientTransport | null = null;
|
||||
private tools: Anthropic.Tool[] = [];
|
||||
|
||||
constructor() {
|
||||
|
|
@ -37,8 +36,8 @@ class MCPClient {
|
|||
: process.execPath;
|
||||
|
||||
// Initialize transport and connect to server
|
||||
this.transport = new StdioClientTransport({ command, args: [serverScriptPath] });
|
||||
await this.mcp.connect(this.transport);
|
||||
const transport = new StdioClientTransport({ command, args: [serverScriptPath] });
|
||||
await this.mcp.connect(transport);
|
||||
|
||||
// List available tools
|
||||
const toolsResult = await this.mcp.listTools();
|
||||
|
|
@ -65,29 +64,40 @@ class MCPClient {
|
|||
];
|
||||
|
||||
// Initial Claude API call
|
||||
const response = await this.anthropic.messages.create({
|
||||
let response = await this.anthropic.messages.create({
|
||||
model: ANTHROPIC_MODEL,
|
||||
max_tokens: 1000,
|
||||
messages,
|
||||
tools: this.tools,
|
||||
});
|
||||
|
||||
// Process response and handle tool calls
|
||||
// Process responses, executing tool calls until Claude stops requesting them
|
||||
const finalText = [];
|
||||
|
||||
for (const content of response.content) {
|
||||
if (content.type === 'text') {
|
||||
finalText.push(content.text);
|
||||
} else if (content.type === 'tool_use') {
|
||||
// Execute tool call
|
||||
const toolName = content.name;
|
||||
const toolArgs = content.input as Record<string, unknown> | undefined;
|
||||
while (true) {
|
||||
const toolUses: Anthropic.ToolUseBlock[] = [];
|
||||
for (const content of response.content) {
|
||||
if (content.type === 'text') {
|
||||
finalText.push(content.text);
|
||||
} else if (content.type === 'tool_use') {
|
||||
toolUses.push(content);
|
||||
}
|
||||
}
|
||||
|
||||
if (toolUses.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Execute every requested tool call and collect the results
|
||||
const toolResults: Anthropic.ToolResultBlockParam[] = [];
|
||||
for (const toolUse of toolUses) {
|
||||
const toolArgs = toolUse.input as Record<string, unknown>;
|
||||
const result = await this.mcp.callTool({
|
||||
name: toolName,
|
||||
name: toolUse.name,
|
||||
arguments: toolArgs,
|
||||
});
|
||||
|
||||
finalText.push(`[Calling tool ${toolName} with args ${JSON.stringify(toolArgs)}]`);
|
||||
finalText.push(`[Calling tool ${toolUse.name} with args ${JSON.stringify(toolArgs)}]`);
|
||||
|
||||
// Extract text from tool result content blocks
|
||||
const toolResultText = result.content
|
||||
|
|
@ -95,30 +105,33 @@ class MCPClient {
|
|||
.map((block) => block.text)
|
||||
.join('\n');
|
||||
|
||||
// Continue conversation with tool results
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: response.content,
|
||||
toolResults.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUse.id,
|
||||
content: toolResultText,
|
||||
// Tell Claude when the tool call failed
|
||||
...(result.isError ? { is_error: true } : {}),
|
||||
});
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'tool_result',
|
||||
tool_use_id: content.id,
|
||||
content: toolResultText,
|
||||
}],
|
||||
});
|
||||
|
||||
// Get next response from Claude
|
||||
const followUp = await this.anthropic.messages.create({
|
||||
model: ANTHROPIC_MODEL,
|
||||
max_tokens: 1000,
|
||||
messages,
|
||||
});
|
||||
|
||||
const firstBlock = followUp.content[0];
|
||||
finalText.push(firstBlock?.type === 'text' ? firstBlock.text : '');
|
||||
}
|
||||
|
||||
// Continue the conversation: the assistant turn, then ALL tool
|
||||
// results together in a single user turn
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: response.content,
|
||||
});
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: toolResults,
|
||||
});
|
||||
|
||||
// Get next response from Claude
|
||||
response = await this.anthropic.messages.create({
|
||||
model: ANTHROPIC_MODEL,
|
||||
max_tokens: 1000,
|
||||
messages,
|
||||
tools: this.tools,
|
||||
});
|
||||
}
|
||||
|
||||
return finalText.join('\n');
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
//#region imports
|
||||
import type {
|
||||
AuthProvider,
|
||||
CallToolResult,
|
||||
InputRequiredResult,
|
||||
OAuthClientInformationContext,
|
||||
OAuthClientInformationMixed,
|
||||
OAuthClientMetadata,
|
||||
|
|
@ -19,16 +21,21 @@ import type {
|
|||
} from '@modelcontextprotocol/client';
|
||||
import {
|
||||
applyMiddlewares,
|
||||
checkResourceAllowed,
|
||||
Client,
|
||||
ClientCredentialsProvider,
|
||||
createMiddleware,
|
||||
CrossAppAccessProvider,
|
||||
discoverAndRequestJwtAuthGrant,
|
||||
isInputRequiredResult,
|
||||
IssuerMismatchError,
|
||||
LOG_LEVEL_META_KEY,
|
||||
PrivateKeyJwtProvider,
|
||||
ProtocolError,
|
||||
resourceUrlFromServerUrl,
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
SdkHttpError,
|
||||
SSEClientTransport,
|
||||
StreamableHTTPClientTransport,
|
||||
TRACEPARENT_META_KEY,
|
||||
|
|
@ -296,6 +303,20 @@ function auth_oauthClientProvider(onRedirect: (url: URL) => void) {
|
|||
authProvider: provider
|
||||
});
|
||||
//#endregion auth_oauthClientProvider
|
||||
|
||||
//#region auth_validateResourceURL
|
||||
class PinnedResourceProvider extends MyOAuthProvider {
|
||||
async validateResourceURL(serverUrl: string | URL, resource?: string): Promise<URL | undefined> {
|
||||
const expected = resourceUrlFromServerUrl(serverUrl); // strips the fragment (RFC 8707 §2)
|
||||
if (resource && !checkResourceAllowed({ requestedResource: expected, configuredResource: resource })) {
|
||||
throw new Error(`Refusing resource ${resource} for server ${expected.href}`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
}
|
||||
//#endregion auth_validateResourceURL
|
||||
void PinnedResourceProvider;
|
||||
|
||||
return { provider, transport };
|
||||
}
|
||||
|
||||
|
|
@ -465,6 +486,23 @@ async function complete_basic(client: Client) {
|
|||
//#endregion complete_basic
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response caching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: Per-call cache disposition via cacheMode. */
|
||||
async function responseCache_basic(client: Client) {
|
||||
//#region responseCache_basic
|
||||
const tools = await client.listTools(); // network, then cached for the server's ttlMs
|
||||
const again = await client.listTools(); // served from cache while still fresh
|
||||
|
||||
await client.listTools(undefined, { cacheMode: 'refresh' }); // always refetch and re-store
|
||||
await client.readResource({ uri: 'config://app' }, { cacheMode: 'bypass' }); // no cache read or write
|
||||
//#endregion responseCache_basic
|
||||
void tools;
|
||||
void again;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -493,6 +531,18 @@ async function setLoggingLevel_basic(client: Client) {
|
|||
//#endregion setLoggingLevel_basic
|
||||
}
|
||||
|
||||
/** Example: Per-request log-level opt-in on a 2026-07-28 connection. */
|
||||
async function logLevelMeta_modern(client: Client) {
|
||||
//#region logLevelMeta_modern
|
||||
const result = await client.callTool({
|
||||
name: 'fetch-data',
|
||||
arguments: { url: 'https://example.com' },
|
||||
_meta: { [LOG_LEVEL_META_KEY]: 'debug' }
|
||||
});
|
||||
//#endregion logLevelMeta_modern
|
||||
void result;
|
||||
}
|
||||
|
||||
/** Example: Automatic list-change tracking via the listChanged option. */
|
||||
async function listChanged_basic() {
|
||||
//#region listChanged_basic
|
||||
|
|
@ -519,11 +569,45 @@ async function listChanged_basic() {
|
|||
return client;
|
||||
}
|
||||
|
||||
/** Example: Open a subscriptions/listen stream explicitly (2026-07-28). */
|
||||
async function listen_basic(client: Client) {
|
||||
//#region listen_basic
|
||||
client.setNotificationHandler('notifications/tools/list_changed', async () => {
|
||||
const { tools } = await client.listTools();
|
||||
console.log('Tools changed:', tools.length);
|
||||
});
|
||||
client.setNotificationHandler('notifications/resources/updated', async notification => {
|
||||
console.log('Resource updated:', notification.params.uri);
|
||||
});
|
||||
|
||||
const subscription = await client.listen({
|
||||
toolsListChanged: true,
|
||||
resourceSubscriptions: ['config://app']
|
||||
});
|
||||
console.log('Server honored:', subscription.honoredFilter);
|
||||
|
||||
// Later: tear the stream down
|
||||
await subscription.close();
|
||||
//#endregion listen_basic
|
||||
}
|
||||
|
||||
/** Example: Watch loop that re-listens on unexpected closes. */
|
||||
async function listen_watchLoop(client: Client, watching: boolean) {
|
||||
//#region listen_watchLoop
|
||||
while (watching) {
|
||||
const sub = await client.listen({ resourceSubscriptions: ['config://app'] });
|
||||
const reason = await sub.closed;
|
||||
if (reason !== 'remote') break; // 'local' or 'graceful': done
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // back off, then re-listen
|
||||
}
|
||||
//#endregion listen_watchLoop
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handling server-initiated requests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: Declare client capabilities for sampling and elicitation. */
|
||||
/** Example: Declare client capabilities for sampling, elicitation, and roots. */
|
||||
function capabilities_declaration() {
|
||||
//#region capabilities_declaration
|
||||
const client = new Client(
|
||||
|
|
@ -531,7 +615,8 @@ function capabilities_declaration() {
|
|||
{
|
||||
capabilities: {
|
||||
sampling: {},
|
||||
elicitation: { form: {} }
|
||||
elicitation: { form: {} },
|
||||
roots: { listChanged: true }
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
@ -590,6 +675,42 @@ function roots_handler(client: Client) {
|
|||
//#endregion roots_handler
|
||||
}
|
||||
|
||||
/** Example: Manual multi-round-trip handling with autoFulfill disabled (2026-07-28). */
|
||||
async function inputRequired_manual(transport: StreamableHTTPClientTransport) {
|
||||
//#region inputRequired_manual
|
||||
const client = new Client(
|
||||
{ name: 'my-client', version: '1.0.0' },
|
||||
{
|
||||
capabilities: { elicitation: { form: {} } },
|
||||
versionNegotiation: { mode: 'auto' },
|
||||
inputRequired: { autoFulfill: false }
|
||||
}
|
||||
);
|
||||
await client.connect(transport);
|
||||
|
||||
const value = (await client.request(
|
||||
{ method: 'tools/call', params: { name: 'deploy', arguments: { env: 'prod' } } },
|
||||
{ allowInputRequired: true }
|
||||
)) as CallToolResult | InputRequiredResult;
|
||||
|
||||
if (isInputRequiredResult(value)) {
|
||||
// Collect responses for value.inputRequests from your UI, then retry:
|
||||
await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'deploy',
|
||||
arguments: { env: 'prod' },
|
||||
inputResponses: { confirm: { action: 'accept', content: { confirm: true } } },
|
||||
requestState: value.requestState // echo byte-exact
|
||||
}
|
||||
},
|
||||
{ allowInputRequired: true }
|
||||
);
|
||||
}
|
||||
//#endregion inputRequired_manual
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -655,6 +776,21 @@ async function errorHandling_timeout(client: Client) {
|
|||
//#endregion errorHandling_timeout
|
||||
}
|
||||
|
||||
/** Example: Typed HTTP transport errors. */
|
||||
async function errorHandling_http(client: Client, transport: StreamableHTTPClientTransport) {
|
||||
//#region errorHandling_http
|
||||
try {
|
||||
await client.connect(transport);
|
||||
} catch (error) {
|
||||
if (error instanceof SdkHttpError) {
|
||||
console.error(`HTTP ${error.status} (${error.statusText ?? ''}) [${error.code}]`);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
//#endregion errorHandling_http
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Advanced patterns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -765,16 +901,22 @@ void readResource_basic;
|
|||
void subscribeResource_basic;
|
||||
void getPrompt_basic;
|
||||
void complete_basic;
|
||||
void responseCache_basic;
|
||||
void notificationHandler_basic;
|
||||
void setLoggingLevel_basic;
|
||||
void logLevelMeta_modern;
|
||||
void listChanged_basic;
|
||||
void listen_basic;
|
||||
void listen_watchLoop;
|
||||
void capabilities_declaration;
|
||||
void sampling_handler;
|
||||
void elicitation_handler;
|
||||
void roots_handler;
|
||||
void inputRequired_manual;
|
||||
void errorHandling_toolErrors;
|
||||
void errorHandling_lifecycle;
|
||||
void errorHandling_timeout;
|
||||
void errorHandling_http;
|
||||
void middleware_basic;
|
||||
void traceContext_perRequest;
|
||||
void traceContext_middleware;
|
||||
|
|
|
|||
|
|
@ -9,12 +9,28 @@
|
|||
|
||||
//#region imports
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createServer } from 'node:http';
|
||||
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type { CallToolResult, ResourceLink } from '@modelcontextprotocol/server';
|
||||
import { completable, McpServer, ResourceTemplate, TRACEPARENT_META_KEY } from '@modelcontextprotocol/server';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
||||
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
|
||||
import {
|
||||
createMcpExpressApp,
|
||||
getOAuthProtectedResourceMetadataUrl,
|
||||
mcpAuthMetadataRouter,
|
||||
requireBearerAuth
|
||||
} from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node';
|
||||
import type { CallToolResult, InputRequiredResult, OAuthMetadata, ResourceLink } from '@modelcontextprotocol/server';
|
||||
import {
|
||||
acceptedContent,
|
||||
completable,
|
||||
createMcpHandler,
|
||||
createRequestStateCodec,
|
||||
inputRequired,
|
||||
McpServer,
|
||||
ResourceTemplate,
|
||||
TRACEPARENT_META_KEY
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { serveStdio, StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
||||
import * as z from 'zod/v4';
|
||||
//#endregion imports
|
||||
|
||||
|
|
@ -289,6 +305,40 @@ function extensionCapabilities_register(server: McpServer) {
|
|||
//#endregion extensionCapabilities_register
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache hints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: cache hints via ServerOptions.cacheHints and a per-resource cacheHint. */
|
||||
function cacheHints_basic() {
|
||||
//#region cacheHints_basic
|
||||
const server = new McpServer(
|
||||
{ name: 'my-server', version: '1.0.0' },
|
||||
{
|
||||
cacheHints: {
|
||||
// The tool list is the same for every caller and rarely changes:
|
||||
'tools/list': { ttlMs: 60_000, cacheScope: 'public' }
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'config',
|
||||
'config://app',
|
||||
{
|
||||
mimeType: 'text/plain',
|
||||
// Wins field-by-field over a cacheHints['resources/read'] entry;
|
||||
// cacheScope stays at the 'private' default here.
|
||||
cacheHint: { ttlMs: 300_000 }
|
||||
},
|
||||
async uri => ({
|
||||
contents: [{ uri: uri.href, text: 'App configuration here' }]
|
||||
})
|
||||
);
|
||||
//#endregion cacheHints_basic
|
||||
return server;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -379,6 +429,50 @@ function registerTool_traceContext(server: McpServer) {
|
|||
//#endregion registerTool_traceContext
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Change notifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: hand-wired resources/subscribe handlers + sendResourceUpdated (2025-era). */
|
||||
function subscriptions_legacy() {
|
||||
//#region subscriptions_legacy
|
||||
const server = new McpServer(
|
||||
{ name: 'my-server', version: '1.0.0' },
|
||||
{ capabilities: { resources: { subscribe: true, listChanged: true } } }
|
||||
);
|
||||
|
||||
const subscriptions = new Set<string>();
|
||||
server.server.setRequestHandler('resources/subscribe', async request => {
|
||||
subscriptions.add(request.params.uri);
|
||||
return {};
|
||||
});
|
||||
server.server.setRequestHandler('resources/unsubscribe', async request => {
|
||||
subscriptions.delete(request.params.uri);
|
||||
return {};
|
||||
});
|
||||
|
||||
// When the underlying data changes:
|
||||
async function onConfigChanged() {
|
||||
if (subscriptions.has('config://app')) {
|
||||
await server.server.sendResourceUpdated({ uri: 'config://app' });
|
||||
}
|
||||
}
|
||||
//#endregion subscriptions_legacy
|
||||
return onConfigChanged;
|
||||
}
|
||||
|
||||
/** Example: publishing change events through the createMcpHandler notify facade (2026-07-28). */
|
||||
function subscriptions_notify(buildServer: () => McpServer) {
|
||||
//#region subscriptions_notify
|
||||
const handler = createMcpHandler(() => buildServer());
|
||||
|
||||
// When the underlying data changes:
|
||||
handler.notify.resourceUpdated('config://app');
|
||||
handler.notify.toolsChanged();
|
||||
//#endregion subscriptions_notify
|
||||
return handler;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server-initiated requests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -479,6 +573,90 @@ function registerTool_roots(server: McpServer) {
|
|||
//#endregion registerTool_roots
|
||||
}
|
||||
|
||||
/** Example: write-once tool requesting input via an input_required return (2026-07-28). */
|
||||
function registerTool_inputRequired(server: McpServer) {
|
||||
//#region registerTool_inputRequired
|
||||
server.registerTool(
|
||||
'deploy',
|
||||
{
|
||||
description: 'Deploy after user confirmation',
|
||||
inputSchema: z.object({ env: z.string() })
|
||||
},
|
||||
async ({ env }, ctx): Promise<CallToolResult | InputRequiredResult> => {
|
||||
const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm');
|
||||
if (confirmed?.confirm !== true) {
|
||||
return inputRequired({
|
||||
inputRequests: {
|
||||
confirm: inputRequired.elicit({
|
||||
message: `Deploy to ${env}?`,
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: { confirm: { type: 'boolean' } },
|
||||
required: ['confirm']
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
return { content: [{ type: 'text', text: `Deployed to ${env}` }] };
|
||||
}
|
||||
);
|
||||
//#endregion registerTool_inputRequired
|
||||
}
|
||||
|
||||
/** Example: HMAC-protected requestState via createRequestStateCodec + the verify hook. */
|
||||
function requestState_codec() {
|
||||
//#region requestState_codec
|
||||
const stateCodec = createRequestStateCodec<{ step: string }>({
|
||||
key: crypto.getRandomValues(new Uint8Array(32)), // >= 32 bytes; share across instances in a fleet
|
||||
ttlSeconds: 600
|
||||
});
|
||||
|
||||
const server = new McpServer(
|
||||
{ name: 'my-server', version: '1.0.0' },
|
||||
{ capabilities: { tools: {} }, requestState: { verify: stateCodec.verify } }
|
||||
);
|
||||
//#endregion requestState_codec
|
||||
|
||||
//#region requestState_mintDecode
|
||||
server.registerTool(
|
||||
'wipe-cache',
|
||||
{ description: 'Confirm, then pick a scope, then wipe', inputSchema: z.object({}) },
|
||||
async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => {
|
||||
const state = ctx.mcpReq.requestState<{ step: string }>();
|
||||
|
||||
if (state?.step !== 'confirmed') {
|
||||
const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm');
|
||||
if (confirmed?.confirm !== true) {
|
||||
return inputRequired({
|
||||
inputRequests: {
|
||||
confirm: inputRequired.elicit({
|
||||
message: 'Really wipe the cache?',
|
||||
requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] }
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
// Mint only what the response above already proved: the user confirmed.
|
||||
return inputRequired({
|
||||
inputRequests: {
|
||||
scope: inputRequired.elicit({
|
||||
message: 'Which scope?',
|
||||
requestedSchema: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'] }
|
||||
})
|
||||
},
|
||||
requestState: await stateCodec.mint({ step: 'confirmed' })
|
||||
});
|
||||
}
|
||||
|
||||
const scope = acceptedContent<{ scope: string }>(ctx.mcpReq.inputResponses, 'scope');
|
||||
return { content: [{ type: 'text', text: `Wiped ${scope?.scope ?? 'all'}` }] };
|
||||
}
|
||||
);
|
||||
//#endregion requestState_mintDecode
|
||||
return server;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -532,6 +710,38 @@ async function stdio_basic() {
|
|||
//#endregion stdio_basic
|
||||
}
|
||||
|
||||
/** Example: serveStdio serving both protocol eras on stdio from one factory. */
|
||||
function serveStdio_basic() {
|
||||
//#region serveStdio_basic
|
||||
serveStdio(() => {
|
||||
const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } });
|
||||
// register tools/resources/prompts once; the same factory serves both eras
|
||||
return server;
|
||||
});
|
||||
//#endregion serveStdio_basic
|
||||
}
|
||||
|
||||
/** Example: createMcpHandler serving both protocol eras over HTTP from one factory. */
|
||||
function createMcpHandler_basic() {
|
||||
//#region createMcpHandler_basic
|
||||
const handler = createMcpHandler(() => {
|
||||
const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } });
|
||||
// register tools/resources/prompts once; the same factory serves both eras
|
||||
return server;
|
||||
});
|
||||
//#endregion createMcpHandler_basic
|
||||
return handler;
|
||||
}
|
||||
|
||||
/** Example: mounting an McpHttpHandler on node:http via toNodeHandler. */
|
||||
function createMcpHandler_node(handler: ReturnType<typeof createMcpHandler>) {
|
||||
//#region createMcpHandler_node
|
||||
createServer(toNodeHandler(handler)).listen(3000);
|
||||
// Express: app.all('/mcp', toNodeHandler(handler));
|
||||
// behind express.json(): const node = toNodeHandler(handler); app.all('/mcp', (req, res) => void node(req, res, req.body));
|
||||
//#endregion createMcpHandler_node
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shutdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -595,6 +805,50 @@ function dnsRebinding_allowedHosts() {
|
|||
return app;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authorization (OAuth resource server)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: protecting an HTTP server as an OAuth resource server. */
|
||||
function auth_resourceServer(
|
||||
verifyJwt: (token: string) => Promise<{ sub: string; scopes: string[]; exp: number }>,
|
||||
oauthMetadata: OAuthMetadata,
|
||||
buildServer: () => McpServer
|
||||
) {
|
||||
//#region auth_resourceServer
|
||||
const mcpServerUrl = new URL('https://api.example.com/mcp');
|
||||
|
||||
// Verify tokens however your deployment requires: JWT verification,
|
||||
// RFC 7662 introspection, a call to your IdP.
|
||||
const verifier: OAuthTokenVerifier = {
|
||||
async verifyAccessToken(token) {
|
||||
const payload = await verifyJwt(token);
|
||||
return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp };
|
||||
}
|
||||
};
|
||||
|
||||
// Public deployment: allow-list the public host (see DNS rebinding protection).
|
||||
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] });
|
||||
|
||||
// Serves /.well-known/oauth-protected-resource/mcp (RFC 9728) and mirrors the
|
||||
// authorization server's metadata, so clients can discover your AS.
|
||||
app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl }));
|
||||
|
||||
// 401/403 responses carry `WWW-Authenticate: Bearer …` with `resource_metadata`
|
||||
// pointing at the document above. That challenge is what starts the client
|
||||
// SDK's OAuth flow.
|
||||
const auth = requireBearerAuth({
|
||||
verifier,
|
||||
requiredScopes: ['mcp'],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
|
||||
});
|
||||
|
||||
const node = toNodeHandler(createMcpHandler(buildServer));
|
||||
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));
|
||||
//#endregion auth_resourceServer
|
||||
return app;
|
||||
}
|
||||
|
||||
// Suppress unused-function warnings (functions exist solely for type-checking)
|
||||
void instructions_basic;
|
||||
void registerTool_basic;
|
||||
|
|
@ -608,16 +862,25 @@ void registerTool_traceContext;
|
|||
void registerTool_sampling;
|
||||
void registerTool_elicitation;
|
||||
void registerTool_roots;
|
||||
void registerTool_inputRequired;
|
||||
void requestState_codec;
|
||||
void registerResource_static;
|
||||
void registerResource_template;
|
||||
void registerPrompt_basic;
|
||||
void registerPrompt_completion;
|
||||
void extensionCapabilities_register;
|
||||
void cacheHints_basic;
|
||||
void subscriptions_legacy;
|
||||
void subscriptions_notify;
|
||||
void streamableHttp_stateful;
|
||||
void streamableHttp_stateless;
|
||||
void streamableHttp_jsonResponse;
|
||||
void stdio_basic;
|
||||
void serveStdio_basic;
|
||||
void createMcpHandler_basic;
|
||||
void createMcpHandler_node;
|
||||
void shutdown_statefulHttp;
|
||||
void shutdown_stdio;
|
||||
void dnsRebinding_basic;
|
||||
void dnsRebinding_allowedHosts;
|
||||
void auth_resourceServer;
|
||||
|
|
|
|||
5
examples/server-quickstart/README.md
Normal file
5
examples/server-quickstart/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# server-quickstart
|
||||
|
||||
Source for the [Server Quickstart](../../docs/server-quickstart.md) tutorial: a stdio weather server exposing `get-alerts` and `get-forecast` tools. The tutorial walks through `src/index.ts` end to end.
|
||||
|
||||
The `package.json` and `tsconfig.json` here are monorepo-internal (`workspace:`/`catalog:` protocols; typecheck-only in CI). To build the server yourself, use the standalone manifests from the tutorial.
|
||||
|
|
@ -1,11 +1,8 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/examples-server-quickstart",
|
||||
"name": "@mcp-examples/server-quickstart",
|
||||
"private": true,
|
||||
"version": "2.0.0-alpha.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"weather": "./build/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
|
|
|||
|
|
@ -1,16 +1,10 @@
|
|||
//#region prelude
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
||||
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const NWS_API_BASE = 'https://api.weather.gov';
|
||||
const USER_AGENT = 'weather-app/1.0';
|
||||
|
||||
// Create server instance
|
||||
const server = new McpServer({
|
||||
name: 'weather',
|
||||
version: '1.0.0',
|
||||
});
|
||||
//#endregion prelude
|
||||
|
||||
//#region helpers
|
||||
|
|
@ -83,140 +77,147 @@ interface ForecastResponse {
|
|||
//#endregion helpers
|
||||
|
||||
//#region registerTools
|
||||
// Register weather tools
|
||||
server.registerTool(
|
||||
'get-alerts',
|
||||
{
|
||||
title: 'Get Weather Alerts',
|
||||
description: 'Get weather alerts for a state',
|
||||
inputSchema: z.object({
|
||||
state: z.string().length(2)
|
||||
.describe('Two-letter state code (e.g. CA, NY)'),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const stateCode = state.toUpperCase();
|
||||
const alertsUrl = `${NWS_API_BASE}/alerts?area=${stateCode}`;
|
||||
const alertsData = await makeNWSRequest<AlertsResponse>(alertsUrl);
|
||||
// Create a server with both weather tools registered. The serving entry calls
|
||||
// this factory to build the instance it serves, so keep it cheap and
|
||||
// side-effect-free.
|
||||
function createServer(): McpServer {
|
||||
const server = new McpServer({
|
||||
name: 'weather',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
server.registerTool(
|
||||
'get-alerts',
|
||||
{
|
||||
title: 'Get Weather Alerts',
|
||||
description: 'Get weather alerts for a state',
|
||||
inputSchema: z.object({
|
||||
state: z.string().length(2)
|
||||
.describe('Two-letter state code (e.g. CA, NY)'),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const stateCode = state.toUpperCase();
|
||||
const alertsUrl = `${NWS_API_BASE}/alerts?area=${stateCode}`;
|
||||
const alertsData = await makeNWSRequest<AlertsResponse>(alertsUrl);
|
||||
|
||||
if (!alertsData) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Failed to retrieve alerts data',
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const features = alertsData.features || [];
|
||||
|
||||
if (features.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `No active alerts for ${stateCode}`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedAlerts = features.map(formatAlert);
|
||||
|
||||
if (!alertsData) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: 'Failed to retrieve alerts data',
|
||||
type: 'text',
|
||||
text: `Active alerts for ${stateCode}:\n\n${formattedAlerts.join('\n')}`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const features = alertsData.features || [];
|
||||
server.registerTool(
|
||||
'get-forecast',
|
||||
{
|
||||
title: 'Get Weather Forecast',
|
||||
description: 'Get weather forecast for a location',
|
||||
inputSchema: z.object({
|
||||
latitude: z.number().min(-90).max(90)
|
||||
.describe('Latitude of the location'),
|
||||
longitude: z.number().min(-180).max(180)
|
||||
.describe('Longitude of the location'),
|
||||
}),
|
||||
},
|
||||
async ({ latitude, longitude }) => {
|
||||
// Get grid point data
|
||||
const pointsUrl = `${NWS_API_BASE}/points/${latitude.toFixed(4)},${longitude.toFixed(4)}`;
|
||||
const pointsData = await makeNWSRequest<PointsResponse>(pointsUrl);
|
||||
|
||||
if (!pointsData) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Failed to retrieve grid point data for coordinates: ${latitude}, ${longitude}. This location may not be supported by the NWS API (only US locations are supported).`,
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const forecastUrl = pointsData.properties?.forecast;
|
||||
if (!forecastUrl) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Failed to get forecast URL from grid point data',
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Get forecast data
|
||||
const forecastData = await makeNWSRequest<ForecastResponse>(forecastUrl);
|
||||
if (!forecastData) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Failed to retrieve forecast data',
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const periods = forecastData.properties?.periods || [];
|
||||
if (periods.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'No forecast periods available',
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// Format forecast periods
|
||||
const formattedForecast = periods.map((period: ForecastPeriod) =>
|
||||
[
|
||||
`${period.name || 'Unknown'}:`,
|
||||
`Temperature: ${period.temperature || 'Unknown'}°${period.temperatureUnit || 'F'}`,
|
||||
`Wind: ${period.windSpeed || 'Unknown'} ${period.windDirection || ''}`,
|
||||
`${period.shortForecast || 'No forecast available'}`,
|
||||
'---',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
if (features.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `No active alerts for ${stateCode}`,
|
||||
type: 'text',
|
||||
text: `Forecast for ${latitude}, ${longitude}:\n\n${formattedForecast.join('\n')}`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const formattedAlerts = features.map(formatAlert);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `Active alerts for ${stateCode}:\n\n${formattedAlerts.join('\n')}`,
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get-forecast',
|
||||
{
|
||||
title: 'Get Weather Forecast',
|
||||
description: 'Get weather forecast for a location',
|
||||
inputSchema: z.object({
|
||||
latitude: z.number().min(-90).max(90)
|
||||
.describe('Latitude of the location'),
|
||||
longitude: z.number().min(-180).max(180)
|
||||
.describe('Longitude of the location'),
|
||||
}),
|
||||
},
|
||||
async ({ latitude, longitude }) => {
|
||||
// Get grid point data
|
||||
const pointsUrl = `${NWS_API_BASE}/points/${latitude.toFixed(4)},${longitude.toFixed(4)}`;
|
||||
const pointsData = await makeNWSRequest<PointsResponse>(pointsUrl);
|
||||
|
||||
if (!pointsData) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `Failed to retrieve grid point data for coordinates: ${latitude}, ${longitude}. This location may not be supported by the NWS API (only US locations are supported).`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const forecastUrl = pointsData.properties?.forecast;
|
||||
if (!forecastUrl) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: 'Failed to get forecast URL from grid point data',
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// Get forecast data
|
||||
const forecastData = await makeNWSRequest<ForecastResponse>(forecastUrl);
|
||||
if (!forecastData) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: 'Failed to retrieve forecast data',
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const periods = forecastData.properties?.periods || [];
|
||||
if (periods.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: 'No forecast periods available',
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// Format forecast periods
|
||||
const formattedForecast = periods.map((period: ForecastPeriod) =>
|
||||
[
|
||||
`${period.name || 'Unknown'}:`,
|
||||
`Temperature: ${period.temperature || 'Unknown'}°${period.temperatureUnit || 'F'}`,
|
||||
`Wind: ${period.windSpeed || 'Unknown'} ${period.windDirection || ''}`,
|
||||
`${period.shortForecast || 'No forecast available'}`,
|
||||
'---',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `Forecast for ${latitude}, ${longitude}:\n\n${formattedForecast.join('\n')}`,
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
return server;
|
||||
}
|
||||
//#endregion registerTools
|
||||
|
||||
//#region main
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error('Weather MCP Server running on stdio');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error in main():', error);
|
||||
process.exit(1);
|
||||
});
|
||||
void serveStdio(createServer);
|
||||
console.error('Weather MCP Server running on stdio');
|
||||
//#endregion main
|
||||
|
|
|
|||
Loading…
Reference in a new issue