run prettier (#995)

This commit is contained in:
Inna Harper 2025-10-02 18:21:00 +01:00 • committed by GitHub
commit 1150b1d843
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
101 changed files with 32176 additions and 33684 deletions

View file

@ -1,51 +1,51 @@
on:
push:
branches:
- main
pull_request:
release:
types: [published]
push:
branches:
- main
pull_request:
release:
types: [published]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- run: npm ci
- run: npm run build
- run: npm test
- run: npm run lint
- run: npm ci
- run: npm run build
- run: npm test
- run: npm run lint
publish:
runs-on: ubuntu-latest
if: github.event_name == 'release'
environment: release
needs: build
publish:
runs-on: ubuntu-latest
if: github.event_name == 'release'
environment: release
needs: build
permissions:
contents: read
id-token: write
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
registry-url: 'https://registry.npmjs.org'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm ci
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View file

@ -17,4 +17,4 @@
}
}
]
}
}

View file

@ -2,127 +2,82 @@
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
Examples of behavior that contributes to a positive environment for our community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account,
or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
<mcp-coc@anthropic.com>.
All complaints will be reviewed and investigated promptly and fairly.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at <mcp-coc@anthropic.com>. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as
well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is
allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
<https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>.
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at <https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
<https://www.contributor-covenant.org/faq>. Translations are available at
<https://www.contributor-covenant.org/translations>.
For answers to common questions about this code of conduct, see the FAQ at <https://www.contributor-covenant.org/faq>. Translations are available at <https://www.contributor-covenant.org/translations>.

1498
README.md

File diff suppressed because it is too large Load diff

View file

@ -9,19 +9,17 @@ export default tseslint.config(
...tseslint.configs.recommended,
{
linterOptions: {
reportUnusedDisableDirectives: false,
reportUnusedDisableDirectives: false
},
rules: {
"@typescript-eslint/no-unused-vars": ["error",
{ "argsIgnorePattern": "^_" }
]
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }]
}
},
{
files: ["src/client/**/*.ts", "src/server/**/*.ts"],
ignores: ["**/*.test.ts"],
files: ['src/client/**/*.ts', 'src/server/**/*.ts'],
ignores: ['**/*.test.ts'],
rules: {
"no-console": "error"
'no-console': 'error'
}
},
eslintConfigPrettier

View file

@ -1,16 +1,14 @@
import { createDefaultEsmPreset } from "ts-jest";
import { createDefaultEsmPreset } from 'ts-jest';
const defaultEsmPreset = createDefaultEsmPreset();
/** @type {import('ts-jest').JestConfigWithTsJest} **/
export default {
...defaultEsmPreset,
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
"^pkce-challenge$": "<rootDir>/src/__mocks__/pkce-challenge.ts"
},
transformIgnorePatterns: [
"/node_modules/(?!eventsource)/"
],
testPathIgnorePatterns: ["/node_modules/", "/dist/"],
...defaultEsmPreset,
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'^pkce-challenge$': '<rootDir>/src/__mocks__/pkce-challenge.ts'
},
transformIgnorePatterns: ['/node_modules/(?!eventsource)/'],
testPathIgnorePatterns: ['/node_modules/', '/dist/']
};

View file

@ -1,105 +1,105 @@
{
"name": "@modelcontextprotocol/sdk",
"version": "1.18.2",
"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": ">=18"
},
"keywords": [
"modelcontextprotocol",
"mcp"
],
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
"name": "@modelcontextprotocol/sdk",
"version": "1.18.2",
"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"
},
"./client": {
"import": "./dist/esm/client/index.js",
"require": "./dist/cjs/client/index.js"
"engines": {
"node": ">=18"
},
"./server": {
"import": "./dist/esm/server/index.js",
"require": "./dist/cjs/server/index.js"
"keywords": [
"modelcontextprotocol",
"mcp"
],
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
},
"./client": {
"import": "./dist/esm/client/index.js",
"require": "./dist/cjs/client/index.js"
},
"./server": {
"import": "./dist/esm/server/index.js",
"require": "./dist/cjs/server/index.js"
},
"./*": {
"import": "./dist/esm/*",
"require": "./dist/cjs/*"
}
},
"./*": {
"import": "./dist/esm/*",
"require": "./dist/cjs/*"
"typesVersions": {
"*": {
"*": [
"./dist/esm/*"
]
}
},
"files": [
"dist"
],
"scripts": {
"fetch:spec-types": "curl -o spec.types.ts https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/heads/main/schema/draft/schema.ts",
"build": "npm run build:esm && npm run build:cjs",
"build:esm": "mkdir -p dist/esm && echo '{\"type\": \"module\"}' > dist/esm/package.json && tsc -p tsconfig.prod.json",
"build:esm:w": "npm run build:esm -- -w",
"build:cjs": "mkdir -p dist/cjs && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json && tsc -p tsconfig.cjs.json",
"build:cjs:w": "npm run build:cjs -- -w",
"examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth",
"prepack": "npm run build:esm && npm run build:cjs",
"lint": "eslint src/ && prettier --check .",
"test": "npm run fetch:spec-types && jest",
"start": "npm run server",
"server": "tsx watch --clear-screen=false src/cli.ts server",
"client": "tsx src/cli.ts client"
},
"dependencies": {
"ajv": "^6.12.6",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^9.8.0",
"@jest-mock/express": "^3.0.0",
"@types/content-type": "^1.1.8",
"@types/cors": "^2.8.17",
"@types/cross-spawn": "^6.0.6",
"@types/eslint__js": "^8.42.3",
"@types/eventsource": "^1.1.15",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.12",
"@types/node": "^22.0.2",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.5.12",
"eslint": "^9.8.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "3.6.2",
"jest": "^29.7.0",
"supertest": "^7.0.0",
"ts-jest": "^29.2.4",
"tsx": "^4.16.5",
"typescript": "^5.5.4",
"typescript-eslint": "^8.0.0",
"ws": "^8.18.0"
},
"resolutions": {
"strip-ansi": "6.0.1"
}
},
"typesVersions": {
"*": {
"*": [
"./dist/esm/*"
]
}
},
"files": [
"dist"
],
"scripts": {
"fetch:spec-types": "curl -o spec.types.ts https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/heads/main/schema/draft/schema.ts",
"build": "npm run build:esm && npm run build:cjs",
"build:esm": "mkdir -p dist/esm && echo '{\"type\": \"module\"}' > dist/esm/package.json && tsc -p tsconfig.prod.json",
"build:esm:w": "npm run build:esm -- -w",
"build:cjs": "mkdir -p dist/cjs && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json && tsc -p tsconfig.cjs.json",
"build:cjs:w": "npm run build:cjs -- -w",
"examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth",
"prepack": "npm run build:esm && npm run build:cjs",
"lint": "eslint src/ && prettier --check .",
"test": "npm run fetch:spec-types && jest",
"start": "npm run server",
"server": "tsx watch --clear-screen=false src/cli.ts server",
"client": "tsx src/cli.ts client"
},
"dependencies": {
"ajv": "^6.12.6",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^9.8.0",
"@jest-mock/express": "^3.0.0",
"@types/content-type": "^1.1.8",
"@types/cors": "^2.8.17",
"@types/cross-spawn": "^6.0.6",
"@types/eslint__js": "^8.42.3",
"@types/eventsource": "^1.1.15",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.12",
"@types/node": "^22.0.2",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.5.12",
"eslint": "^9.8.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "3.6.2",
"jest": "^29.7.0",
"supertest": "^7.0.0",
"ts-jest": "^29.2.4",
"tsx": "^4.16.5",
"typescript": "^5.5.4",
"typescript-eslint": "^8.0.0",
"ws": "^8.18.0"
},
"resolutions": {
"strip-ansi": "6.0.1"
}
}
}

View file

@ -1,6 +1,6 @@
export default function pkceChallenge() {
return {
code_verifier: "test_verifier",
code_challenge: "test_challenge",
};
}
return {
code_verifier: 'test_verifier',
code_challenge: 'test_challenge'
};
}

View file

@ -1,163 +1,161 @@
import WebSocket from "ws";
import WebSocket from 'ws';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(global as any).WebSocket = WebSocket;
import express from "express";
import { Client } from "./client/index.js";
import { SSEClientTransport } from "./client/sse.js";
import { StdioClientTransport } from "./client/stdio.js";
import { WebSocketClientTransport } from "./client/websocket.js";
import { Server } from "./server/index.js";
import { SSEServerTransport } from "./server/sse.js";
import { StdioServerTransport } from "./server/stdio.js";
import { ListResourcesResultSchema } from "./types.js";
import express from 'express';
import { Client } from './client/index.js';
import { SSEClientTransport } from './client/sse.js';
import { StdioClientTransport } from './client/stdio.js';
import { WebSocketClientTransport } from './client/websocket.js';
import { Server } from './server/index.js';
import { SSEServerTransport } from './server/sse.js';
import { StdioServerTransport } from './server/stdio.js';
import { ListResourcesResultSchema } from './types.js';
async function runClient(url_or_command: string, args: string[]) {
const client = new Client(
{
name: "mcp-typescript test client",
version: "0.1.0",
},
{
capabilities: {
sampling: {},
},
},
);
const client = new Client(
{
name: 'mcp-typescript test client',
version: '0.1.0'
},
{
capabilities: {
sampling: {}
}
}
);
let clientTransport;
let clientTransport;
let url: URL | undefined = undefined;
try {
url = new URL(url_or_command);
} catch {
// Ignore
}
let url: URL | undefined = undefined;
try {
url = new URL(url_or_command);
} catch {
// Ignore
}
if (url?.protocol === "http:" || url?.protocol === "https:") {
clientTransport = new SSEClientTransport(new URL(url_or_command));
} else if (url?.protocol === "ws:" || url?.protocol === "wss:") {
clientTransport = new WebSocketClientTransport(new URL(url_or_command));
} else {
clientTransport = new StdioClientTransport({
command: url_or_command,
args,
});
}
if (url?.protocol === 'http:' || url?.protocol === 'https:') {
clientTransport = new SSEClientTransport(new URL(url_or_command));
} else if (url?.protocol === 'ws:' || url?.protocol === 'wss:') {
clientTransport = new WebSocketClientTransport(new URL(url_or_command));
} else {
clientTransport = new StdioClientTransport({
command: url_or_command,
args
});
}
console.log("Connected to server.");
console.log('Connected to server.');
await client.connect(clientTransport);
console.log("Initialized.");
await client.connect(clientTransport);
console.log('Initialized.');
await client.request({ method: "resources/list" }, ListResourcesResultSchema);
await client.request({ method: 'resources/list' }, ListResourcesResultSchema);
await client.close();
console.log("Closed.");
await client.close();
console.log('Closed.');
}
async function runServer(port: number | null) {
if (port !== null) {
const app = express();
if (port !== null) {
const app = express();
let servers: Server[] = [];
let servers: Server[] = [];
app.get("/sse", async (req, res) => {
console.log("Got new SSE connection");
app.get('/sse', async (req, res) => {
console.log('Got new SSE connection');
const transport = new SSEServerTransport("/message", res);
const server = new Server(
{
name: "mcp-typescript test server",
version: "0.1.0",
},
{
capabilities: {},
},
);
const transport = new SSEServerTransport('/message', res);
const server = new Server(
{
name: 'mcp-typescript test server',
version: '0.1.0'
},
{
capabilities: {}
}
);
servers.push(server);
servers.push(server);
server.onclose = () => {
console.log("SSE connection closed");
servers = servers.filter((s) => s !== server);
};
server.onclose = () => {
console.log('SSE connection closed');
servers = servers.filter(s => s !== server);
};
await server.connect(transport);
});
await server.connect(transport);
});
app.post("/message", async (req, res) => {
console.log("Received message");
app.post('/message', async (req, res) => {
console.log('Received message');
const sessionId = req.query.sessionId as string;
const transport = servers
.map((s) => s.transport as SSEServerTransport)
.find((t) => t.sessionId === sessionId);
if (!transport) {
res.status(404).send("Session not found");
return;
}
const sessionId = req.query.sessionId as string;
const transport = servers.map(s => s.transport as SSEServerTransport).find(t => t.sessionId === sessionId);
if (!transport) {
res.status(404).send('Session not found');
return;
}
await transport.handlePostMessage(req, res);
});
await transport.handlePostMessage(req, res);
});
app.listen(port, (error) => {
if (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
console.log(`Server running on http://localhost:${port}/sse`);
});
} else {
const server = new Server(
{
name: "mcp-typescript test server",
version: "0.1.0",
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {},
},
},
);
app.listen(port, error => {
if (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
console.log(`Server running on http://localhost:${port}/sse`);
});
} else {
const server = new Server(
{
name: 'mcp-typescript test server',
version: '0.1.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
const transport = new StdioServerTransport();
await server.connect(transport);
console.log("Server running on stdio");
}
console.log('Server running on stdio');
}
}
const args = process.argv.slice(2);
const command = args[0];
switch (command) {
case "client":
if (args.length < 2) {
console.error("Usage: client <server_url_or_command> [args...]");
process.exit(1);
case 'client':
if (args.length < 2) {
console.error('Usage: client <server_url_or_command> [args...]');
process.exit(1);
}
runClient(args[1], args.slice(2)).catch(error => {
console.error(error);
process.exit(1);
});
break;
case 'server': {
const port = args[1] ? parseInt(args[1]) : null;
runServer(port).catch(error => {
console.error(error);
process.exit(1);
});
break;
}
runClient(args[1], args.slice(2)).catch((error) => {
console.error(error);
process.exit(1);
});
break;
case "server": {
const port = args[1] ? parseInt(args[1]) : null;
runServer(port).catch((error) => {
console.error(error);
process.exit(1);
});
break;
}
default:
console.error("Unrecognized command:", command);
default:
console.error('Unrecognized command:', command);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,152 +1,152 @@
import { StdioClientTransport, getDefaultEnvironment } from "./stdio.js";
import spawn from "cross-spawn";
import { JSONRPCMessage } from "../types.js";
import { ChildProcess } from "node:child_process";
import { StdioClientTransport, getDefaultEnvironment } from './stdio.js';
import spawn from 'cross-spawn';
import { JSONRPCMessage } from '../types.js';
import { ChildProcess } from 'node:child_process';
// mock cross-spawn
jest.mock("cross-spawn");
jest.mock('cross-spawn');
const mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
describe("StdioClientTransport using cross-spawn", () => {
beforeEach(() => {
// mock cross-spawn's return value
mockSpawn.mockImplementation(() => {
const mockProcess: {
on: jest.Mock;
stdin?: { on: jest.Mock; write: jest.Mock };
stdout?: { on: jest.Mock };
stderr?: null;
} = {
on: jest.fn((event: string, callback: () => void) => {
if (event === "spawn") {
callback();
}
return mockProcess;
}),
stdin: {
on: jest.fn(),
write: jest.fn().mockReturnValue(true)
},
stdout: {
on: jest.fn()
},
stderr: null
};
return mockProcess as unknown as ChildProcess;
});
});
afterEach(() => {
jest.clearAllMocks();
});
test("should call cross-spawn correctly", async () => {
const transport = new StdioClientTransport({
command: "test-command",
args: ["arg1", "arg2"]
describe('StdioClientTransport using cross-spawn', () => {
beforeEach(() => {
// mock cross-spawn's return value
mockSpawn.mockImplementation(() => {
const mockProcess: {
on: jest.Mock;
stdin?: { on: jest.Mock; write: jest.Mock };
stdout?: { on: jest.Mock };
stderr?: null;
} = {
on: jest.fn((event: string, callback: () => void) => {
if (event === 'spawn') {
callback();
}
return mockProcess;
}),
stdin: {
on: jest.fn(),
write: jest.fn().mockReturnValue(true)
},
stdout: {
on: jest.fn()
},
stderr: null
};
return mockProcess as unknown as ChildProcess;
});
});
await transport.start();
// verify spawn is called correctly
expect(mockSpawn).toHaveBeenCalledWith(
"test-command",
["arg1", "arg2"],
expect.objectContaining({
shell: false
})
);
});
test("should pass environment variables correctly", async () => {
const customEnv = { TEST_VAR: "test-value" };
const transport = new StdioClientTransport({
command: "test-command",
env: customEnv
afterEach(() => {
jest.clearAllMocks();
});
await transport.start();
test('should call cross-spawn correctly', async () => {
const transport = new StdioClientTransport({
command: 'test-command',
args: ['arg1', 'arg2']
});
// verify environment variables are merged correctly
expect(mockSpawn).toHaveBeenCalledWith(
"test-command",
[],
expect.objectContaining({
env: {
...getDefaultEnvironment(),
...customEnv
}
})
);
});
await transport.start();
test("should use default environment when env is undefined", async () => {
const transport = new StdioClientTransport({
command: "test-command",
env: undefined
// verify spawn is called correctly
expect(mockSpawn).toHaveBeenCalledWith(
'test-command',
['arg1', 'arg2'],
expect.objectContaining({
shell: false
})
);
});
await transport.start();
test('should pass environment variables correctly', async () => {
const customEnv = { TEST_VAR: 'test-value' };
const transport = new StdioClientTransport({
command: 'test-command',
env: customEnv
});
// verify default environment is used
expect(mockSpawn).toHaveBeenCalledWith(
"test-command",
[],
expect.objectContaining({
env: getDefaultEnvironment()
})
);
});
await transport.start();
test("should send messages correctly", async () => {
const transport = new StdioClientTransport({
command: "test-command"
// verify environment variables are merged correctly
expect(mockSpawn).toHaveBeenCalledWith(
'test-command',
[],
expect.objectContaining({
env: {
...getDefaultEnvironment(),
...customEnv
}
})
);
});
// get the mock process object
const mockProcess: {
on: jest.Mock;
stdin: {
on: jest.Mock;
write: jest.Mock;
once: jest.Mock;
};
stdout: {
on: jest.Mock;
};
stderr: null;
} = {
on: jest.fn((event: string, callback: () => void) => {
if (event === "spawn") {
callback();
}
return mockProcess;
}),
stdin: {
on: jest.fn(),
write: jest.fn().mockReturnValue(true),
once: jest.fn()
},
stdout: {
on: jest.fn()
},
stderr: null
};
test('should use default environment when env is undefined', async () => {
const transport = new StdioClientTransport({
command: 'test-command',
env: undefined
});
mockSpawn.mockReturnValue(mockProcess as unknown as ChildProcess);
await transport.start();
await transport.start();
// verify default environment is used
expect(mockSpawn).toHaveBeenCalledWith(
'test-command',
[],
expect.objectContaining({
env: getDefaultEnvironment()
})
);
});
// 关键修复:确保 jsonrpc 是字面量 "2.0"
const message: JSONRPCMessage = {
jsonrpc: "2.0",
id: "test-id",
method: "test-method"
};
test('should send messages correctly', async () => {
const transport = new StdioClientTransport({
command: 'test-command'
});
await transport.send(message);
// get the mock process object
const mockProcess: {
on: jest.Mock;
stdin: {
on: jest.Mock;
write: jest.Mock;
once: jest.Mock;
};
stdout: {
on: jest.Mock;
};
stderr: null;
} = {
on: jest.fn((event: string, callback: () => void) => {
if (event === 'spawn') {
callback();
}
return mockProcess;
}),
stdin: {
on: jest.fn(),
write: jest.fn().mockReturnValue(true),
once: jest.fn()
},
stdout: {
on: jest.fn()
},
stderr: null
};
// verify message is sent correctly
expect(mockProcess.stdin.write).toHaveBeenCalled();
});
});
mockSpawn.mockReturnValue(mockProcess as unknown as ChildProcess);
await transport.start();
// 关键修复:确保 jsonrpc 是字面量 "2.0"
const message: JSONRPCMessage = {
jsonrpc: '2.0',
id: 'test-id',
method: 'test-method'
};
await transport.send(message);
// verify message is sent correctly
expect(mockProcess.stdin.write).toHaveBeenCalled();
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,56 +1,51 @@
import { mergeCapabilities, Protocol, ProtocolOptions, RequestOptions } from '../shared/protocol.js';
import { Transport } from '../shared/transport.js';
import {
mergeCapabilities,
Protocol,
ProtocolOptions,
RequestOptions,
} from "../shared/protocol.js";
import { Transport } from "../shared/transport.js";
import {
CallToolRequest,
CallToolResultSchema,
ClientCapabilities,
ClientNotification,
ClientRequest,
ClientResult,
CompatibilityCallToolResultSchema,
CompleteRequest,
CompleteResultSchema,
EmptyResultSchema,
GetPromptRequest,
GetPromptResultSchema,
Implementation,
InitializeResultSchema,
LATEST_PROTOCOL_VERSION,
ListPromptsRequest,
ListPromptsResultSchema,
ListResourcesRequest,
ListResourcesResultSchema,
ListResourceTemplatesRequest,
ListResourceTemplatesResultSchema,
ListToolsRequest,
ListToolsResultSchema,
LoggingLevel,
Notification,
ReadResourceRequest,
ReadResourceResultSchema,
Request,
Result,
ServerCapabilities,
SubscribeRequest,
SUPPORTED_PROTOCOL_VERSIONS,
UnsubscribeRequest,
Tool,
ErrorCode,
McpError,
} from "../types.js";
import Ajv from "ajv";
import type { ValidateFunction } from "ajv";
CallToolRequest,
CallToolResultSchema,
ClientCapabilities,
ClientNotification,
ClientRequest,
ClientResult,
CompatibilityCallToolResultSchema,
CompleteRequest,
CompleteResultSchema,
EmptyResultSchema,
GetPromptRequest,
GetPromptResultSchema,
Implementation,
InitializeResultSchema,
LATEST_PROTOCOL_VERSION,
ListPromptsRequest,
ListPromptsResultSchema,
ListResourcesRequest,
ListResourcesResultSchema,
ListResourceTemplatesRequest,
ListResourceTemplatesResultSchema,
ListToolsRequest,
ListToolsResultSchema,
LoggingLevel,
Notification,
ReadResourceRequest,
ReadResourceResultSchema,
Request,
Result,
ServerCapabilities,
SubscribeRequest,
SUPPORTED_PROTOCOL_VERSIONS,
UnsubscribeRequest,
Tool,
ErrorCode,
McpError
} from '../types.js';
import Ajv from 'ajv';
import type { ValidateFunction } from 'ajv';
export type ClientOptions = ProtocolOptions & {
/**
* Capabilities to advertise as being supported by this client.
*/
capabilities?: ClientCapabilities;
/**
* Capabilities to advertise as being supported by this client.
*/
capabilities?: ClientCapabilities;
};
/**
@ -79,441 +74,333 @@ export type ClientOptions = ProtocolOptions & {
* ```
*/
export class Client<
RequestT extends Request = Request,
NotificationT extends Notification = Notification,
ResultT extends Result = Result,
> extends Protocol<
ClientRequest | RequestT,
ClientNotification | NotificationT,
ClientResult | ResultT
> {
private _serverCapabilities?: ServerCapabilities;
private _serverVersion?: Implementation;
private _capabilities: ClientCapabilities;
private _instructions?: string;
private _cachedToolOutputValidators: Map<string, ValidateFunction> = new Map();
private _ajv: InstanceType<typeof Ajv>;
RequestT extends Request = Request,
NotificationT extends Notification = Notification,
ResultT extends Result = Result
> extends Protocol<ClientRequest | RequestT, ClientNotification | NotificationT, ClientResult | ResultT> {
private _serverCapabilities?: ServerCapabilities;
private _serverVersion?: Implementation;
private _capabilities: ClientCapabilities;
private _instructions?: string;
private _cachedToolOutputValidators: Map<string, ValidateFunction> = new Map();
private _ajv: InstanceType<typeof Ajv>;
/**
* Initializes this client with the given name and version information.
*/
constructor(
private _clientInfo: Implementation,
options?: ClientOptions,
) {
super(options);
this._capabilities = options?.capabilities ?? {};
this._ajv = new Ajv();
}
/**
* Registers new capabilities. This can only be called before connecting to a transport.
*
* The new capabilities will be merged with any existing capabilities previously given (e.g., at initialization).
*/
public registerCapabilities(capabilities: ClientCapabilities): void {
if (this.transport) {
throw new Error(
"Cannot register capabilities after connecting to transport",
);
/**
* Initializes this client with the given name and version information.
*/
constructor(
private _clientInfo: Implementation,
options?: ClientOptions
) {
super(options);
this._capabilities = options?.capabilities ?? {};
this._ajv = new Ajv();
}
this._capabilities = mergeCapabilities(this._capabilities, capabilities);
}
/**
* Registers new capabilities. This can only be called before connecting to a transport.
*
* The new capabilities will be merged with any existing capabilities previously given (e.g., at initialization).
*/
public registerCapabilities(capabilities: ClientCapabilities): void {
if (this.transport) {
throw new Error('Cannot register capabilities after connecting to transport');
}
protected assertCapability(
capability: keyof ServerCapabilities,
method: string,
): void {
if (!this._serverCapabilities?.[capability]) {
throw new Error(
`Server does not support ${capability} (required for ${method})`,
);
this._capabilities = mergeCapabilities(this._capabilities, capabilities);
}
}
override async connect(transport: Transport, options?: RequestOptions): Promise<void> {
await super.connect(transport);
// When transport sessionId is already set this means we are trying to reconnect.
// In this case we don't need to initialize again.
if (transport.sessionId !== undefined) {
return;
protected assertCapability(capability: keyof ServerCapabilities, method: string): void {
if (!this._serverCapabilities?.[capability]) {
throw new Error(`Server does not support ${capability} (required for ${method})`);
}
}
try {
const result = await this.request(
{
method: "initialize",
params: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: this._capabilities,
clientInfo: this._clientInfo,
},
},
InitializeResultSchema,
options
);
if (result === undefined) {
throw new Error(`Server sent invalid initialize result: ${result}`);
}
if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) {
throw new Error(
`Server's protocol version is not supported: ${result.protocolVersion}`,
);
}
this._serverCapabilities = result.capabilities;
this._serverVersion = result.serverInfo;
// HTTP transports must set the protocol version in each header after initialization.
if (transport.setProtocolVersion) {
transport.setProtocolVersion(result.protocolVersion);
}
this._instructions = result.instructions;
await this.notification({
method: "notifications/initialized",
});
} catch (error) {
// Disconnect if initialization fails.
void this.close();
throw error;
}
}
/**
* After initialization has completed, this will be populated with the server's reported capabilities.
*/
getServerCapabilities(): ServerCapabilities | undefined {
return this._serverCapabilities;
}
/**
* After initialization has completed, this will be populated with information about the server's name and version.
*/
getServerVersion(): Implementation | undefined {
return this._serverVersion;
}
/**
* After initialization has completed, this may be populated with information about the server's instructions.
*/
getInstructions(): string | undefined {
return this._instructions;
}
protected assertCapabilityForMethod(method: RequestT["method"]): void {
switch (method as ClientRequest["method"]) {
case "logging/setLevel":
if (!this._serverCapabilities?.logging) {
throw new Error(
`Server does not support logging (required for ${method})`,
);
override async connect(transport: Transport, options?: RequestOptions): Promise<void> {
await super.connect(transport);
// When transport sessionId is already set this means we are trying to reconnect.
// In this case we don't need to initialize again.
if (transport.sessionId !== undefined) {
return;
}
break;
case "prompts/get":
case "prompts/list":
if (!this._serverCapabilities?.prompts) {
throw new Error(
`Server does not support prompts (required for ${method})`,
);
}
break;
case "resources/list":
case "resources/templates/list":
case "resources/read":
case "resources/subscribe":
case "resources/unsubscribe":
if (!this._serverCapabilities?.resources) {
throw new Error(
`Server does not support resources (required for ${method})`,
);
}
if (
method === "resources/subscribe" &&
!this._serverCapabilities.resources.subscribe
) {
throw new Error(
`Server does not support resource subscriptions (required for ${method})`,
);
}
break;
case "tools/call":
case "tools/list":
if (!this._serverCapabilities?.tools) {
throw new Error(
`Server does not support tools (required for ${method})`,
);
}
break;
case "completion/complete":
if (!this._serverCapabilities?.completions) {
throw new Error(
`Server does not support completions (required for ${method})`,
);
}
break;
case "initialize":
// No specific capability required for initialize
break;
case "ping":
// No specific capability required for ping
break;
}
}
protected assertNotificationCapability(
method: NotificationT["method"],
): void {
switch (method as ClientNotification["method"]) {
case "notifications/roots/list_changed":
if (!this._capabilities.roots?.listChanged) {
throw new Error(
`Client does not support roots list changed notifications (required for ${method})`,
);
}
break;
case "notifications/initialized":
// No specific capability required for initialized
break;
case "notifications/cancelled":
// Cancellation notifications are always allowed
break;
case "notifications/progress":
// Progress notifications are always allowed
break;
}
}
protected assertRequestHandlerCapability(method: string): void {
switch (method) {
case "sampling/createMessage":
if (!this._capabilities.sampling) {
throw new Error(
`Client does not support sampling capability (required for ${method})`,
);
}
break;
case "elicitation/create":
if (!this._capabilities.elicitation) {
throw new Error(
`Client does not support elicitation capability (required for ${method})`,
);
}
break;
case "roots/list":
if (!this._capabilities.roots) {
throw new Error(
`Client does not support roots capability (required for ${method})`,
);
}
break;
case "ping":
// No specific capability required for ping
break;
}
}
async ping(options?: RequestOptions) {
return this.request({ method: "ping" }, EmptyResultSchema, options);
}
async complete(params: CompleteRequest["params"], options?: RequestOptions) {
return this.request(
{ method: "completion/complete", params },
CompleteResultSchema,
options,
);
}
async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) {
return this.request(
{ method: "logging/setLevel", params: { level } },
EmptyResultSchema,
options,
);
}
async getPrompt(
params: GetPromptRequest["params"],
options?: RequestOptions,
) {
return this.request(
{ method: "prompts/get", params },
GetPromptResultSchema,
options,
);
}
async listPrompts(
params?: ListPromptsRequest["params"],
options?: RequestOptions,
) {
return this.request(
{ method: "prompts/list", params },
ListPromptsResultSchema,
options,
);
}
async listResources(
params?: ListResourcesRequest["params"],
options?: RequestOptions,
) {
return this.request(
{ method: "resources/list", params },
ListResourcesResultSchema,
options,
);
}
async listResourceTemplates(
params?: ListResourceTemplatesRequest["params"],
options?: RequestOptions,
) {
return this.request(
{ method: "resources/templates/list", params },
ListResourceTemplatesResultSchema,
options,
);
}
async readResource(
params: ReadResourceRequest["params"],
options?: RequestOptions,
) {
return this.request(
{ method: "resources/read", params },
ReadResourceResultSchema,
options,
);
}
async subscribeResource(
params: SubscribeRequest["params"],
options?: RequestOptions,
) {
return this.request(
{ method: "resources/subscribe", params },
EmptyResultSchema,
options,
);
}
async unsubscribeResource(
params: UnsubscribeRequest["params"],
options?: RequestOptions,
) {
return this.request(
{ method: "resources/unsubscribe", params },
EmptyResultSchema,
options,
);
}
async callTool(
params: CallToolRequest["params"],
resultSchema:
| typeof CallToolResultSchema
| typeof CompatibilityCallToolResultSchema = CallToolResultSchema,
options?: RequestOptions,
) {
const result = await this.request(
{ method: "tools/call", params },
resultSchema,
options,
);
// Check if the tool has an outputSchema
const validator = this.getToolOutputValidator(params.name);
if (validator) {
// If tool has outputSchema, it MUST return structuredContent (unless it's an error)
if (!result.structuredContent && !result.isError) {
throw new McpError(
ErrorCode.InvalidRequest,
`Tool ${params.name} has an output schema but did not return structured content`
);
}
// Only validate structured content if present (not when there's an error)
if (result.structuredContent) {
try {
// Validate the structured content (which is already an object) against the schema
const isValid = validator(result.structuredContent);
if (!isValid) {
throw new McpError(
ErrorCode.InvalidParams,
`Structured content does not match the tool's output schema: ${this._ajv.errorsText(validator.errors)}`
const result = await this.request(
{
method: 'initialize',
params: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: this._capabilities,
clientInfo: this._clientInfo
}
},
InitializeResultSchema,
options
);
}
if (result === undefined) {
throw new Error(`Server sent invalid initialize result: ${result}`);
}
if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) {
throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
}
this._serverCapabilities = result.capabilities;
this._serverVersion = result.serverInfo;
// HTTP transports must set the protocol version in each header after initialization.
if (transport.setProtocolVersion) {
transport.setProtocolVersion(result.protocolVersion);
}
this._instructions = result.instructions;
await this.notification({
method: 'notifications/initialized'
});
} catch (error) {
if (error instanceof McpError) {
// Disconnect if initialization fails.
void this.close();
throw error;
}
throw new McpError(
ErrorCode.InvalidParams,
`Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}`
);
}
}
}
return result;
}
private cacheToolOutputSchemas(tools: Tool[]) {
this._cachedToolOutputValidators.clear();
for (const tool of tools) {
// If the tool has an outputSchema, create and cache the Ajv validator
if (tool.outputSchema) {
try {
const validator = this._ajv.compile(tool.outputSchema);
this._cachedToolOutputValidators.set(tool.name, validator);
} catch {
// Ignore schema compilation errors
}
}
/**
* After initialization has completed, this will be populated with the server's reported capabilities.
*/
getServerCapabilities(): ServerCapabilities | undefined {
return this._serverCapabilities;
}
}
private getToolOutputValidator(toolName: string): ValidateFunction | undefined {
return this._cachedToolOutputValidators.get(toolName);
}
/**
* After initialization has completed, this will be populated with information about the server's name and version.
*/
getServerVersion(): Implementation | undefined {
return this._serverVersion;
}
async listTools(
params?: ListToolsRequest["params"],
options?: RequestOptions,
) {
const result = await this.request(
{ method: "tools/list", params },
ListToolsResultSchema,
options,
);
/**
* After initialization has completed, this may be populated with information about the server's instructions.
*/
getInstructions(): string | undefined {
return this._instructions;
}
// Cache the tools and their output schemas for future validation
this.cacheToolOutputSchemas(result.tools);
protected assertCapabilityForMethod(method: RequestT['method']): void {
switch (method as ClientRequest['method']) {
case 'logging/setLevel':
if (!this._serverCapabilities?.logging) {
throw new Error(`Server does not support logging (required for ${method})`);
}
break;
return result;
}
case 'prompts/get':
case 'prompts/list':
if (!this._serverCapabilities?.prompts) {
throw new Error(`Server does not support prompts (required for ${method})`);
}
break;
async sendRootsListChanged() {
return this.notification({ method: "notifications/roots/list_changed" });
}
case 'resources/list':
case 'resources/templates/list':
case 'resources/read':
case 'resources/subscribe':
case 'resources/unsubscribe':
if (!this._serverCapabilities?.resources) {
throw new Error(`Server does not support resources (required for ${method})`);
}
if (method === 'resources/subscribe' && !this._serverCapabilities.resources.subscribe) {
throw new Error(`Server does not support resource subscriptions (required for ${method})`);
}
break;
case 'tools/call':
case 'tools/list':
if (!this._serverCapabilities?.tools) {
throw new Error(`Server does not support tools (required for ${method})`);
}
break;
case 'completion/complete':
if (!this._serverCapabilities?.completions) {
throw new Error(`Server does not support completions (required for ${method})`);
}
break;
case 'initialize':
// No specific capability required for initialize
break;
case 'ping':
// No specific capability required for ping
break;
}
}
protected assertNotificationCapability(method: NotificationT['method']): void {
switch (method as ClientNotification['method']) {
case 'notifications/roots/list_changed':
if (!this._capabilities.roots?.listChanged) {
throw new Error(`Client does not support roots list changed notifications (required for ${method})`);
}
break;
case 'notifications/initialized':
// No specific capability required for initialized
break;
case 'notifications/cancelled':
// Cancellation notifications are always allowed
break;
case 'notifications/progress':
// Progress notifications are always allowed
break;
}
}
protected assertRequestHandlerCapability(method: string): void {
switch (method) {
case 'sampling/createMessage':
if (!this._capabilities.sampling) {
throw new Error(`Client does not support sampling capability (required for ${method})`);
}
break;
case 'elicitation/create':
if (!this._capabilities.elicitation) {
throw new Error(`Client does not support elicitation capability (required for ${method})`);
}
break;
case 'roots/list':
if (!this._capabilities.roots) {
throw new Error(`Client does not support roots capability (required for ${method})`);
}
break;
case 'ping':
// No specific capability required for ping
break;
}
}
async ping(options?: RequestOptions) {
return this.request({ method: 'ping' }, EmptyResultSchema, options);
}
async complete(params: CompleteRequest['params'], options?: RequestOptions) {
return this.request({ method: 'completion/complete', params }, CompleteResultSchema, options);
}
async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) {
return this.request({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options);
}
async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions) {
return this.request({ method: 'prompts/get', params }, GetPromptResultSchema, options);
}
async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) {
return this.request({ method: 'prompts/list', params }, ListPromptsResultSchema, options);
}
async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) {
return this.request({ method: 'resources/list', params }, ListResourcesResultSchema, options);
}
async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) {
return this.request({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options);
}
async readResource(params: ReadResourceRequest['params'], options?: RequestOptions) {
return this.request({ method: 'resources/read', params }, ReadResourceResultSchema, options);
}
async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions) {
return this.request({ method: 'resources/subscribe', params }, EmptyResultSchema, options);
}
async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions) {
return this.request({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options);
}
async callTool(
params: CallToolRequest['params'],
resultSchema: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema = CallToolResultSchema,
options?: RequestOptions
) {
const result = await this.request({ method: 'tools/call', params }, resultSchema, options);
// Check if the tool has an outputSchema
const validator = this.getToolOutputValidator(params.name);
if (validator) {
// If tool has outputSchema, it MUST return structuredContent (unless it's an error)
if (!result.structuredContent && !result.isError) {
throw new McpError(
ErrorCode.InvalidRequest,
`Tool ${params.name} has an output schema but did not return structured content`
);
}
// Only validate structured content if present (not when there's an error)
if (result.structuredContent) {
try {
// Validate the structured content (which is already an object) against the schema
const isValid = validator(result.structuredContent);
if (!isValid) {
throw new McpError(
ErrorCode.InvalidParams,
`Structured content does not match the tool's output schema: ${this._ajv.errorsText(validator.errors)}`
);
}
} catch (error) {
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InvalidParams,
`Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}`
);
}
}
}
return result;
}
private cacheToolOutputSchemas(tools: Tool[]) {
this._cachedToolOutputValidators.clear();
for (const tool of tools) {
// If the tool has an outputSchema, create and cache the Ajv validator
if (tool.outputSchema) {
try {
const validator = this._ajv.compile(tool.outputSchema);
this._cachedToolOutputValidators.set(tool.name, validator);
} catch {
// Ignore schema compilation errors
}
}
}
}
private getToolOutputValidator(toolName: string): ValidateFunction | undefined {
return this._cachedToolOutputValidators.get(toolName);
}
async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) {
const result = await this.request({ method: 'tools/list', params }, ListToolsResultSchema, options);
// Cache the tools and their output schemas for future validation
this.cacheToolOutputSchemas(result.tools);
return result;
}
async sendRootsListChanged() {
return this.notification({ method: 'notifications/roots/list_changed' });
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,5 @@
import {
auth,
extractResourceMetadataUrl,
OAuthClientProvider,
UnauthorizedError,
} from "./auth.js";
import { FetchLike } from "../shared/transport.js";
import { auth, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from './auth.js';
import { FetchLike } from '../shared/transport.js';
/**
* Middleware function that wraps and enhances fetch functionality.
@ -39,114 +34,106 @@ export type Middleware = (next: FetchLike) => FetchLike;
* @returns A fetch middleware function
*/
export const withOAuth =
(provider: OAuthClientProvider, baseUrl?: string | URL): Middleware =>
(next) => {
return async (input, init) => {
const makeRequest = async (): Promise<Response> => {
const headers = new Headers(init?.headers);
(provider: OAuthClientProvider, baseUrl?: string | URL): Middleware =>
next => {
return async (input, init) => {
const makeRequest = async (): Promise<Response> => {
const headers = new Headers(init?.headers);
// Add authorization header if tokens are available
const tokens = await provider.tokens();
if (tokens) {
headers.set("Authorization", `Bearer ${tokens.access_token}`);
}
// Add authorization header if tokens are available
const tokens = await provider.tokens();
if (tokens) {
headers.set('Authorization', `Bearer ${tokens.access_token}`);
}
return await next(input, { ...init, headers });
};
return await next(input, { ...init, headers });
};
let response = await makeRequest();
let response = await makeRequest();
// Handle 401 responses by attempting re-authentication
if (response.status === 401) {
try {
const resourceMetadataUrl = extractResourceMetadataUrl(response);
// Handle 401 responses by attempting re-authentication
if (response.status === 401) {
try {
const resourceMetadataUrl = extractResourceMetadataUrl(response);
// Use provided baseUrl or extract from request URL
const serverUrl =
baseUrl ||
(typeof input === "string" ? new URL(input).origin : input.origin);
// Use provided baseUrl or extract from request URL
const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin);
const result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
fetchFn: next,
});
const result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
fetchFn: next
});
if (result === "REDIRECT") {
throw new UnauthorizedError(
"Authentication requires user authorization - redirect initiated",
);
}
if (result === 'REDIRECT') {
throw new UnauthorizedError('Authentication requires user authorization - redirect initiated');
}
if (result !== "AUTHORIZED") {
throw new UnauthorizedError(
`Authentication failed with result: ${result}`,
);
}
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError(`Authentication failed with result: ${result}`);
}
// Retry the request with fresh tokens
response = await makeRequest();
} catch (error) {
if (error instanceof UnauthorizedError) {
throw error;
}
throw new UnauthorizedError(
`Failed to re-authenticate: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// Retry the request with fresh tokens
response = await makeRequest();
} catch (error) {
if (error instanceof UnauthorizedError) {
throw error;
}
throw new UnauthorizedError(`Failed to re-authenticate: ${error instanceof Error ? error.message : String(error)}`);
}
}
// If we still have a 401 after re-auth attempt, throw an error
if (response.status === 401) {
const url = typeof input === "string" ? input : input.toString();
throw new UnauthorizedError(`Authentication failed for ${url}`);
}
// If we still have a 401 after re-auth attempt, throw an error
if (response.status === 401) {
const url = typeof input === 'string' ? input : input.toString();
throw new UnauthorizedError(`Authentication failed for ${url}`);
}
return response;
return response;
};
};
};
/**
* Logger function type for HTTP requests
*/
export type RequestLogger = (input: {
method: string;
url: string | URL;
status: number;
statusText: string;
duration: number;
requestHeaders?: Headers;
responseHeaders?: Headers;
error?: Error;
method: string;
url: string | URL;
status: number;
statusText: string;
duration: number;
requestHeaders?: Headers;
responseHeaders?: Headers;
error?: Error;
}) => void;
/**
* Configuration options for the logging middleware
*/
export type LoggingOptions = {
/**
* Custom logger function, defaults to console logging
*/
logger?: RequestLogger;
/**
* Custom logger function, defaults to console logging
*/
logger?: RequestLogger;
/**
* Whether to include request headers in logs
* @default false
*/
includeRequestHeaders?: boolean;
/**
* Whether to include request headers in logs
* @default false
*/
includeRequestHeaders?: boolean;
/**
* Whether to include response headers in logs
* @default false
*/
includeResponseHeaders?: boolean;
/**
* Whether to include response headers in logs
* @default false
*/
includeResponseHeaders?: boolean;
/**
* Status level filter - only log requests with status >= this value
* Set to 0 to log all requests, 400 to log only errors
* @default 0
*/
statusLevel?: number;
/**
* Status level filter - only log requests with status >= this value
* Set to 0 to log all requests, 400 to log only errors
* @default 0
*/
statusLevel?: number;
};
/**
@ -166,100 +153,82 @@ export type LoggingOptions = {
* @returns A fetch middleware function
*/
export const withLogging = (options: LoggingOptions = {}): Middleware => {
const {
logger,
includeRequestHeaders = false,
includeResponseHeaders = false,
statusLevel = 0,
} = options;
const { logger, includeRequestHeaders = false, includeResponseHeaders = false, statusLevel = 0 } = options;
const defaultLogger: RequestLogger = (input) => {
const {
method,
url,
status,
statusText,
duration,
requestHeaders,
responseHeaders,
error,
} = input;
const defaultLogger: RequestLogger = input => {
const { method, url, status, statusText, duration, requestHeaders, responseHeaders, error } = input;
let message = error
? `HTTP ${method} ${url} failed: ${error.message} (${duration}ms)`
: `HTTP ${method} ${url} ${status} ${statusText} (${duration}ms)`;
let message = error
? `HTTP ${method} ${url} failed: ${error.message} (${duration}ms)`
: `HTTP ${method} ${url} ${status} ${statusText} (${duration}ms)`;
// Add headers to message if requested
if (includeRequestHeaders && requestHeaders) {
const reqHeaders = Array.from(requestHeaders.entries())
.map(([key, value]) => `${key}: ${value}`)
.join(", ");
message += `\n Request Headers: {${reqHeaders}}`;
}
// Add headers to message if requested
if (includeRequestHeaders && requestHeaders) {
const reqHeaders = Array.from(requestHeaders.entries())
.map(([key, value]) => `${key}: ${value}`)
.join(', ');
message += `\n Request Headers: {${reqHeaders}}`;
}
if (includeResponseHeaders && responseHeaders) {
const resHeaders = Array.from(responseHeaders.entries())
.map(([key, value]) => `${key}: ${value}`)
.join(", ");
message += `\n Response Headers: {${resHeaders}}`;
}
if (includeResponseHeaders && responseHeaders) {
const resHeaders = Array.from(responseHeaders.entries())
.map(([key, value]) => `${key}: ${value}`)
.join(', ');
message += `\n Response Headers: {${resHeaders}}`;
}
if (error || status >= 400) {
// eslint-disable-next-line no-console
console.error(message);
} else {
// eslint-disable-next-line no-console
console.log(message);
}
};
if (error || status >= 400) {
// eslint-disable-next-line no-console
console.error(message);
} else {
// eslint-disable-next-line no-console
console.log(message);
}
};
const logFn = logger || defaultLogger;
const logFn = logger || defaultLogger;
return (next) => async (input, init) => {
const startTime = performance.now();
const method = init?.method || "GET";
const url = typeof input === "string" ? input : input.toString();
const requestHeaders = includeRequestHeaders
? new Headers(init?.headers)
: undefined;
return next => async (input, init) => {
const startTime = performance.now();
const method = init?.method || 'GET';
const url = typeof input === 'string' ? input : input.toString();
const requestHeaders = includeRequestHeaders ? new Headers(init?.headers) : undefined;
try {
const response = await next(input, init);
const duration = performance.now() - startTime;
try {
const response = await next(input, init);
const duration = performance.now() - startTime;
// Only log if status meets the log level threshold
if (response.status >= statusLevel) {
logFn({
method,
url,
status: response.status,
statusText: response.statusText,
duration,
requestHeaders,
responseHeaders: includeResponseHeaders
? response.headers
: undefined,
});
}
// Only log if status meets the log level threshold
if (response.status >= statusLevel) {
logFn({
method,
url,
status: response.status,
statusText: response.statusText,
duration,
requestHeaders,
responseHeaders: includeResponseHeaders ? response.headers : undefined
});
}
return response;
} catch (error) {
const duration = performance.now() - startTime;
return response;
} catch (error) {
const duration = performance.now() - startTime;
// Always log errors regardless of log level
logFn({
method,
url,
status: 0,
statusText: "Network Error",
duration,
requestHeaders,
error: error as Error,
});
// Always log errors regardless of log level
logFn({
method,
url,
status: 0,
statusText: 'Network Error',
duration,
requestHeaders,
error: error as Error
});
throw error;
}
};
throw error;
}
};
};
/**
@ -281,12 +250,10 @@ export const withLogging = (options: LoggingOptions = {}): Middleware => {
* @param middleware - Array of fetch middleware to compose into a pipeline
* @returns A single composed middleware function
*/
export const applyMiddlewares = (
...middleware: Middleware[]
): Middleware => {
return (next) => {
return middleware.reduce((handler, mw) => mw(handler), next);
};
export const applyMiddlewares = (...middleware: Middleware[]): Middleware => {
return next => {
return middleware.reduce((handler, mw) => mw(handler), next);
};
};
/**
@ -347,12 +314,6 @@ export const applyMiddlewares = (
* @param handler - Function that receives the next handler and request parameters
* @returns A fetch middleware function
*/
export const createMiddleware = (
handler: (
next: FetchLike,
input: string | URL,
init?: RequestInit,
) => Promise<Response>,
): Middleware => {
return (next) => (input, init) => handler(next, input as string | URL, init);
export const createMiddleware = (handler: (next: FetchLike, input: string | URL, init?: RequestInit) => Promise<Response>): Middleware => {
return next => (input, init) => handler(next, input as string | URL, init);
};

File diff suppressed because it is too large Load diff

View file

@ -1,57 +1,57 @@
import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource";
import { Transport, FetchLike } from "../shared/transport.js";
import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { EventSource, type ErrorEvent, type EventSourceInit } from 'eventsource';
import { Transport, FetchLike } from '../shared/transport.js';
import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js';
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from './auth.js';
export class SseError extends Error {
constructor(
public readonly code: number | undefined,
message: string | undefined,
public readonly event: ErrorEvent,
) {
super(`SSE error: ${message}`);
}
constructor(
public readonly code: number | undefined,
message: string | undefined,
public readonly event: ErrorEvent
) {
super(`SSE error: ${message}`);
}
}
/**
* Configuration options for the `SSEClientTransport`.
*/
export type SSEClientTransportOptions = {
/**
* An OAuth client provider to use for authentication.
*
* When an `authProvider` is specified and the SSE connection is started:
* 1. The connection is attempted with any existing access token from the `authProvider`.
* 2. If the access token has expired, the `authProvider` is used to refresh the token.
* 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`.
*
* After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `SSEClientTransport.finishAuth` with the authorization code before retrying the connection.
*
* If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown.
*
* `UnauthorizedError` might also be thrown when sending any message over the SSE transport, indicating that the session has expired, and needs to be re-authed and reconnected.
*/
authProvider?: OAuthClientProvider;
/**
* An OAuth client provider to use for authentication.
*
* When an `authProvider` is specified and the SSE connection is started:
* 1. The connection is attempted with any existing access token from the `authProvider`.
* 2. If the access token has expired, the `authProvider` is used to refresh the token.
* 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`.
*
* After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `SSEClientTransport.finishAuth` with the authorization code before retrying the connection.
*
* If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown.
*
* `UnauthorizedError` might also be thrown when sending any message over the SSE transport, indicating that the session has expired, and needs to be re-authed and reconnected.
*/
authProvider?: OAuthClientProvider;
/**
* Customizes the initial SSE request to the server (the request that begins the stream).
*
* NOTE: Setting this property will prevent an `Authorization` header from
* being automatically attached to the SSE request, if an `authProvider` is
* also given. This can be worked around by setting the `Authorization` header
* manually.
*/
eventSourceInit?: EventSourceInit;
/**
* Customizes the initial SSE request to the server (the request that begins the stream).
*
* NOTE: Setting this property will prevent an `Authorization` header from
* being automatically attached to the SSE request, if an `authProvider` is
* also given. This can be worked around by setting the `Authorization` header
* manually.
*/
eventSourceInit?: EventSourceInit;
/**
* Customizes recurring POST requests to the server.
*/
requestInit?: RequestInit;
/**
* Customizes recurring POST requests to the server.
*/
requestInit?: RequestInit;
/**
* Custom fetch implementation used for all network requests.
*/
fetch?: FetchLike;
/**
* Custom fetch implementation used for all network requests.
*/
fetch?: FetchLike;
};
/**
@ -59,220 +59,217 @@ export type SSEClientTransportOptions = {
* messages and make separate POST requests for sending messages.
*/
export class SSEClientTransport implements Transport {
private _eventSource?: EventSource;
private _endpoint?: URL;
private _abortController?: AbortController;
private _url: URL;
private _resourceMetadataUrl?: URL;
private _eventSourceInit?: EventSourceInit;
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
private _fetch?: FetchLike;
private _protocolVersion?: string;
private _eventSource?: EventSource;
private _endpoint?: URL;
private _abortController?: AbortController;
private _url: URL;
private _resourceMetadataUrl?: URL;
private _eventSourceInit?: EventSourceInit;
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
private _fetch?: FetchLike;
private _protocolVersion?: string;
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
constructor(
url: URL,
opts?: SSEClientTransportOptions,
) {
this._url = url;
this._resourceMetadataUrl = undefined;
this._eventSourceInit = opts?.eventSourceInit;
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
this._fetch = opts?.fetch;
}
private async _authThenStart(): Promise<void> {
if (!this._authProvider) {
throw new UnauthorizedError("No auth provider");
constructor(url: URL, opts?: SSEClientTransportOptions) {
this._url = url;
this._resourceMetadataUrl = undefined;
this._eventSourceInit = opts?.eventSourceInit;
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
this._fetch = opts?.fetch;
}
let result: AuthResult;
try {
result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch });
} catch (error) {
this.onerror?.(error as Error);
throw error;
}
if (result !== "AUTHORIZED") {
throw new UnauthorizedError();
}
return await this._startOrAuth();
}
private async _commonHeaders(): Promise<Headers> {
const headers: HeadersInit = {};
if (this._authProvider) {
const tokens = await this._authProvider.tokens();
if (tokens) {
headers["Authorization"] = `Bearer ${tokens.access_token}`;
}
}
if (this._protocolVersion) {
headers["mcp-protocol-version"] = this._protocolVersion;
}
return new Headers(
{ ...headers, ...this._requestInit?.headers }
);
}
private _startOrAuth(): Promise<void> {
const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch
return new Promise((resolve, reject) => {
this._eventSource = new EventSource(
this._url.href,
{
...this._eventSourceInit,
fetch: async (url, init) => {
const headers = await this._commonHeaders();
headers.set("Accept", "text/event-stream");
const response = await fetchImpl(url, {
...init,
headers,
})
if (response.status === 401 && response.headers.has('www-authenticate')) {
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
}
return response
},
},
);
this._abortController = new AbortController();
this._eventSource.onerror = (event) => {
if (event.code === 401 && this._authProvider) {
this._authThenStart().then(resolve, reject);
return;
private async _authThenStart(): Promise<void> {
if (!this._authProvider) {
throw new UnauthorizedError('No auth provider');
}
const error = new SseError(event.code, event.message, event);
reject(error);
this.onerror?.(error);
};
this._eventSource.onopen = () => {
// The connection is open, but we need to wait for the endpoint to be received.
};
this._eventSource.addEventListener("endpoint", (event: Event) => {
const messageEvent = event as MessageEvent;
let result: AuthResult;
try {
this._endpoint = new URL(messageEvent.data, this._url);
if (this._endpoint.origin !== this._url.origin) {
throw new Error(
`Endpoint origin does not match connection origin: ${this._endpoint.origin}`,
);
}
result = await auth(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
fetchFn: this._fetch
});
} catch (error) {
reject(error);
this.onerror?.(error as Error);
void this.close();
return;
this.onerror?.(error as Error);
throw error;
}
resolve();
});
this._eventSource.onmessage = (event: Event) => {
const messageEvent = event as MessageEvent;
let message: JSONRPCMessage;
try {
message = JSONRPCMessageSchema.parse(JSON.parse(messageEvent.data));
} catch (error) {
this.onerror?.(error as Error);
return;
}
this.onmessage?.(message);
};
});
}
async start() {
if (this._eventSource) {
throw new Error(
"SSEClientTransport already started! If using Client class, note that connect() calls start() automatically.",
);
}
return await this._startOrAuth();
}
/**
* Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth.
*/
async finishAuth(authorizationCode: string): Promise<void> {
if (!this._authProvider) {
throw new UnauthorizedError("No auth provider");
}
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError("Failed to authorize");
}
}
async close(): Promise<void> {
this._abortController?.abort();
this._eventSource?.close();
this.onclose?.();
}
async send(message: JSONRPCMessage): Promise<void> {
if (!this._endpoint) {
throw new Error("Not connected");
}
try {
const headers = await this._commonHeaders();
headers.set("content-type", "application/json");
const init = {
...this._requestInit,
method: "POST",
headers,
body: JSON.stringify(message),
signal: this._abortController?.signal,
};
const response = await (this._fetch ?? fetch)(this._endpoint, init);
if (!response.ok) {
if (response.status === 401 && this._authProvider) {
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch });
if (result !== "AUTHORIZED") {
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError();
}
// Purposely _not_ awaited, so we don't call onerror twice
return this.send(message);
}
const text = await response.text().catch(() => null);
throw new Error(
`Error POSTing to endpoint (HTTP ${response.status}): ${text}`,
);
}
} catch (error) {
this.onerror?.(error as Error);
throw error;
return await this._startOrAuth();
}
}
setProtocolVersion(version: string): void {
this._protocolVersion = version;
}
private async _commonHeaders(): Promise<Headers> {
const headers: HeadersInit = {};
if (this._authProvider) {
const tokens = await this._authProvider.tokens();
if (tokens) {
headers['Authorization'] = `Bearer ${tokens.access_token}`;
}
}
if (this._protocolVersion) {
headers['mcp-protocol-version'] = this._protocolVersion;
}
return new Headers({ ...headers, ...this._requestInit?.headers });
}
private _startOrAuth(): Promise<void> {
const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch;
return new Promise((resolve, reject) => {
this._eventSource = new EventSource(this._url.href, {
...this._eventSourceInit,
fetch: async (url, init) => {
const headers = await this._commonHeaders();
headers.set('Accept', 'text/event-stream');
const response = await fetchImpl(url, {
...init,
headers
});
if (response.status === 401 && response.headers.has('www-authenticate')) {
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
}
return response;
}
});
this._abortController = new AbortController();
this._eventSource.onerror = event => {
if (event.code === 401 && this._authProvider) {
this._authThenStart().then(resolve, reject);
return;
}
const error = new SseError(event.code, event.message, event);
reject(error);
this.onerror?.(error);
};
this._eventSource.onopen = () => {
// The connection is open, but we need to wait for the endpoint to be received.
};
this._eventSource.addEventListener('endpoint', (event: Event) => {
const messageEvent = event as MessageEvent;
try {
this._endpoint = new URL(messageEvent.data, this._url);
if (this._endpoint.origin !== this._url.origin) {
throw new Error(`Endpoint origin does not match connection origin: ${this._endpoint.origin}`);
}
} catch (error) {
reject(error);
this.onerror?.(error as Error);
void this.close();
return;
}
resolve();
});
this._eventSource.onmessage = (event: Event) => {
const messageEvent = event as MessageEvent;
let message: JSONRPCMessage;
try {
message = JSONRPCMessageSchema.parse(JSON.parse(messageEvent.data));
} catch (error) {
this.onerror?.(error as Error);
return;
}
this.onmessage?.(message);
};
});
}
async start() {
if (this._eventSource) {
throw new Error('SSEClientTransport already started! If using Client class, note that connect() calls start() automatically.');
}
return await this._startOrAuth();
}
/**
* Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth.
*/
async finishAuth(authorizationCode: string): Promise<void> {
if (!this._authProvider) {
throw new UnauthorizedError('No auth provider');
}
const result = await auth(this._authProvider, {
serverUrl: this._url,
authorizationCode,
resourceMetadataUrl: this._resourceMetadataUrl,
fetchFn: this._fetch
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError('Failed to authorize');
}
}
async close(): Promise<void> {
this._abortController?.abort();
this._eventSource?.close();
this.onclose?.();
}
async send(message: JSONRPCMessage): Promise<void> {
if (!this._endpoint) {
throw new Error('Not connected');
}
try {
const headers = await this._commonHeaders();
headers.set('content-type', 'application/json');
const init = {
...this._requestInit,
method: 'POST',
headers,
body: JSON.stringify(message),
signal: this._abortController?.signal
};
const response = await (this._fetch ?? fetch)(this._endpoint, init);
if (!response.ok) {
if (response.status === 401 && this._authProvider) {
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
const result = await auth(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
fetchFn: this._fetch
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError();
}
// Purposely _not_ awaited, so we don't call onerror twice
return this.send(message);
}
const text = await response.text().catch(() => null);
throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`);
}
} catch (error) {
this.onerror?.(error as Error);
throw error;
}
}
setProtocolVersion(version: string): void {
this._protocolVersion = version;
}
}

View file

@ -1,77 +1,77 @@
import { JSONRPCMessage } from "../types.js";
import { StdioClientTransport, StdioServerParameters } from "./stdio.js";
import { JSONRPCMessage } from '../types.js';
import { StdioClientTransport, StdioServerParameters } from './stdio.js';
// Configure default server parameters based on OS
// Uses 'more' command for Windows and 'tee' command for Unix/Linux
const getDefaultServerParameters = (): StdioServerParameters => {
if (process.platform === "win32") {
return { command: "more" };
}
return { command: "/usr/bin/tee" };
if (process.platform === 'win32') {
return { command: 'more' };
}
return { command: '/usr/bin/tee' };
};
const serverParameters = getDefaultServerParameters();
test("should start then close cleanly", async () => {
const client = new StdioClientTransport(serverParameters);
client.onerror = (error) => {
throw error;
};
let didClose = false;
client.onclose = () => {
didClose = true;
};
await client.start();
expect(didClose).toBeFalsy();
await client.close();
expect(didClose).toBeTruthy();
});
test("should read messages", async () => {
const client = new StdioClientTransport(serverParameters);
client.onerror = (error) => {
throw error;
};
const messages: JSONRPCMessage[] = [
{
jsonrpc: "2.0",
id: 1,
method: "ping",
},
{
jsonrpc: "2.0",
method: "notifications/initialized",
},
];
const readMessages: JSONRPCMessage[] = [];
const finished = new Promise<void>((resolve) => {
client.onmessage = (message) => {
readMessages.push(message);
if (JSON.stringify(message) === JSON.stringify(messages[1])) {
resolve();
}
test('should start then close cleanly', async () => {
const client = new StdioClientTransport(serverParameters);
client.onerror = error => {
throw error;
};
});
await client.start();
await client.send(messages[0]);
await client.send(messages[1]);
await finished;
expect(readMessages).toEqual(messages);
let didClose = false;
client.onclose = () => {
didClose = true;
};
await client.close();
await client.start();
expect(didClose).toBeFalsy();
await client.close();
expect(didClose).toBeTruthy();
});
test("should return child process pid", async () => {
const client = new StdioClientTransport(serverParameters);
test('should read messages', async () => {
const client = new StdioClientTransport(serverParameters);
client.onerror = error => {
throw error;
};
await client.start();
expect(client.pid).not.toBeNull();
await client.close();
expect(client.pid).toBeNull();
const messages: JSONRPCMessage[] = [
{
jsonrpc: '2.0',
id: 1,
method: 'ping'
},
{
jsonrpc: '2.0',
method: 'notifications/initialized'
}
];
const readMessages: JSONRPCMessage[] = [];
const finished = new Promise<void>(resolve => {
client.onmessage = message => {
readMessages.push(message);
if (JSON.stringify(message) === JSON.stringify(messages[1])) {
resolve();
}
};
});
await client.start();
await client.send(messages[0]);
await client.send(messages[1]);
await finished;
expect(readMessages).toEqual(messages);
await client.close();
});
test('should return child process pid', async () => {
const client = new StdioClientTransport(serverParameters);
await client.start();
expect(client.pid).not.toBeNull();
await client.close();
expect(client.pid).toBeNull();
});

View file

@ -1,87 +1,87 @@
import { ChildProcess, IOType } from "node:child_process";
import spawn from "cross-spawn";
import process from "node:process";
import { Stream, PassThrough } from "node:stream";
import { ReadBuffer, serializeMessage } from "../shared/stdio.js";
import { Transport } from "../shared/transport.js";
import { JSONRPCMessage } from "../types.js";
import { ChildProcess, IOType } from 'node:child_process';
import spawn from 'cross-spawn';
import process from 'node:process';
import { Stream, PassThrough } from 'node:stream';
import { ReadBuffer, serializeMessage } from '../shared/stdio.js';
import { Transport } from '../shared/transport.js';
import { JSONRPCMessage } from '../types.js';
export type StdioServerParameters = {
/**
* The executable to run to start the server.
*/
command: string;
/**
* The executable to run to start the server.
*/
command: string;
/**
* Command line arguments to pass to the executable.
*/
args?: string[];
/**
* Command line arguments to pass to the executable.
*/
args?: string[];
/**
* The environment to use when spawning the process.
*
* If not specified, the result of getDefaultEnvironment() will be used.
*/
env?: Record<string, string>;
/**
* The environment to use when spawning the process.
*
* If not specified, the result of getDefaultEnvironment() will be used.
*/
env?: Record<string, string>;
/**
* How to handle stderr of the child process. This matches the semantics of Node's `child_process.spawn`.
*
* The default is "inherit", meaning messages to stderr will be printed to the parent process's stderr.
*/
stderr?: IOType | Stream | number;
/**
* How to handle stderr of the child process. This matches the semantics of Node's `child_process.spawn`.
*
* The default is "inherit", meaning messages to stderr will be printed to the parent process's stderr.
*/
stderr?: IOType | Stream | number;
/**
* The working directory to use when spawning the process.
*
* If not specified, the current working directory will be inherited.
*/
cwd?: string;
/**
* The working directory to use when spawning the process.
*
* If not specified, the current working directory will be inherited.
*/
cwd?: string;
};
/**
* Environment variables to inherit by default, if an environment is not explicitly given.
*/
export const DEFAULT_INHERITED_ENV_VARS =
process.platform === "win32"
? [
"APPDATA",
"HOMEDRIVE",
"HOMEPATH",
"LOCALAPPDATA",
"PATH",
"PROCESSOR_ARCHITECTURE",
"SYSTEMDRIVE",
"SYSTEMROOT",
"TEMP",
"USERNAME",
"USERPROFILE",
"PROGRAMFILES",
]
: /* list inspired by the default env inheritance of sudo */
["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"];
process.platform === 'win32'
? [
'APPDATA',
'HOMEDRIVE',
'HOMEPATH',
'LOCALAPPDATA',
'PATH',
'PROCESSOR_ARCHITECTURE',
'SYSTEMDRIVE',
'SYSTEMROOT',
'TEMP',
'USERNAME',
'USERPROFILE',
'PROGRAMFILES'
]
: /* list inspired by the default env inheritance of sudo */
['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER'];
/**
* Returns a default environment object including only environment variables deemed safe to inherit.
*/
export function getDefaultEnvironment(): Record<string, string> {
const env: Record<string, string> = {};
const env: Record<string, string> = {};
for (const key of DEFAULT_INHERITED_ENV_VARS) {
const value = process.env[key];
if (value === undefined) {
continue;
for (const key of DEFAULT_INHERITED_ENV_VARS) {
const value = process.env[key];
if (value === undefined) {
continue;
}
if (value.startsWith('()')) {
// Skip functions, which are a security risk.
continue;
}
env[key] = value;
}
if (value.startsWith("()")) {
// Skip functions, which are a security risk.
continue;
}
env[key] = value;
}
return env;
return env;
}
/**
@ -90,151 +90,147 @@ export function getDefaultEnvironment(): Record<string, string> {
* This transport is only available in Node.js environments.
*/
export class StdioClientTransport implements Transport {
private _process?: ChildProcess;
private _abortController: AbortController = new AbortController();
private _readBuffer: ReadBuffer = new ReadBuffer();
private _serverParams: StdioServerParameters;
private _stderrStream: PassThrough | null = null;
private _process?: ChildProcess;
private _abortController: AbortController = new AbortController();
private _readBuffer: ReadBuffer = new ReadBuffer();
private _serverParams: StdioServerParameters;
private _stderrStream: PassThrough | null = null;
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
constructor(server: StdioServerParameters) {
this._serverParams = server;
if (server.stderr === "pipe" || server.stderr === "overlapped") {
this._stderrStream = new PassThrough();
}
}
/**
* Starts the server process and prepares to communicate with it.
*/
async start(): Promise<void> {
if (this._process) {
throw new Error(
"StdioClientTransport already started! If using Client class, note that connect() calls start() automatically."
);
constructor(server: StdioServerParameters) {
this._serverParams = server;
if (server.stderr === 'pipe' || server.stderr === 'overlapped') {
this._stderrStream = new PassThrough();
}
}
return new Promise((resolve, reject) => {
this._process = spawn(
this._serverParams.command,
this._serverParams.args ?? [],
{
// merge default env with server env because mcp server needs some env vars
env: {
...getDefaultEnvironment(),
...this._serverParams.env,
},
stdio: ["pipe", "pipe", this._serverParams.stderr ?? "inherit"],
shell: false,
signal: this._abortController.signal,
windowsHide: process.platform === "win32" && isElectron(),
cwd: this._serverParams.cwd,
}
);
this._process.on("error", (error) => {
if (error.name === "AbortError") {
// Expected when close() is called.
this.onclose?.();
return;
/**
* Starts the server process and prepares to communicate with it.
*/
async start(): Promise<void> {
if (this._process) {
throw new Error(
'StdioClientTransport already started! If using Client class, note that connect() calls start() automatically.'
);
}
reject(error);
this.onerror?.(error);
});
return new Promise((resolve, reject) => {
this._process = spawn(this._serverParams.command, this._serverParams.args ?? [], {
// merge default env with server env because mcp server needs some env vars
env: {
...getDefaultEnvironment(),
...this._serverParams.env
},
stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'],
shell: false,
signal: this._abortController.signal,
windowsHide: process.platform === 'win32' && isElectron(),
cwd: this._serverParams.cwd
});
this._process.on("spawn", () => {
resolve();
});
this._process.on('error', error => {
if (error.name === 'AbortError') {
// Expected when close() is called.
this.onclose?.();
return;
}
this._process.on("close", (_code) => {
reject(error);
this.onerror?.(error);
});
this._process.on('spawn', () => {
resolve();
});
this._process.on('close', _code => {
this._process = undefined;
this.onclose?.();
});
this._process.stdin?.on('error', error => {
this.onerror?.(error);
});
this._process.stdout?.on('data', chunk => {
this._readBuffer.append(chunk);
this.processReadBuffer();
});
this._process.stdout?.on('error', error => {
this.onerror?.(error);
});
if (this._stderrStream && this._process.stderr) {
this._process.stderr.pipe(this._stderrStream);
}
});
}
/**
* The stderr stream of the child process, if `StdioServerParameters.stderr` was set to "pipe" or "overlapped".
*
* If stderr piping was requested, a PassThrough stream is returned _immediately_, allowing callers to
* attach listeners before the start method is invoked. This prevents loss of any early
* error output emitted by the child process.
*/
get stderr(): Stream | null {
if (this._stderrStream) {
return this._stderrStream;
}
return this._process?.stderr ?? null;
}
/**
* The child process pid spawned by this transport.
*
* This is only available after the transport has been started.
*/
get pid(): number | null {
return this._process?.pid ?? null;
}
private processReadBuffer() {
while (true) {
try {
const message = this._readBuffer.readMessage();
if (message === null) {
break;
}
this.onmessage?.(message);
} catch (error) {
this.onerror?.(error as Error);
}
}
}
async close(): Promise<void> {
this._abortController.abort();
this._process = undefined;
this.onclose?.();
});
this._process.stdin?.on("error", (error) => {
this.onerror?.(error);
});
this._process.stdout?.on("data", (chunk) => {
this._readBuffer.append(chunk);
this.processReadBuffer();
});
this._process.stdout?.on("error", (error) => {
this.onerror?.(error);
});
if (this._stderrStream && this._process.stderr) {
this._process.stderr.pipe(this._stderrStream);
}
});
}
/**
* The stderr stream of the child process, if `StdioServerParameters.stderr` was set to "pipe" or "overlapped".
*
* If stderr piping was requested, a PassThrough stream is returned _immediately_, allowing callers to
* attach listeners before the start method is invoked. This prevents loss of any early
* error output emitted by the child process.
*/
get stderr(): Stream | null {
if (this._stderrStream) {
return this._stderrStream;
this._readBuffer.clear();
}
return this._process?.stderr ?? null;
}
send(message: JSONRPCMessage): Promise<void> {
return new Promise(resolve => {
if (!this._process?.stdin) {
throw new Error('Not connected');
}
/**
* The child process pid spawned by this transport.
*
* This is only available after the transport has been started.
*/
get pid(): number | null {
return this._process?.pid ?? null;
}
private processReadBuffer() {
while (true) {
try {
const message = this._readBuffer.readMessage();
if (message === null) {
break;
}
this.onmessage?.(message);
} catch (error) {
this.onerror?.(error as Error);
}
const json = serializeMessage(message);
if (this._process.stdin.write(json)) {
resolve();
} else {
this._process.stdin.once('drain', resolve);
}
});
}
}
async close(): Promise<void> {
this._abortController.abort();
this._process = undefined;
this._readBuffer.clear();
}
send(message: JSONRPCMessage): Promise<void> {
return new Promise((resolve) => {
if (!this._process?.stdin) {
throw new Error("Not connected");
}
const json = serializeMessage(message);
if (this._process.stdin.write(json)) {
resolve();
} else {
this._process.stdin.once("drain", resolve);
}
});
}
}
function isElectron() {
return "type" in process;
return 'type' in process;
}

Some files were not shown because too many files have changed in this diff Show more