Implement MCP 2026-07-28 (#2286)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
e4227d135c
commit
1823aae891
159 changed files with 69131 additions and 9001 deletions
94
examples/CONTRIBUTING.md
Normal file
94
examples/CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Contributing an example
|
||||
|
||||
Each `examples/<story>/` directory is a tiny `@mcp-examples/<story>` workspace
|
||||
package containing a `server.ts` / `client.ts` pair. The pair is a
|
||||
self-verifying e2e test: the client connects, asserts results, and exits
|
||||
non-zero on any mismatch. `pnpm run:examples` runs every story over its
|
||||
configured transport × era legs and is part of the per-PR CI gate.
|
||||
|
||||
## Typical shape
|
||||
|
||||
Examples are **compiled documentation**. Every story shows the SDK transport
|
||||
setup **inline** — no helper hides `serveStdio`, `createMcpHandler`, `Client`,
|
||||
or transport construction. The duplication is the feature: when the public API
|
||||
changes, 25 compile errors flag 25 doc pages.
|
||||
|
||||
Only the part a reader is _not_ here to learn — argv parsing — is shared, via
|
||||
`parseExampleArgs` / `check` / `siblingPath` from `@mcp-examples/shared` (a
|
||||
workspace package, so it reads as scaffolding, not part of the example). The
|
||||
demo OAuth provider and `InMemoryEventStore` live at the
|
||||
`@mcp-examples/shared/auth` subpath so the args-only root barrel does not pull
|
||||
better-auth/express/better-sqlite3 into every story.
|
||||
|
||||
Most stories follow the skeleton below; deviate freely when the story calls for
|
||||
it (HTTP-only auth, sessionful transports, framework adapters, etc.).
|
||||
|
||||
### `server.ts`
|
||||
|
||||
```ts
|
||||
import { createServer } from 'node:http';
|
||||
|
||||
import { parseExampleArgs } from '@mcp-examples/shared';
|
||||
import { toNodeHandler } from '@modelcontextprotocol/node';
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
||||
|
||||
function buildServer(): McpServer {
|
||||
const server = new McpServer({ name: '<story>-example', version: '1.0.0' });
|
||||
// … register tools / resources / prompts here …
|
||||
return server;
|
||||
}
|
||||
|
||||
const { transport, port } = parseExampleArgs();
|
||||
|
||||
if (transport === 'stdio') {
|
||||
void serveStdio(buildServer);
|
||||
console.error('[server] serving over stdio');
|
||||
} else {
|
||||
const handler = createMcpHandler(buildServer);
|
||||
createServer(toNodeHandler(handler)).listen(port, () => {
|
||||
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### `client.ts`
|
||||
|
||||
```ts
|
||||
import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared';
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
||||
|
||||
const { transport, url, era } = parseExampleArgs();
|
||||
|
||||
const client = new Client({ name: '<story>-example-client', version: '1.0.0' }, { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } });
|
||||
|
||||
await client.connect(transport === 'stdio' ? new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] }) : new StreamableHTTPClientTransport(new URL(url)));
|
||||
|
||||
// … example body — drive the server and assert with `check.*` …
|
||||
|
||||
await client.close();
|
||||
```
|
||||
|
||||
The body uses top-level `await`. A `check.*` failure throws, Node prints the
|
||||
error and exits 1; on success `client.close()` releases the last handle and
|
||||
Node exits 0. `pnpm run:examples` reports PASS/FAIL from the exit code (a
|
||||
timeout is reported as a hang — investigate it as a possible unclosed handle).
|
||||
|
||||
## Import rules (lint-enforced)
|
||||
|
||||
Stories may import from:
|
||||
|
||||
- `@modelcontextprotocol/{server,client,core,node,express,hono}` and their published
|
||||
subpath exports (e.g. `@modelcontextprotocol/server/stdio`)
|
||||
- `@mcp-examples/shared` (args/assert) and `@mcp-examples/shared/auth` (demo OAuth + `InMemoryEventStore`)
|
||||
- third-party packages a consumer would `npm install`
|
||||
|
||||
Stories may **not** import from:
|
||||
|
||||
- `@modelcontextprotocol/core-internal` or `@modelcontextprotocol/core-internal/*` (internal barrel)
|
||||
- `@modelcontextprotocol/*/src/*` or `@modelcontextprotocol/*/dist/*` (deep paths)
|
||||
- `@modelcontextprotocol/test-helpers`
|
||||
- any relative path that hides the SDK transport setup behind a shared helper
|
||||
|
||||
`@mcp-examples/shared` itself must never import from a story package (one-way).
|
||||
172
examples/README.md
Normal file
172
examples/README.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# MCP TypeScript SDK examples
|
||||
|
||||
One **story** per directory. Every story is a runnable, self-verifying client/server pair: `server.ts` is what you would deploy, `client.ts` is what a host would write — it connects, exercises the feature with the public client API, asserts results, and exits 0. CI runs every
|
||||
pair over every transport it supports (`scripts/examples/run-examples.ts`); a non-zero exit fails the build.
|
||||
|
||||
Each story is its own private workspace package (`@mcp-examples/<story>`). Run any pair from the repo root:
|
||||
|
||||
```bash
|
||||
# stdio (the client spawns the server itself):
|
||||
pnpm --filter @mcp-examples/<story> client
|
||||
|
||||
# Streamable HTTP (two terminals):
|
||||
pnpm --filter @mcp-examples/<story> server -- --http --port 3000
|
||||
pnpm --filter @mcp-examples/<story> client -- --http http://127.0.0.1:3000/mcp
|
||||
```
|
||||
|
||||
Add `-- --legacy` to the client command for the 2025-era handshake.
|
||||
|
||||
## Start here
|
||||
|
||||
| Story | What it teaches |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| [`tools/`](./tools/README.md) | Register tools, infer input/output schemas, call them, structured output |
|
||||
| [`prompts/`](./prompts/README.md) | Prompts + argument completion |
|
||||
| [`resources/`](./resources/README.md) | Static + templated resources, list/read |
|
||||
| [`dual-era/`](./dual-era/README.md) | One factory, both protocol eras, both transports |
|
||||
|
||||
## Feature stories
|
||||
|
||||
| Story | What it teaches | Transports | Era |
|
||||
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------ | -------------- |
|
||||
| [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | modern |
|
||||
| [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | modern |
|
||||
| [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | dual |
|
||||
| [`elicitation/`](./elicitation/README.md) | Elicitation (form + URL mode), both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual |
|
||||
| [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client, both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual |
|
||||
| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | dual |
|
||||
| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | modern |
|
||||
| [`gateway/`](./gateway/README.md) | `connect({ prior })` — probe once, zero-round-trip connect for every worker (gateway pattern) | http | modern |
|
||||
| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual |
|
||||
| [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | dual |
|
||||
| [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | legacy |
|
||||
| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual |
|
||||
| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | dual (in-body) |
|
||||
| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual |
|
||||
| [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual |
|
||||
| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual |
|
||||
| [`scoped-tools/`](./scoped-tools/README.md) | Per-tool scope on `createMcpHandler` — bearer-verify gate + handler-level `ctx.http?.authInfo` checks | http | modern |
|
||||
|
||||
## HTTP hosting variants
|
||||
|
||||
| Story | What it teaches | Transports | Era |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------- | ---------- | -------------- |
|
||||
| [`stateless-legacy/`](./stateless-legacy/README.md) | `createMcpHandler` default posture (the minimal deployment) | http | dual (in-body) |
|
||||
| [`json-response/`](./json-response/README.md) | `createMcpHandler({ responseMode: 'json' })` | http | modern |
|
||||
| [`hono/`](./hono/README.md) | `createMcpHandler(...).fetch` on Hono / web-standard runtimes | http | dual |
|
||||
| [`sse-polling/`](./sse-polling/README.md) | SEP-1699 SSE polling/resumption (sessionful 2025) | http | legacy |
|
||||
| [`standalone-get/`](./standalone-get/README.md) | Standalone GET stream + `listChanged` push (sessionful 2025) | http | legacy |
|
||||
|
||||
`dual (in-body)` = the client connects to both eras inside one runner invocation; the story demonstrates one server serving both side by side.
|
||||
|
||||
## Excluded
|
||||
|
||||
| Directory | What it is | Why not in CI |
|
||||
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. |
|
||||
| [`guides/`](./guides/README.md) | Snippet collections synced into `docs/server.md` and `docs/client.md` | Typecheck-only; not a runnable pair. |
|
||||
| `server-quickstart/`, `client-quickstart/` | Website-tutorial sources | External network / API key; typecheck-only. |
|
||||
| `shared/` | Argv/assert scaffold (`parseExampleArgs`/`check`/`siblingPath`); demo OAuth provider + `InMemoryEventStore` at the `./auth` subpath | Not a story — imported by every story as scaffolding. |
|
||||
|
||||
## Multi-node deployment patterns
|
||||
|
||||
When deploying MCP servers in a horizontally scaled environment (multiple server instances), there are a few different options that can be useful for different use cases:
|
||||
|
||||
- **Stateless mode** - no need to maintain state between calls.
|
||||
- **Persistent storage mode** - state stored in a database; any node can handle a session.
|
||||
- **Local state with message routing** - stateful nodes + pub/sub routing for a session.
|
||||
|
||||
### Stateless mode
|
||||
|
||||
To enable stateless mode, configure the `NodeStreamableHTTPServerTransport` with:
|
||||
|
||||
```typescript
|
||||
sessionIdGenerator: undefined;
|
||||
```
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Client │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Load Balancer │
|
||||
└─────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────────┐
|
||||
│ MCP Server #1 │ │ MCP Server #2 │
|
||||
│ (Node.js) │ │ (Node.js) │
|
||||
└─────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
### Persistent storage mode
|
||||
|
||||
Configure the transport with session management, but use an external event store:
|
||||
|
||||
```typescript
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
eventStore: databaseEventStore
|
||||
```
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Client │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Load Balancer │
|
||||
└─────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────────┐
|
||||
│ MCP Server #1 │ │ MCP Server #2 │
|
||||
│ (Node.js) │ │ (Node.js) │
|
||||
└─────────────────┘ └─────────────────────┘
|
||||
│ │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Database (PostgreSQL) │
|
||||
│ │
|
||||
│ • Session state │
|
||||
│ • Event storage for resumability │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Streamable HTTP with distributed message routing
|
||||
|
||||
For scenarios where local in-memory state must be maintained on specific nodes, combine Streamable HTTP with pub/sub routing so one node can terminate the client connection while another node owns the session state.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Client │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Load Balancer │
|
||||
└─────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────────┐
|
||||
│ MCP Server #1 │◄───►│ MCP Server #2 │
|
||||
│ (Has Session A) │ │ (Has Session B) │
|
||||
└─────────────────┘ └─────────────────────┘
|
||||
▲│ ▲│
|
||||
│▼ │▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Message Queue / Pub-Sub │
|
||||
│ │
|
||||
│ • Session ownership registry │
|
||||
│ • Bidirectional message routing │
|
||||
│ • Request/response forwarding │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Backwards compatibility (Streamable HTTP ↔ legacy SSE)
|
||||
|
||||
A client that needs to fall back from Streamable HTTP to the legacy HTTP+SSE transport (for servers that only implement the older transport) follows the [`connect_sseFallback`](../docs/client.md#sse-fallback-for-legacy-servers) recipe in the client guide — try
|
||||
`StreamableHTTPClientTransport` first, fall back to `SSEClientTransport` on a 4xx. There is no runnable pair for this in `examples/` (the legacy SSE server transport is deprecated); the snippet in `guides/clientGuide.examples.ts` is the complete pattern.
|
||||
6
examples/bearer-auth/README.md
Normal file
6
examples/bearer-auth/README.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# bearer-auth
|
||||
|
||||
Resource-server-only auth: `requireBearerAuth` from `@modelcontextprotocol/express` in front of `createMcpHandler`. The client asserts `401` + `WWW-Authenticate` without a token, and that the verified `authInfo` reaches the factory (`ctx.authInfo`) with
|
||||
one.
|
||||
|
||||
**HTTP-only** by definition. The full interactive OAuth set lives under `../oauth/` (run headlessly in CI via the demo AS's auto-consent mode).
|
||||
31
examples/bearer-auth/client.ts
Normal file
31
examples/bearer-auth/client.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Asserts a bare request is `401` with a `WWW-Authenticate` header, and that
|
||||
* a request with `Authorization: Bearer demo-token` reaches the `whoami` tool
|
||||
* with the verified `authInfo`.
|
||||
*/
|
||||
import { check, parseExampleArgs } from '@mcp-examples/shared';
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const { url, era } = parseExampleArgs();
|
||||
|
||||
// Unauthenticated → 401 + WWW-Authenticate.
|
||||
const unauth = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' })
|
||||
});
|
||||
check.equal(unauth.status, 401);
|
||||
check.match(unauth.headers.get('www-authenticate') ?? '', /Bearer/);
|
||||
|
||||
// Authenticated → 200 and the tool sees the authInfo. Bearer auth is
|
||||
// HTTP-layer and era-agnostic; the client honours `--legacy` via `era`.
|
||||
const client = new Client(
|
||||
{ name: 'bearer-auth-example-client', version: '1.0.0' },
|
||||
{ versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } }
|
||||
);
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(url), { authProvider: { token: async () => 'demo-token' } }));
|
||||
|
||||
const result = await client.callTool({ name: 'whoami', arguments: {} });
|
||||
check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', 'client=demo-client');
|
||||
|
||||
await client.close();
|
||||
28
examples/bearer-auth/package.json
Normal file
28
examples/bearer-auth/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "@mcp-examples/bearer-auth",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"server": "tsx server.ts",
|
||||
"client": "tsx client.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mcp-examples/shared": "workspace:*",
|
||||
"@modelcontextprotocol/client": "workspace:*",
|
||||
"@modelcontextprotocol/express": "workspace:*",
|
||||
"@modelcontextprotocol/node": "workspace:*",
|
||||
"@modelcontextprotocol/server": "workspace:*",
|
||||
"zod": "catalog:runtimeShared"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "catalog:devTools"
|
||||
},
|
||||
"example": {
|
||||
"transports": [
|
||||
"http"
|
||||
],
|
||||
"era": "dual",
|
||||
"path": "/mcp",
|
||||
"//": "Bearer auth + 401/WWW-Authenticate is HTTP-layer and era-agnostic; the client honours --legacy via the inline versionNegotiation branch."
|
||||
}
|
||||
}
|
||||
51
examples/bearer-auth/server.ts
Normal file
51
examples/bearer-auth/server.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Minimal Resource-Server-only auth: `requireBearerAuth` + `OAuthTokenVerifier`
|
||||
* in front of `createMcpHandler`. The verifier accepts a single static
|
||||
* `demo-token`; the verified `authInfo` reaches the factory as `ctx.authInfo`.
|
||||
*
|
||||
* No Authorization Server here, and no metadata endpoints — see `examples/oauth/`
|
||||
* for the full RS + AS discovery flow. HTTP-only by definition.
|
||||
*/
|
||||
import { parseExampleArgs } from '@mcp-examples/shared';
|
||||
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
|
||||
import { createMcpExpressApp, requireBearerAuth } from '@modelcontextprotocol/express';
|
||||
import { toNodeHandler } from '@modelcontextprotocol/node';
|
||||
import type { AuthInfo, McpServerFactory } from '@modelcontextprotocol/server';
|
||||
import { createMcpHandler, McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const buildServer: McpServerFactory = ctx => {
|
||||
const server = new McpServer({ name: 'bearer-auth-example', version: '1.0.0' });
|
||||
server.registerTool('whoami', { description: 'Returns the authenticated subject.', inputSchema: z.object({}) }, async () => ({
|
||||
content: [{ type: 'text', text: `client=${ctx.authInfo?.clientId ?? 'anon'}` }]
|
||||
}));
|
||||
return server;
|
||||
};
|
||||
|
||||
const { port } = parseExampleArgs();
|
||||
|
||||
// Replace with JWT verification, RFC 7662 introspection, etc.
|
||||
const staticTokenVerifier: OAuthTokenVerifier = {
|
||||
async verifyAccessToken(token): Promise<AuthInfo> {
|
||||
if (token !== 'demo-token') {
|
||||
throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token');
|
||||
}
|
||||
return { token, clientId: 'demo-client', scopes: ['mcp'], expiresAt: Math.floor(Date.now() / 1000) + 3600 };
|
||||
}
|
||||
};
|
||||
|
||||
// Bearer auth is HTTP-layer (no stdio arm). The MCP handler is the canonical
|
||||
// `createMcpHandler(buildServer)`; the Express auth middleware in front of it
|
||||
// is the point of this story.
|
||||
const handler = createMcpHandler(buildServer);
|
||||
|
||||
const app = createMcpExpressApp();
|
||||
const auth = requireBearerAuth({ verifier: staticTokenVerifier, requiredScopes: ['mcp'] });
|
||||
// `requireBearerAuth` sets `req.auth`; `toNodeHandler` reads it and passes it
|
||||
// to the factory as `ctx.authInfo`.
|
||||
const node = toNodeHandler(handler);
|
||||
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));
|
||||
|
||||
app.listen(port, () => {
|
||||
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
|
||||
});
|
||||
52
examples/caching/README.md
Normal file
52
examples/caching/README.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# caching
|
||||
|
||||
`CacheableResult` freshness hints (protocol revision 2026-07-28). The server declares hints at two layers — a per-registration `cacheHint` on the resource and server-level `ServerOptions.cacheHints` — and the SDK resolves most-specific-author-first (handler-return fields would
|
||||
take precedence over both) and stamps `ttlMs`/`cacheScope` on the wire toward modern clients only. The client honours the stamped values: a still-fresh held entry is served without a round trip.
|
||||
|
||||
```bash
|
||||
pnpm tsx examples/caching/client.ts
|
||||
```
|
||||
|
||||
The client calls `listTools()` and `readResource()` twice each; the second of each pair is served from the response cache. The server exposes a `request-count` tool (how many `tools/list` requests reached it) and a `read-count` tool (how many times the resource handler ran), so the example asserts each counter is unchanged after the cache-served call and increments after `cacheMode: 'refresh'`.
|
||||
|
||||
## `cacheMode`
|
||||
|
||||
Per-call control on the cacheable verbs (`listTools()` / `listPrompts()` / `listResources()` / `listResourceTemplates()` / `readResource()`):
|
||||
|
||||
```ts
|
||||
await client.readResource({ uri: 'config://app' }); // 'use' (default): serve from cache if fresh
|
||||
await client.readResource({ uri: 'config://app' }, { cacheMode: 'refresh' }); // always fetch, then re-store
|
||||
await client.readResource({ uri: 'config://app' }, { cacheMode: 'bypass' }); // fetch; do not read or write the cache
|
||||
```
|
||||
|
||||
A `list_changed` notification still evicts immediately regardless of TTL.
|
||||
|
||||
## Custom store
|
||||
|
||||
The default per-client `InMemoryResponseCacheStore` (bounded at 512 entries by default) is enough for most hosts. To back the cache with something persistent (Redis, KV, IndexedDB), implement the five-method `ResponseCacheStore` interface — the store is a dumb keyed-value carrier; freshness and partitioning are the client's job:
|
||||
|
||||
```ts
|
||||
import type { CacheEntry, CacheKey, CacheScope, ResponseCacheStore } from '@modelcontextprotocol/client';
|
||||
|
||||
class MyStore implements ResponseCacheStore {
|
||||
async get(key: CacheKey): Promise<CacheEntry | undefined> {
|
||||
/* read {value, stamp, expiresAt, scope} from your backend */
|
||||
}
|
||||
async set(key: CacheKey, entry: { value: unknown; expiresAt?: number; scope?: CacheScope }): Promise<number> {
|
||||
/* write entry under key; return a monotonically-increasing stamp */
|
||||
}
|
||||
async delete(key: CacheKey): Promise<void> {
|
||||
/* drop the single entry under key (no-op if absent) */
|
||||
}
|
||||
async evict(method: string): Promise<void> {
|
||||
/* drop every entry whose key.method === method (across every partition) */
|
||||
}
|
||||
async clear(): Promise<void> {
|
||||
/* drop everything */
|
||||
}
|
||||
}
|
||||
|
||||
const client = new Client({ name: 'host', version: '1.0.0' }, { responseCacheStore: new MyStore(), cachePartition: principalId });
|
||||
```
|
||||
|
||||
The SDK scopes every entry by the connected server's identity automatically — you do not encode server identity into `cachePartition` or the store key yourself. When one store backs several principals against the same server, set `ClientOptions.cachePartition` to a stable identity of the authorization context (e.g. the auth subject) so `'private'`-scoped entries are isolated per principal; `'public'`-scoped entries are shared within the connected server's namespace automatically. Note `serverInfo` is self-reported, so a server that deliberately impersonates another's `name`/`version` shares its `'public'` slot; the per-principal isolation holds regardless.
|
||||
78
examples/caching/client.ts
Normal file
78
examples/caching/client.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Reads the cache hints emitted on cacheable results (2026-07-28 connections
|
||||
* only) and asserts the client honours them: a still-fresh cached entry is
|
||||
* served without a round trip.
|
||||
*/
|
||||
import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared';
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
||||
|
||||
interface Cacheable {
|
||||
ttlMs?: number;
|
||||
cacheScope?: 'public' | 'private';
|
||||
}
|
||||
|
||||
async function callCount(client: Client, name: 'read-count' | 'request-count'): Promise<number> {
|
||||
const r = await client.callTool({ name });
|
||||
return Number((r.content[0] as { text: string }).text);
|
||||
}
|
||||
|
||||
const { transport, url, era } = parseExampleArgs();
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'caching-example-client', version: '1.0.0' },
|
||||
{ versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } }
|
||||
);
|
||||
|
||||
await (transport === 'stdio'
|
||||
? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] }))
|
||||
: client.connect(new StreamableHTTPClientTransport(new URL(url))));
|
||||
|
||||
check.equal(client.getNegotiatedProtocolVersion(), '2026-07-28');
|
||||
|
||||
// The server stamps `tools/list` with `ttlMs: 30_000, cacheScope: 'public'`.
|
||||
const tools = (await client.listTools()) as Cacheable & Awaited<ReturnType<typeof client.listTools>>;
|
||||
check.equal(tools.ttlMs, 30_000);
|
||||
check.equal(tools.cacheScope, 'public');
|
||||
// `request-count` proves the wire was reached exactly once.
|
||||
check.equal(await callCount(client, 'request-count'), 1);
|
||||
|
||||
// The second call is served from the response cache: the server-side
|
||||
// `tools/list` counter is unchanged, and the result is a fresh copy of the
|
||||
// held entry (so mutating it cannot reach the cache).
|
||||
const toolsAgain = await client.listTools();
|
||||
check.deepEqual(
|
||||
toolsAgain.tools.map(t => t.name),
|
||||
tools.tools.map(t => t.name)
|
||||
);
|
||||
check.equal(await callCount(client, 'request-count'), 1);
|
||||
|
||||
// `cacheMode: 'refresh'` always fetches and re-stores: the counter moves.
|
||||
await client.listTools(undefined, { cacheMode: 'refresh' });
|
||||
check.equal(await callCount(client, 'request-count'), 2);
|
||||
|
||||
const resources = (await client.listResources()) as Cacheable & Awaited<ReturnType<typeof client.listResources>>;
|
||||
check.equal(resources.ttlMs, 5000);
|
||||
check.equal(resources.cacheScope, 'public');
|
||||
|
||||
// `readResource`: the resource handler counts how many times it ran, and
|
||||
// the `read-count` tool exposes that counter.
|
||||
const read = (await client.readResource({ uri: 'config://app' })) as Cacheable & Awaited<ReturnType<typeof client.readResource>>;
|
||||
check.equal(read.ttlMs, 60_000);
|
||||
check.equal(read.cacheScope, 'private');
|
||||
check.equal(await callCount(client, 'read-count'), 1);
|
||||
|
||||
// Within TTL, default `cacheMode: 'use'` → served from cache; the server
|
||||
// handler does not run.
|
||||
await client.readResource({ uri: 'config://app' });
|
||||
check.equal(await callCount(client, 'read-count'), 1);
|
||||
|
||||
// `cacheMode: 'refresh'` always fetches and re-stores.
|
||||
await client.readResource({ uri: 'config://app' }, { cacheMode: 'refresh' });
|
||||
check.equal(await callCount(client, 'read-count'), 2);
|
||||
|
||||
// After the refresh the entry is fresh again — back to cache-served.
|
||||
await client.readResource({ uri: 'config://app' });
|
||||
check.equal(await callCount(client, 'read-count'), 2);
|
||||
|
||||
await client.close();
|
||||
22
examples/caching/package.json
Normal file
22
examples/caching/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "@mcp-examples/caching",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"server": "tsx server.ts",
|
||||
"client": "tsx client.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mcp-examples/shared": "workspace:*",
|
||||
"@modelcontextprotocol/client": "workspace:*",
|
||||
"@modelcontextprotocol/node": "workspace:*",
|
||||
"@modelcontextprotocol/server": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "catalog:devTools"
|
||||
},
|
||||
"example": {
|
||||
"era": "modern",
|
||||
"//": "cacheHints (ttlMs / cacheScope on cacheable results) are emitted only toward 2026-era clients."
|
||||
}
|
||||
}
|
||||
102
examples/caching/server.ts
Normal file
102
examples/caching/server.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* Cache hints (`CacheableResult`, protocol revision 2026-07-28).
|
||||
*
|
||||
* The 2026-07-28 revision requires `ttlMs`/`cacheScope` on the cacheable
|
||||
* result types (the list operations and `resources/read`). The values are
|
||||
* resolved most-specific-author-first:
|
||||
*
|
||||
* 1. fields the handler returns on the result itself,
|
||||
* 2. a per-registration `cacheHint` (here: the resource's read result),
|
||||
* 3. the server-level per-operation `ServerOptions.cacheHints`,
|
||||
* 4. the conservative defaults (`ttlMs: 0`, `cacheScope: 'private'`).
|
||||
*
|
||||
* The fields are emitted ONLY toward 2026-era clients — a 2025-era response
|
||||
* is byte-for-byte unchanged. One binary, either transport.
|
||||
*/
|
||||
import { createServer } from 'node:http';
|
||||
|
||||
import { parseExampleArgs } from '@mcp-examples/shared';
|
||||
import { toNodeHandler } from '@modelcontextprotocol/node';
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
||||
|
||||
// Module-level (process-wide) counters so the values survive the stateless
|
||||
// HTTP leg (fresh `buildServer()` per request) as well as stdio's single
|
||||
// per-connection instance. The client asserts against these to prove a
|
||||
// cache-served call never reached the server.
|
||||
let readCount = 0;
|
||||
let listCount = 0;
|
||||
|
||||
function buildServer(): McpServer {
|
||||
const server = new McpServer(
|
||||
{ name: 'caching-example', version: '1.0.0' },
|
||||
{
|
||||
// Server-level per-operation hints: any list/read result that does not
|
||||
// override a field gets these.
|
||||
cacheHints: {
|
||||
'resources/list': { ttlMs: 5000, cacheScope: 'public' },
|
||||
'tools/list': { ttlMs: 30_000, cacheScope: 'public' }
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// A direct resource carrying a per-registration hint that wins for its
|
||||
// own resources/read result.
|
||||
server.registerResource(
|
||||
'app-config',
|
||||
'config://app',
|
||||
{
|
||||
mimeType: 'application/json',
|
||||
description: 'Static application config (rarely changes)',
|
||||
cacheHint: { ttlMs: 60_000, cacheScope: 'private' }
|
||||
},
|
||||
async uri => {
|
||||
readCount++;
|
||||
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: '{"feature":true}' }] };
|
||||
}
|
||||
);
|
||||
|
||||
// A tool, so tools/list has something to cache.
|
||||
server.registerTool('noop', { description: 'no-op' }, async () => ({ content: [{ type: 'text', text: 'ok' }] }));
|
||||
|
||||
// Exposes the server-side `resources/read` invocation count so the client
|
||||
// can assert that a cache-served call did not reach the wire.
|
||||
server.registerTool('read-count', { description: 'Number of resources/read calls that reached this server' }, async () => ({
|
||||
content: [{ type: 'text', text: String(readCount) }]
|
||||
}));
|
||||
|
||||
// Exposes the server-side `tools/list` invocation count.
|
||||
server.registerTool('request-count', { description: 'Number of tools/list requests that reached this server' }, async () => ({
|
||||
content: [{ type: 'text', text: String(listCount) }]
|
||||
}));
|
||||
|
||||
// Wrap the auto-generated `tools/list` handler so the example can prove a
|
||||
// cache-served `listTools()` never reached the wire. `McpServer` registers
|
||||
// the handler lazily on the first `registerTool()`; we re-seat it here so
|
||||
// every dispatch increments `listCount` before delegating to the original.
|
||||
// (Reaches the underlying request-handler map directly — there is no public
|
||||
// wrapper hook; acceptable for an instrumentation example.)
|
||||
const handlers = (server.server as unknown as { _requestHandlers: Map<string, (...a: unknown[]) => Promise<unknown>> })
|
||||
._requestHandlers;
|
||||
const original = handlers.get('tools/list');
|
||||
if (original) {
|
||||
handlers.set('tools/list', (...a) => {
|
||||
listCount++;
|
||||
return original(...a);
|
||||
});
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
const { transport, port } = parseExampleArgs();
|
||||
|
||||
if (transport === 'stdio') {
|
||||
void serveStdio(buildServer);
|
||||
console.error('[server] serving over stdio');
|
||||
} else {
|
||||
const handler = createMcpHandler(buildServer);
|
||||
createServer(toNodeHandler(handler)).listen(port, () => {
|
||||
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
# MCP TypeScript SDK Examples (Client)
|
||||
|
||||
This directory contains runnable MCP **client** examples built with `@modelcontextprotocol/client`.
|
||||
|
||||
For server examples, see [`../server/README.md`](../server/README.md). For guided docs, see [`../../docs/client.md`](../../docs/client.md).
|
||||
|
||||
## Running examples
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleStreamableHttp.ts
|
||||
```
|
||||
|
||||
Or, from within this package:
|
||||
|
||||
```bash
|
||||
cd examples/client
|
||||
pnpm tsx src/simpleStreamableHttp.ts
|
||||
```
|
||||
|
||||
Most clients expect a server to be running. Start one from [`../server/README.md`](../server/README.md) (for example `src/simpleStreamableHttp.ts` in `examples/server`).
|
||||
|
||||
## Example index
|
||||
|
||||
| Scenario | Description | File |
|
||||
| --------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Interactive Streamable HTTP client | CLI client that exercises tools/resources/prompts, notifications, and elicitation. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) |
|
||||
| Backwards-compatible client (Streamable HTTP → SSE) | Tries Streamable HTTP first, falls back to legacy SSE on 4xx responses. | [`src/streamableHttpWithSseFallbackClient.ts`](src/streamableHttpWithSseFallbackClient.ts) |
|
||||
| SSE polling client (legacy) | Polls a legacy HTTP+SSE server and demonstrates notification handling. | [`src/ssePollingClient.ts`](src/ssePollingClient.ts) |
|
||||
| Parallel tool calls | Runs multiple tool calls in parallel. | [`src/parallelToolCallsClient.ts`](src/parallelToolCallsClient.ts) |
|
||||
| Multiple clients in parallel | Connects multiple clients concurrently to the same server. | [`src/multipleClientsParallel.ts`](src/multipleClientsParallel.ts) |
|
||||
| OAuth client (interactive) | OAuth-enabled client (dynamic registration, auth flow). | [`src/simpleOAuthClient.ts`](src/simpleOAuthClient.ts) |
|
||||
| OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) |
|
||||
| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) |
|
||||
| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) |
|
||||
|
||||
## URL elicitation example (server + client)
|
||||
|
||||
Run the server first:
|
||||
|
||||
```bash
|
||||
pnpm --filter @modelcontextprotocol/examples-server exec tsx src/elicitationUrlExample.ts
|
||||
```
|
||||
|
||||
Then run the client:
|
||||
|
||||
```bash
|
||||
pnpm --filter @modelcontextprotocol/examples-client exec tsx src/elicitationUrlExample.ts
|
||||
```
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
import baseConfig from '@modelcontextprotocol/eslint-config';
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx,js,jsx,mts,cts}'],
|
||||
rules: {
|
||||
// Allow console statements in examples only
|
||||
'no-console': 'off'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/examples-client",
|
||||
"private": true,
|
||||
"version": "2.0.0-alpha.0",
|
||||
"description": "Model Context Protocol implementation for TypeScript",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
"homepage": "https://modelcontextprotocol.io",
|
||||
"bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"keywords": [
|
||||
"modelcontextprotocol",
|
||||
"mcp"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "tsgo -p tsconfig.json --noEmit",
|
||||
"build": "tsdown",
|
||||
"build:watch": "tsdown --watch",
|
||||
"prepack": "pnpm run build:esm && pnpm run build:cjs",
|
||||
"lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .",
|
||||
"lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .",
|
||||
"check": "pnpm run typecheck && pnpm run lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/client": "workspace:^",
|
||||
"ajv": "catalog:runtimeShared",
|
||||
"open": "^11.0.0",
|
||||
"zod": "catalog:runtimeShared"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/eslint-config": "workspace:^",
|
||||
"@modelcontextprotocol/examples-shared": "workspace:^",
|
||||
"@modelcontextprotocol/tsconfig": "workspace:^",
|
||||
"@modelcontextprotocol/vitest-config": "workspace:^",
|
||||
"tsdown": "catalog:devTools"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/**
|
||||
* Custom (non-spec) method example: a client that sends `acme/search` and
|
||||
* listens for `acme/searchProgress` notifications.
|
||||
*
|
||||
* Build `examples/server` first; this client spawns the server via stdio.
|
||||
*/
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
const SearchResult = z.object({ items: z.array(z.string()) });
|
||||
const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() });
|
||||
|
||||
const client = new Client({ name: 'acme-search-client', version: '0.0.0' });
|
||||
|
||||
client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => {
|
||||
console.log(`[progress] ${params.stage} ${Math.round(params.pct * 100)}%`);
|
||||
});
|
||||
|
||||
await client.connect(new StdioClientTransport({ command: 'node', args: ['../server/dist/customMethodExample.js'] }));
|
||||
|
||||
const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult);
|
||||
console.log('items:', result.items);
|
||||
|
||||
await client.close();
|
||||
|
|
@ -1,824 +0,0 @@
|
|||
// Run with: pnpm tsx src/elicitationUrlExample.ts
|
||||
//
|
||||
// This example demonstrates how to use URL elicitation to securely
|
||||
// collect user input in a remote (HTTP) server.
|
||||
// URL elicitation allows servers to prompt the end-user to open a URL in their browser
|
||||
// to collect sensitive information.
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
import type {
|
||||
ElicitRequest,
|
||||
ElicitRequestURLParams,
|
||||
ElicitResult,
|
||||
ListToolsRequest,
|
||||
OAuthClientMetadata,
|
||||
ResourceLink
|
||||
} from '@modelcontextprotocol/client';
|
||||
import {
|
||||
Client,
|
||||
getDisplayName,
|
||||
ProtocolError,
|
||||
ProtocolErrorCode,
|
||||
StreamableHTTPClientTransport,
|
||||
UnauthorizedError,
|
||||
UrlElicitationRequiredError
|
||||
} from '@modelcontextprotocol/client';
|
||||
import open from 'open';
|
||||
|
||||
import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider';
|
||||
|
||||
// Set up OAuth (required for this example)
|
||||
const OAUTH_CALLBACK_PORT = 8090; // Use different port than auth server (3001)
|
||||
const OAUTH_CALLBACK_URL = `http://localhost:${OAUTH_CALLBACK_PORT}/callback`;
|
||||
|
||||
console.log('Getting OAuth token...');
|
||||
const clientMetadata: OAuthClientMetadata = {
|
||||
client_name: 'Elicitation MCP Client',
|
||||
redirect_uris: [OAUTH_CALLBACK_URL],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'client_secret_post',
|
||||
scope: 'mcp:tools'
|
||||
};
|
||||
const oauthProvider = new InMemoryOAuthClientProvider(OAUTH_CALLBACK_URL, clientMetadata, (redirectUrl: URL) => {
|
||||
console.log(`🌐 Opening browser for OAuth redirect: ${redirectUrl.toString()}`);
|
||||
openBrowser(redirectUrl.toString());
|
||||
});
|
||||
|
||||
// Create readline interface for user input
|
||||
const readline = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
let abortCommand = new AbortController();
|
||||
|
||||
// Global client and transport for interactive commands
|
||||
let client: Client | null = null;
|
||||
let transport: StreamableHTTPClientTransport | null = null;
|
||||
let serverUrl = 'http://localhost:3000/mcp';
|
||||
let sessionId: string | undefined;
|
||||
|
||||
// Elicitation queue management
|
||||
interface QueuedElicitation {
|
||||
request: ElicitRequest;
|
||||
resolve: (result: ElicitResult) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
let isProcessingCommand = false;
|
||||
let isProcessingElicitations = false;
|
||||
const elicitationQueue: QueuedElicitation[] = [];
|
||||
let elicitationQueueSignal: (() => void) | null = null;
|
||||
let elicitationsCompleteSignal: (() => void) | null = null;
|
||||
|
||||
// Map to track pending URL elicitations waiting for completion notifications
|
||||
const pendingURLElicitations = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
>();
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('MCP Interactive Client');
|
||||
console.log('=====================');
|
||||
|
||||
// Connect to server immediately with default settings
|
||||
await connect();
|
||||
|
||||
// Start the elicitation loop in the background
|
||||
elicitationLoop().catch(error => {
|
||||
console.error('Unexpected error in elicitation loop:', error);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Short delay allowing the server to send any SSE elicitations on connection
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Wait until we are done processing any initial elicitations
|
||||
await waitForElicitationsToComplete();
|
||||
|
||||
// Print help and start the command loop
|
||||
printHelp();
|
||||
await commandLoop();
|
||||
}
|
||||
|
||||
async function waitForElicitationsToComplete(): Promise<void> {
|
||||
// Wait until the queue is empty and nothing is being processed
|
||||
while (elicitationQueue.length > 0 || isProcessingElicitations) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log('\nAvailable commands:');
|
||||
console.log(' connect [url] - Connect to MCP server (default: http://localhost:3000/mcp)');
|
||||
console.log(' disconnect - Disconnect from server');
|
||||
console.log(' terminate-session - Terminate the current session');
|
||||
console.log(' reconnect - Reconnect to the server');
|
||||
console.log(' list-tools - List available tools');
|
||||
console.log(' call-tool <name> [args] - Call a tool with optional JSON arguments');
|
||||
console.log(' payment-confirm - Test URL elicitation via error response with payment-confirm tool');
|
||||
console.log(' third-party-auth - Test tool that requires third-party OAuth credentials');
|
||||
console.log(' help - Show this help');
|
||||
console.log(' quit - Exit the program');
|
||||
}
|
||||
|
||||
async function commandLoop(): Promise<void> {
|
||||
await new Promise<void>(resolve => {
|
||||
if (isProcessingElicitations) {
|
||||
elicitationsCompleteSignal = resolve;
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
readline.question('\n> ', { signal: abortCommand.signal }, async input => {
|
||||
isProcessingCommand = true;
|
||||
|
||||
const args = input.trim().split(/\s+/);
|
||||
const command = args[0]?.toLowerCase();
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'connect': {
|
||||
await connect(args[1]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'disconnect': {
|
||||
await disconnect();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'terminate-session': {
|
||||
await terminateSession();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'reconnect': {
|
||||
await reconnect();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'list-tools': {
|
||||
await listTools();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'call-tool': {
|
||||
if (args.length < 2) {
|
||||
console.log('Usage: call-tool <name> [args]');
|
||||
} else {
|
||||
const toolName = args[1]!;
|
||||
let toolArgs = {};
|
||||
if (args.length > 2) {
|
||||
try {
|
||||
toolArgs = JSON.parse(args.slice(2).join(' '));
|
||||
} catch {
|
||||
console.log('Invalid JSON arguments. Using empty args.');
|
||||
}
|
||||
}
|
||||
await callTool(toolName, toolArgs);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'payment-confirm': {
|
||||
await callPaymentConfirmTool();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'third-party-auth': {
|
||||
await callThirdPartyAuthTool();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'help': {
|
||||
printHelp();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'quit':
|
||||
case 'exit': {
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
default: {
|
||||
if (command) {
|
||||
console.log(`Unknown command: ${command}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing command: ${error}`);
|
||||
} finally {
|
||||
isProcessingCommand = false;
|
||||
}
|
||||
|
||||
// Process another command after we've processed the this one
|
||||
await commandLoop();
|
||||
});
|
||||
}
|
||||
|
||||
async function elicitationLoop(): Promise<void> {
|
||||
while (true) {
|
||||
// Wait until we have elicitations to process
|
||||
await new Promise<void>(resolve => {
|
||||
if (elicitationQueue.length > 0) {
|
||||
resolve();
|
||||
} else {
|
||||
elicitationQueueSignal = resolve;
|
||||
}
|
||||
});
|
||||
|
||||
isProcessingElicitations = true;
|
||||
abortCommand.abort(); // Abort the command loop if it's running
|
||||
|
||||
// Process all queued elicitations
|
||||
while (elicitationQueue.length > 0) {
|
||||
const queued = elicitationQueue.shift()!;
|
||||
console.log(`📤 Processing queued elicitation (${elicitationQueue.length} remaining)`);
|
||||
|
||||
try {
|
||||
const result = await handleElicitationRequest(queued.request);
|
||||
queued.resolve(result);
|
||||
} catch (error) {
|
||||
queued.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ All queued elicitations processed. Resuming command loop...\n');
|
||||
isProcessingElicitations = false;
|
||||
|
||||
// Reset the abort controller for the next command loop
|
||||
abortCommand = new AbortController();
|
||||
|
||||
// Resume the command loop
|
||||
if (elicitationsCompleteSignal) {
|
||||
elicitationsCompleteSignal();
|
||||
elicitationsCompleteSignal = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_SCHEMES = new Set(['http:', 'https:']);
|
||||
|
||||
async function openBrowser(url: string): Promise<void> {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!ALLOWED_SCHEMES.has(parsed.protocol)) {
|
||||
console.error(`Refusing to open URL with unsupported scheme '${parsed.protocol}': ${url}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
console.error(`Invalid URL: ${url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await open(url);
|
||||
} catch {
|
||||
console.log(`Please manually open: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues an elicitation request and returns the result.
|
||||
*
|
||||
* This function is used so that our CLI (which can only handle one input request at a time)
|
||||
* can handle elicitation requests and the command loop.
|
||||
*
|
||||
* @param request - The elicitation request to be handled
|
||||
* @returns The elicitation result
|
||||
*/
|
||||
async function elicitationRequestHandler(request: ElicitRequest): Promise<ElicitResult> {
|
||||
// If we are processing a command, handle this elicitation immediately
|
||||
if (isProcessingCommand) {
|
||||
console.log('📋 Processing elicitation immediately (during command execution)');
|
||||
return await handleElicitationRequest(request);
|
||||
}
|
||||
|
||||
// Otherwise, queue the request to be handled by the elicitation loop
|
||||
console.log(`📥 Queueing elicitation request (queue size will be: ${elicitationQueue.length + 1})`);
|
||||
|
||||
return new Promise<ElicitResult>((resolve, reject) => {
|
||||
elicitationQueue.push({
|
||||
request,
|
||||
resolve,
|
||||
reject
|
||||
});
|
||||
|
||||
// Signal the elicitation loop that there's work to do
|
||||
if (elicitationQueueSignal) {
|
||||
elicitationQueueSignal();
|
||||
elicitationQueueSignal = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an elicitation request.
|
||||
*
|
||||
* This function is used to handle the elicitation request and return the result.
|
||||
*
|
||||
* @param request - The elicitation request to be handled
|
||||
* @returns The elicitation result
|
||||
*/
|
||||
async function handleElicitationRequest(request: ElicitRequest): Promise<ElicitResult> {
|
||||
const mode = request.params.mode;
|
||||
console.log('\n🔔 Elicitation Request Received:');
|
||||
console.log(`Mode: ${mode}`);
|
||||
|
||||
if (mode === 'url') {
|
||||
return {
|
||||
action: await handleURLElicitation(request.params as ElicitRequestURLParams)
|
||||
};
|
||||
} else {
|
||||
// Should not happen because the client declares its capabilities to the server,
|
||||
// but being defensive is a good practice:
|
||||
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unsupported elicitation mode: ${mode}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a URL elicitation by opening the URL in the browser.
|
||||
*
|
||||
* Note: This is a shared code for both request handlers and error handlers.
|
||||
* As a result of sharing schema, there is no big forking of logic for the client.
|
||||
*
|
||||
* @param params - The URL elicitation request parameters
|
||||
* @returns The action to take (accept, cancel, or decline)
|
||||
*/
|
||||
async function handleURLElicitation(params: ElicitRequestURLParams): Promise<ElicitResult['action']> {
|
||||
const url = params.url;
|
||||
const elicitationId = params.elicitationId;
|
||||
const message = params.message;
|
||||
console.log(`🆔 Elicitation ID: ${elicitationId}`); // Print for illustration
|
||||
|
||||
// Parse URL to show domain for security
|
||||
let domain = 'unknown domain';
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
domain = parsedUrl.hostname;
|
||||
} catch {
|
||||
console.error('Invalid URL provided by server');
|
||||
return 'decline';
|
||||
}
|
||||
|
||||
// Example security warning to help prevent phishing attacks
|
||||
console.log('\n⚠️ \u001B[33mSECURITY WARNING\u001B[0m ⚠️');
|
||||
console.log('\u001B[33mThe server is requesting you to open an external URL.\u001B[0m');
|
||||
console.log('\u001B[33mOnly proceed if you trust this server and understand why it needs this.\u001B[0m\n');
|
||||
console.log(`🌐 Target domain: \u001B[36m${domain}\u001B[0m`);
|
||||
console.log(`🔗 Full URL: \u001B[36m${url}\u001B[0m`);
|
||||
console.log(`\nℹ️ Server's reason:\n\n\u001B[36m${message}\u001B[0m\n`);
|
||||
|
||||
// 1. Ask for user consent to open the URL
|
||||
const consent = await new Promise<string>(resolve => {
|
||||
readline.question('\nDo you want to open this URL in your browser? (y/n): ', input => {
|
||||
resolve(input.trim().toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
// 2. If user did not consent, return appropriate result
|
||||
if (consent === 'no' || consent === 'n') {
|
||||
console.log('❌ URL navigation declined.');
|
||||
return 'decline';
|
||||
} else if (consent !== 'yes' && consent !== 'y') {
|
||||
console.log('🚫 Invalid response. Cancelling elicitation.');
|
||||
return 'cancel';
|
||||
}
|
||||
|
||||
// 3. Wait for completion notification in the background
|
||||
const completionPromise = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
pendingURLElicitations.delete(elicitationId);
|
||||
console.log(`\u001B[31m❌ Elicitation ${elicitationId} timed out waiting for completion.\u001B[0m`);
|
||||
reject(new Error('Elicitation completion timeout'));
|
||||
},
|
||||
5 * 60 * 1000
|
||||
); // 5 minute timeout
|
||||
|
||||
pendingURLElicitations.set(elicitationId, {
|
||||
resolve: () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
},
|
||||
reject,
|
||||
timeout
|
||||
});
|
||||
});
|
||||
|
||||
completionPromise.catch(error => {
|
||||
console.error('Background completion wait failed:', error);
|
||||
});
|
||||
|
||||
// 4. Open the URL in the browser
|
||||
console.log(`\n🚀 Opening browser to: ${url}`);
|
||||
await openBrowser(url);
|
||||
|
||||
console.log('\n⏳ Waiting for you to complete the interaction in your browser...');
|
||||
console.log(' The server will send a notification once you complete the action.');
|
||||
|
||||
// 5. Acknowledge the user accepted the elicitation
|
||||
return 'accept';
|
||||
}
|
||||
|
||||
/**
|
||||
* Example OAuth callback handler - in production, use a more robust approach
|
||||
* for handling callbacks and storing tokens
|
||||
*/
|
||||
/**
|
||||
* Starts a temporary HTTP server to receive the OAuth callback
|
||||
*/
|
||||
async function waitForOAuthCallback(): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
// Ignore favicon requests
|
||||
if (req.url === '/favicon.ico') {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📥 Received callback: ${req.url}`);
|
||||
const parsedUrl = new URL(req.url || '', 'http://localhost');
|
||||
const code = parsedUrl.searchParams.get('code');
|
||||
const error = parsedUrl.searchParams.get('error');
|
||||
|
||||
if (code) {
|
||||
console.log(`✅ Authorization code received: ${code?.slice(0, 10)}...`);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Authorization Successful!</h1>
|
||||
<p>This simulates successful authorization of the MCP client, which now has an access token for the MCP server.</p>
|
||||
<p>This window will close automatically in 10 seconds.</p>
|
||||
<script>setTimeout(() => window.close(), 10000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
resolve(code);
|
||||
setTimeout(() => server.close(), 15_000);
|
||||
} else if (error) {
|
||||
console.log(`❌ Authorization error: ${error}`);
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>Error: ${error}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
reject(new Error(`OAuth authorization failed: ${error}`));
|
||||
} else {
|
||||
console.log(`❌ No authorization code or error in callback`);
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
reject(new Error('No authorization code provided'));
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(OAUTH_CALLBACK_PORT, () => {
|
||||
console.log(`OAuth callback server started on http://localhost:${OAUTH_CALLBACK_PORT}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to connect to the MCP server with OAuth authentication.
|
||||
* Handles OAuth flow recursively if authorization is required.
|
||||
*/
|
||||
async function attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Promise<void> {
|
||||
console.log('🚢 Creating transport with OAuth provider...');
|
||||
const baseUrl = new URL(serverUrl);
|
||||
transport = new StreamableHTTPClientTransport(baseUrl, {
|
||||
sessionId: sessionId,
|
||||
authProvider: oauthProvider
|
||||
});
|
||||
console.log('🚢 Transport created');
|
||||
|
||||
try {
|
||||
console.log('🔌 Attempting connection (this will trigger OAuth redirect if needed)...');
|
||||
await client!.connect(transport);
|
||||
sessionId = transport.sessionId;
|
||||
console.log('Transport created with session ID:', sessionId);
|
||||
console.log('✅ Connected successfully');
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
console.log('🔐 OAuth required - waiting for authorization...');
|
||||
const callbackPromise = waitForOAuthCallback();
|
||||
const authCode = await callbackPromise;
|
||||
await transport.finishAuth(authCode);
|
||||
console.log('🔐 Authorization code received:', authCode);
|
||||
console.log('🔌 Reconnecting with authenticated transport...');
|
||||
// Recursively retry connection after OAuth completion
|
||||
await attemptConnection(oauthProvider);
|
||||
} else {
|
||||
console.error('❌ Connection failed with non-auth error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(url?: string): Promise<void> {
|
||||
if (client) {
|
||||
console.log('Already connected. Disconnect first.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (url) {
|
||||
serverUrl = url;
|
||||
}
|
||||
|
||||
console.log(`🔗 Attempting to connect to ${serverUrl}...`);
|
||||
|
||||
// Create a new client with elicitation capability
|
||||
console.log('👤 Creating MCP client...');
|
||||
client = new Client(
|
||||
{
|
||||
name: 'example-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: {
|
||||
// Only URL elicitation is supported in this demo
|
||||
// (see server/elicitationExample.ts for a demo of form mode elicitation)
|
||||
url: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
console.log('👤 Client created');
|
||||
|
||||
// Set up elicitation request handler with proper validation
|
||||
client.setRequestHandler('elicitation/create', elicitationRequestHandler);
|
||||
|
||||
// Set up notification handler for elicitation completion
|
||||
client.setNotificationHandler('notifications/elicitation/complete', notification => {
|
||||
const { elicitationId } = notification.params;
|
||||
const pending = pendingURLElicitations.get(elicitationId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
pendingURLElicitations.delete(elicitationId);
|
||||
console.log(`\u001B[32m✅ Elicitation ${elicitationId} completed!\u001B[0m`);
|
||||
pending.resolve();
|
||||
} else {
|
||||
// Shouldn't happen - discard it!
|
||||
console.warn(`Received completion notification for unknown elicitation: ${elicitationId}`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
console.log('🔐 Starting OAuth flow...');
|
||||
await attemptConnection(oauthProvider!);
|
||||
console.log('Connected to MCP server');
|
||||
|
||||
// Set up error handler after connection is established so we don't double log errors
|
||||
client.onerror = error => {
|
||||
console.error('\u001B[31mClient error:', error, '\u001B[0m');
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to connect:', error);
|
||||
client = null;
|
||||
transport = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect(): Promise<void> {
|
||||
if (!client || !transport) {
|
||||
console.log('Not connected.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await transport.close();
|
||||
console.log('Disconnected from MCP server');
|
||||
client = null;
|
||||
transport = null;
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function terminateSession(): Promise<void> {
|
||||
if (!client || !transport) {
|
||||
console.log('Not connected.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Terminating session with ID:', transport.sessionId);
|
||||
await transport.terminateSession();
|
||||
console.log('Session terminated successfully');
|
||||
|
||||
// Check if sessionId was cleared after termination
|
||||
if (transport.sessionId) {
|
||||
console.log('Server responded with 405 Method Not Allowed (session termination not supported)');
|
||||
console.log('Session ID is still active:', transport.sessionId);
|
||||
} else {
|
||||
console.log('Session ID has been cleared');
|
||||
sessionId = undefined;
|
||||
|
||||
// Also close the transport and clear client objects
|
||||
await transport.close();
|
||||
console.log('Transport closed after session termination');
|
||||
client = null;
|
||||
transport = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error terminating session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function reconnect(): Promise<void> {
|
||||
if (client) {
|
||||
await disconnect();
|
||||
}
|
||||
await connect();
|
||||
}
|
||||
|
||||
async function listTools(): Promise<void> {
|
||||
if (!client) {
|
||||
console.log('Not connected to server.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const toolsRequest: ListToolsRequest = {
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
const toolsResult = await client.request(toolsRequest);
|
||||
|
||||
console.log('Available tools:');
|
||||
if (toolsResult.tools.length === 0) {
|
||||
console.log(' No tools available');
|
||||
} else {
|
||||
for (const tool of toolsResult.tools) {
|
||||
console.log(` - id: ${tool.name}, name: ${getDisplayName(tool)}, description: ${tool.description}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Tools not supported by this server (${error})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function callTool(name: string, args: Record<string, unknown>): Promise<void> {
|
||||
if (!client) {
|
||||
console.log('Not connected to server.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Calling tool '${name}' with args:`, args);
|
||||
const result = await client.callTool({ name, arguments: args });
|
||||
|
||||
console.log('Tool result:');
|
||||
const resourceLinks: ResourceLink[] = [];
|
||||
|
||||
for (const item of result.content) {
|
||||
switch (item.type) {
|
||||
case 'text': {
|
||||
console.log(` ${item.text}`);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'resource_link': {
|
||||
const resourceLink = item as ResourceLink;
|
||||
resourceLinks.push(resourceLink);
|
||||
console.log(` 📁 Resource Link: ${resourceLink.name}`);
|
||||
console.log(` URI: ${resourceLink.uri}`);
|
||||
if (resourceLink.mimeType) {
|
||||
console.log(` Type: ${resourceLink.mimeType}`);
|
||||
}
|
||||
if (resourceLink.description) {
|
||||
console.log(` Description: ${resourceLink.description}`);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'resource': {
|
||||
console.log(` [Embedded Resource: ${item.resource.uri}]`);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'image': {
|
||||
console.log(` [Image: ${item.mimeType}]`);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'audio': {
|
||||
console.log(` [Audio: ${item.mimeType}]`);
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.log(` [Unknown content type]:`, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Offer to read resource links
|
||||
if (resourceLinks.length > 0) {
|
||||
console.log(`\nFound ${resourceLinks.length} resource link(s). Use 'read-resource <uri>' to read their content.`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof UrlElicitationRequiredError) {
|
||||
console.log('\n🔔 Elicitation Required Error Received:');
|
||||
console.log(`Message: ${error.message}`);
|
||||
for (const e of error.elicitations) {
|
||||
await handleURLElicitation(e); // For the error handler, we discard the action result because we don't respond to an error response
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.log(`Error calling tool ${name}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
if (client && transport) {
|
||||
try {
|
||||
// First try to terminate the session gracefully
|
||||
if (transport.sessionId) {
|
||||
try {
|
||||
console.log('Terminating session before exit...');
|
||||
await transport.terminateSession();
|
||||
console.log('Session terminated successfully');
|
||||
} catch (error) {
|
||||
console.error('Error terminating session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Then close the transport
|
||||
await transport.close();
|
||||
} catch (error) {
|
||||
console.error('Error closing transport:', error);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.setRawMode(false);
|
||||
readline.close();
|
||||
console.log('\nGoodbye!');
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function callPaymentConfirmTool(): Promise<void> {
|
||||
console.log('Calling payment-confirm tool...');
|
||||
await callTool('payment-confirm', { cartId: 'cart_123' });
|
||||
}
|
||||
|
||||
async function callThirdPartyAuthTool(): Promise<void> {
|
||||
console.log('Calling third-party-auth tool...');
|
||||
await callTool('third-party-auth', { param1: 'test' });
|
||||
}
|
||||
|
||||
// Set up raw mode for keyboard input to capture Escape key
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.on('data', async data => {
|
||||
// Check for Escape key (27)
|
||||
if (data.length === 1 && data[0] === 27) {
|
||||
console.log('\nESC key pressed. Disconnecting from server...');
|
||||
|
||||
// Abort current operation and disconnect from server
|
||||
if (client && transport) {
|
||||
await disconnect();
|
||||
console.log('Disconnected. Press Enter to continue.');
|
||||
} else {
|
||||
console.log('Not connected to server.');
|
||||
}
|
||||
|
||||
// Re-display the prompt
|
||||
process.stdout.write('> ');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Ctrl+C
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\nReceived SIGINT. Cleaning up...');
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
// Start the interactive client
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error('Error running MCP client:', error);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import type { CallToolResult } from '@modelcontextprotocol/client';
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
/**
|
||||
* Multiple Clients MCP Example
|
||||
*
|
||||
* This client demonstrates how to:
|
||||
* 1. Create multiple MCP clients in parallel
|
||||
* 2. Each client calls a single tool
|
||||
* 3. Track notifications from each client independently
|
||||
*/
|
||||
|
||||
// Command line args processing
|
||||
const args = process.argv.slice(2);
|
||||
const serverUrl = args[0] || 'http://localhost:3000/mcp';
|
||||
|
||||
interface ClientConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
toolName: string;
|
||||
toolArguments: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
async function createAndRunClient(config: ClientConfig): Promise<{ id: string; result: CallToolResult }> {
|
||||
console.log(`[${config.id}] Creating client: ${config.name}`);
|
||||
|
||||
const client = new Client({
|
||||
name: config.name,
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
|
||||
// Set up client-specific error handler
|
||||
client.onerror = error => {
|
||||
console.error(`[${config.id}] Client error:`, error);
|
||||
};
|
||||
|
||||
// Set up client-specific notification handler
|
||||
client.setNotificationHandler('notifications/message', notification => {
|
||||
console.log(`[${config.id}] Notification: ${notification.params.data}`);
|
||||
});
|
||||
|
||||
try {
|
||||
// Connect to the server
|
||||
await client.connect(transport);
|
||||
console.log(`[${config.id}] Connected to MCP server`);
|
||||
|
||||
// Call the specified tool
|
||||
console.log(`[${config.id}] Calling tool: ${config.toolName}`);
|
||||
const result = await client.callTool({
|
||||
name: config.toolName,
|
||||
arguments: {
|
||||
...config.toolArguments,
|
||||
// Add client ID to arguments for identification in notifications
|
||||
caller: config.id
|
||||
}
|
||||
});
|
||||
console.log(`[${config.id}] Tool call completed`);
|
||||
|
||||
// Keep the connection open for a bit to receive notifications
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
// Disconnect
|
||||
await transport.close();
|
||||
console.log(`[${config.id}] Disconnected from MCP server`);
|
||||
|
||||
return { id: config.id, result };
|
||||
} catch (error) {
|
||||
console.error(`[${config.id}] Error:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('MCP Multiple Clients Example');
|
||||
console.log('============================');
|
||||
console.log(`Server URL: ${serverUrl}`);
|
||||
console.log('');
|
||||
|
||||
try {
|
||||
// Define client configurations
|
||||
const clientConfigs: ClientConfig[] = [
|
||||
{
|
||||
id: 'client1',
|
||||
name: 'basic-client-1',
|
||||
toolName: 'start-notification-stream',
|
||||
toolArguments: {
|
||||
interval: 3, // 1 second between notifications
|
||||
count: 5 // Send 5 notifications
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'client2',
|
||||
name: 'basic-client-2',
|
||||
toolName: 'start-notification-stream',
|
||||
toolArguments: {
|
||||
interval: 2, // 2 seconds between notifications
|
||||
count: 3 // Send 3 notifications
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'client3',
|
||||
name: 'basic-client-3',
|
||||
toolName: 'start-notification-stream',
|
||||
toolArguments: {
|
||||
interval: 1, // 0.5 second between notifications
|
||||
count: 8 // Send 8 notifications
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Start all clients in parallel
|
||||
console.log(`Starting ${clientConfigs.length} clients in parallel...`);
|
||||
console.log('');
|
||||
|
||||
const clientPromises = clientConfigs.map(config => createAndRunClient(config));
|
||||
const results = await Promise.all(clientPromises);
|
||||
|
||||
// Display results from all clients
|
||||
console.log('\n=== Final Results ===');
|
||||
for (const { id, result } of results) {
|
||||
console.log(`\n[${id}] Tool result:`);
|
||||
if (Array.isArray(result.content)) {
|
||||
for (const item of result.content) {
|
||||
if (item.type === 'text' && item.text) {
|
||||
console.log(` ${item.text}`);
|
||||
} else {
|
||||
console.log(` ${item.type} content:`, item);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(` Unexpected result format:`, result);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== All clients completed successfully ===');
|
||||
} catch (error) {
|
||||
console.error('Error running multiple clients:', error);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the example
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error('Error running multiple clients:', error);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
import type { CallToolResult, ListToolsRequest } from '@modelcontextprotocol/client';
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
/**
|
||||
* Parallel Tool Calls MCP Client
|
||||
*
|
||||
* This client demonstrates how to:
|
||||
* 1. Start multiple tool calls in parallel
|
||||
* 2. Track notifications from each tool call using a caller parameter
|
||||
*/
|
||||
|
||||
// Command line args processing
|
||||
const args = process.argv.slice(2);
|
||||
const serverUrl = args[0] || 'http://localhost:3000/mcp';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('MCP Parallel Tool Calls Client');
|
||||
console.log('==============================');
|
||||
console.log(`Connecting to server at: ${serverUrl}`);
|
||||
|
||||
let client: Client;
|
||||
let transport: StreamableHTTPClientTransport;
|
||||
|
||||
try {
|
||||
// Create client with streamable HTTP transport
|
||||
client = new Client({
|
||||
name: 'parallel-tool-calls-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
client.onerror = error => {
|
||||
console.error('Client error:', error);
|
||||
};
|
||||
|
||||
// Connect to the server
|
||||
transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
await client.connect(transport);
|
||||
console.log('Successfully connected to MCP server');
|
||||
|
||||
// Set up notification handler with caller identification
|
||||
client.setNotificationHandler('notifications/message', notification => {
|
||||
console.log(`Notification: ${notification.params.data}`);
|
||||
});
|
||||
|
||||
console.log('List tools');
|
||||
const toolsRequest = await listTools(client);
|
||||
console.log('Tools:', toolsRequest);
|
||||
|
||||
// 2. Start multiple notification tools in parallel
|
||||
console.log('\n=== Starting Multiple Notification Streams in Parallel ===');
|
||||
const toolResults = await startParallelNotificationTools(client);
|
||||
|
||||
// Log the results from each tool call
|
||||
for (const [caller, result] of Object.entries(toolResults)) {
|
||||
console.log(`\n=== Tool result for ${caller} ===`);
|
||||
for (const item of result.content) {
|
||||
if (item.type === 'text') {
|
||||
console.log(` ${item.text}`);
|
||||
} else {
|
||||
console.log(` ${item.type} content:`, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Wait for all notifications (10 seconds)
|
||||
console.log('\n=== Waiting for all notifications ===');
|
||||
await new Promise(resolve => setTimeout(resolve, 10_000));
|
||||
|
||||
// 4. Disconnect
|
||||
console.log('\n=== Disconnecting ===');
|
||||
await transport.close();
|
||||
console.log('Disconnected from MCP server');
|
||||
} catch (error) {
|
||||
console.error('Error running client:', error);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List available tools on the server
|
||||
*/
|
||||
async function listTools(client: Client): Promise<void> {
|
||||
try {
|
||||
const toolsRequest: ListToolsRequest = {
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
const toolsResult = await client.request(toolsRequest);
|
||||
|
||||
console.log('Available tools:');
|
||||
if (toolsResult.tools.length === 0) {
|
||||
console.log(' No tools available');
|
||||
} else {
|
||||
for (const tool of toolsResult.tools) {
|
||||
console.log(` - ${tool.name}: ${tool.description}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Tools not supported by this server: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start multiple notification tools in parallel with different configurations
|
||||
* Each tool call includes a caller parameter to identify its notifications
|
||||
*/
|
||||
async function startParallelNotificationTools(client: Client): Promise<Record<string, CallToolResult>> {
|
||||
try {
|
||||
// Define multiple tool calls with different configurations
|
||||
const toolCalls = [
|
||||
{
|
||||
caller: 'fast-notifier',
|
||||
args: {
|
||||
interval: 2, // 0.5 second between notifications
|
||||
count: 10, // Send 10 notifications
|
||||
caller: 'fast-notifier' // Identify this tool call
|
||||
}
|
||||
},
|
||||
{
|
||||
caller: 'slow-notifier',
|
||||
args: {
|
||||
interval: 5, // 2 seconds between notifications
|
||||
count: 5, // Send 5 notifications
|
||||
caller: 'slow-notifier' // Identify this tool call
|
||||
}
|
||||
},
|
||||
{
|
||||
caller: 'burst-notifier',
|
||||
args: {
|
||||
interval: 1, // 0.1 second between notifications
|
||||
count: 3, // Send just 3 notifications
|
||||
caller: 'burst-notifier' // Identify this tool call
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
console.log(`Starting ${toolCalls.length} notification tools in parallel...`);
|
||||
|
||||
// Start all tool calls in parallel
|
||||
const toolPromises = toolCalls.map(({ caller, args }) => {
|
||||
console.log(`Starting tool call for ${caller}...`);
|
||||
return client
|
||||
.callTool({ name: 'start-notification-stream', arguments: args })
|
||||
.then(result => ({ caller, result }))
|
||||
.catch(error => {
|
||||
console.error(`Error in tool call for ${caller}:`, error);
|
||||
throw error;
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for all tool calls to complete
|
||||
const results = await Promise.all(toolPromises);
|
||||
|
||||
// Organize results by caller
|
||||
const resultsByTool: Record<string, CallToolResult> = {};
|
||||
for (const { caller, result } of results) {
|
||||
resultsByTool[caller] = result;
|
||||
}
|
||||
|
||||
return resultsByTool;
|
||||
} catch (error) {
|
||||
console.error(`Error starting parallel notification tools:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Run the client
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error('Error running client:', error);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Example demonstrating client_credentials grant for machine-to-machine authentication.
|
||||
*
|
||||
* Supports two authentication methods based on environment variables:
|
||||
*
|
||||
* 1. client_secret_basic (default):
|
||||
* MCP_CLIENT_ID - OAuth client ID (required)
|
||||
* MCP_CLIENT_SECRET - OAuth client secret (required)
|
||||
*
|
||||
* 2. private_key_jwt (when MCP_CLIENT_PRIVATE_KEY_PEM is set):
|
||||
* MCP_CLIENT_ID - OAuth client ID (required)
|
||||
* MCP_CLIENT_PRIVATE_KEY_PEM - PEM-encoded private key for JWT signing (required)
|
||||
* MCP_CLIENT_ALGORITHM - Signing algorithm (default: RS256)
|
||||
*
|
||||
* Common:
|
||||
* MCP_SERVER_URL - Server URL (default: http://localhost:3000/mcp)
|
||||
*/
|
||||
|
||||
import type { OAuthClientProvider } from '@modelcontextprotocol/client';
|
||||
import { Client, ClientCredentialsProvider, PrivateKeyJwtProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp';
|
||||
|
||||
function createProvider(): OAuthClientProvider {
|
||||
const clientId = process.env.MCP_CLIENT_ID;
|
||||
if (!clientId) {
|
||||
console.error('MCP_CLIENT_ID environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// If private key is provided, use private_key_jwt authentication
|
||||
const privateKeyPem = process.env.MCP_CLIENT_PRIVATE_KEY_PEM;
|
||||
if (privateKeyPem) {
|
||||
const algorithm = process.env.MCP_CLIENT_ALGORITHM || 'RS256';
|
||||
console.log('Using private_key_jwt authentication');
|
||||
return new PrivateKeyJwtProvider({
|
||||
clientId,
|
||||
privateKey: privateKeyPem,
|
||||
algorithm
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise, use client_secret_basic authentication
|
||||
const clientSecret = process.env.MCP_CLIENT_SECRET;
|
||||
if (!clientSecret) {
|
||||
console.error('MCP_CLIENT_SECRET or MCP_CLIENT_PRIVATE_KEY_PEM environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Using client_secret_basic authentication');
|
||||
return new ClientCredentialsProvider({
|
||||
clientId,
|
||||
clientSecret
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const provider = createProvider();
|
||||
|
||||
const client = new Client({ name: 'client-credentials-example', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(DEFAULT_SERVER_URL), {
|
||||
authProvider: provider
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
console.log('Connected successfully.');
|
||||
|
||||
const tools = await client.listTools();
|
||||
console.log('Available tools:', tools.tools.map(t => t.name).join(', ') || '(none)');
|
||||
|
||||
await transport.close();
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error('Error running client:', error);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
/**
|
||||
* SSE Polling Example Client (SEP-1699)
|
||||
*
|
||||
* This example demonstrates client-side behavior during server-initiated
|
||||
* SSE stream disconnection and automatic reconnection.
|
||||
*
|
||||
* Key features demonstrated:
|
||||
* - Automatic reconnection when server closes SSE stream
|
||||
* - Event replay via Last-Event-ID header
|
||||
* - Resumption token tracking via onresumptiontoken callback
|
||||
*
|
||||
* Run with: pnpm tsx src/ssePollingClient.ts
|
||||
* Requires: ssePollingExample.ts server running on port 3001
|
||||
*/
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const SERVER_URL = 'http://localhost:3001/mcp';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('SSE Polling Example Client');
|
||||
console.log('==========================');
|
||||
console.log(`Connecting to ${SERVER_URL}...`);
|
||||
console.log('');
|
||||
|
||||
// Create transport with reconnection options
|
||||
const transport = new StreamableHTTPClientTransport(new URL(SERVER_URL), {
|
||||
// Use default reconnection options - SDK handles automatic reconnection
|
||||
});
|
||||
|
||||
// Track the last event ID for debugging
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
// Set up transport error handler to observe disconnections
|
||||
// Filter out expected errors from SSE reconnection
|
||||
transport.onerror = error => {
|
||||
// Skip abort errors during intentional close
|
||||
if (error.message.includes('AbortError')) return;
|
||||
// Show SSE disconnect (expected when server closes stream)
|
||||
if (error.message.includes('Unexpected end of JSON')) {
|
||||
console.log('[Transport] SSE stream disconnected - client will auto-reconnect');
|
||||
return;
|
||||
}
|
||||
console.log(`[Transport] Error: ${error.message}`);
|
||||
};
|
||||
|
||||
// Set up transport close handler
|
||||
transport.onclose = () => {
|
||||
console.log('[Transport] Connection closed');
|
||||
};
|
||||
|
||||
// Create and connect client
|
||||
const client = new Client({
|
||||
name: 'sse-polling-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// Set up notification handler to receive progress updates
|
||||
client.setNotificationHandler('notifications/message', notification => {
|
||||
const data = notification.params.data;
|
||||
console.log(`[Notification] ${data}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
console.log('[Client] Connected successfully');
|
||||
console.log('');
|
||||
|
||||
// Call the long-operation tool
|
||||
console.log('[Client] Calling long-operation tool...');
|
||||
console.log('[Client] Server will disconnect mid-operation to demonstrate polling');
|
||||
console.log('');
|
||||
|
||||
const result = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'long-operation',
|
||||
arguments: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
// Track resumption tokens for debugging
|
||||
onresumptiontoken: token => {
|
||||
lastEventId = token;
|
||||
console.log(`[Event ID] ${token}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log('');
|
||||
console.log('[Client] Tool completed!');
|
||||
console.log(`[Result] ${JSON.stringify(result.content, null, 2)}`);
|
||||
console.log('');
|
||||
console.log(`[Debug] Final event ID: ${lastEventId}`);
|
||||
} catch (error) {
|
||||
console.error('[Error]', error);
|
||||
} finally {
|
||||
await transport.close();
|
||||
console.log('[Client] Disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error('Error running MCP client:', error);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
import type { ListToolsRequest } from '@modelcontextprotocol/client';
|
||||
import { Client, SSEClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
/**
|
||||
* Simplified Backwards Compatible MCP Client
|
||||
*
|
||||
* This client demonstrates backward compatibility with both:
|
||||
* 1. Modern servers using Streamable HTTP transport (protocol version 2025-03-26)
|
||||
* 2. Older servers using HTTP+SSE transport (protocol version 2024-11-05)
|
||||
*
|
||||
* Following the MCP specification for backwards compatibility:
|
||||
* - Attempts to POST an initialize request to the server URL first (modern transport)
|
||||
* - If that fails with 4xx status, falls back to GET request for SSE stream (older transport)
|
||||
*/
|
||||
|
||||
// Command line args processing
|
||||
const args = process.argv.slice(2);
|
||||
const serverUrl = args[0] || 'http://localhost:3000/mcp';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('MCP Backwards Compatible Client');
|
||||
console.log('===============================');
|
||||
console.log(`Connecting to server at: ${serverUrl}`);
|
||||
|
||||
let client: Client;
|
||||
let transport: StreamableHTTPClientTransport | SSEClientTransport;
|
||||
|
||||
try {
|
||||
// Try connecting with automatic transport detection
|
||||
const connection = await connectWithBackwardsCompatibility(serverUrl);
|
||||
client = connection.client;
|
||||
transport = connection.transport;
|
||||
|
||||
// Set up notification handler
|
||||
client.setNotificationHandler('notifications/message', notification => {
|
||||
console.log(`Notification: ${notification.params.level} - ${notification.params.data}`);
|
||||
});
|
||||
|
||||
// DEMO WORKFLOW:
|
||||
// 1. List available tools
|
||||
console.log('\n=== Listing Available Tools ===');
|
||||
await listTools(client);
|
||||
|
||||
// 2. Call the notification tool
|
||||
console.log('\n=== Starting Notification Stream ===');
|
||||
await startNotificationTool(client);
|
||||
|
||||
// 3. Wait for all notifications (5 seconds)
|
||||
console.log('\n=== Waiting for all notifications ===');
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
// 4. Disconnect
|
||||
console.log('\n=== Disconnecting ===');
|
||||
await transport.close();
|
||||
console.log('Disconnected from MCP server');
|
||||
} catch (error) {
|
||||
console.error('Error running client:', error);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to an MCP server with backwards compatibility
|
||||
* Following the spec for client backward compatibility
|
||||
*/
|
||||
async function connectWithBackwardsCompatibility(url: string): Promise<{
|
||||
client: Client;
|
||||
transport: StreamableHTTPClientTransport | SSEClientTransport;
|
||||
transportType: 'streamable-http' | 'sse';
|
||||
}> {
|
||||
console.log('1. Trying Streamable HTTP transport first...');
|
||||
|
||||
// Step 1: Try Streamable HTTP transport first
|
||||
const client = new Client({
|
||||
name: 'backwards-compatible-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
client.onerror = error => {
|
||||
console.error('Client error:', error);
|
||||
};
|
||||
const baseUrl = new URL(url);
|
||||
|
||||
try {
|
||||
// Create modern transport
|
||||
const streamableTransport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(streamableTransport);
|
||||
|
||||
console.log('Successfully connected using modern Streamable HTTP transport.');
|
||||
return {
|
||||
client,
|
||||
transport: streamableTransport,
|
||||
transportType: 'streamable-http'
|
||||
};
|
||||
} catch (error) {
|
||||
// Step 2: If transport fails, try the older SSE transport
|
||||
console.log(`StreamableHttp transport connection failed: ${error}`);
|
||||
console.log('2. Falling back to deprecated HTTP+SSE transport...');
|
||||
|
||||
try {
|
||||
// Create SSE transport pointing to /sse endpoint
|
||||
const sseTransport = new SSEClientTransport(baseUrl);
|
||||
const sseClient = new Client({
|
||||
name: 'backwards-compatible-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
await sseClient.connect(sseTransport);
|
||||
|
||||
console.log('Successfully connected using deprecated HTTP+SSE transport.');
|
||||
return {
|
||||
client: sseClient,
|
||||
transport: sseTransport,
|
||||
transportType: 'sse'
|
||||
};
|
||||
} catch (sseError) {
|
||||
console.error(`Failed to connect with either transport method:\n1. Streamable HTTP error: ${error}\n2. SSE error: ${sseError}`);
|
||||
throw new Error('Could not connect to server with any available transport');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List available tools on the server
|
||||
*/
|
||||
async function listTools(client: Client): Promise<void> {
|
||||
try {
|
||||
const toolsRequest: ListToolsRequest = {
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
const toolsResult = await client.request(toolsRequest);
|
||||
|
||||
console.log('Available tools:');
|
||||
if (toolsResult.tools.length === 0) {
|
||||
console.log(' No tools available');
|
||||
} else {
|
||||
for (const tool of toolsResult.tools) {
|
||||
console.log(` - ${tool.name}: ${tool.description}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Tools not supported by this server: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a notification stream by calling the notification tool
|
||||
*/
|
||||
async function startNotificationTool(client: Client): Promise<void> {
|
||||
try {
|
||||
console.log('Calling notification tool...');
|
||||
const result = await client.callTool({
|
||||
name: 'start-notification-stream',
|
||||
arguments: {
|
||||
interval: 1000, // 1 second between notifications
|
||||
count: 5 // Send 5 notifications
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Tool result:');
|
||||
for (const item of result.content) {
|
||||
if (item.type === 'text') {
|
||||
console.log(` ${item.text}`);
|
||||
} else {
|
||||
console.log(` ${item.type} content:`, item);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error calling notification tool: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the client
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error('Error running MCP client:', error);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue