Merge branch 'main' into fix/1

This commit is contained in:
오병진 2025-05-26 18:58:12 +09:00 • committed by GitHub
commit 823fc43d4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 3942 additions and 358 deletions

View file

@ -1,7 +1,8 @@
# MCP TypeScript SDK Guide
## Build & Test Commands
```
```sh
npm run build # Build ESM and CJS versions
npm run lint # Run ESLint
npm test # Run all tests
@ -10,6 +11,7 @@ npx jest -t "test name" # Run tests matching pattern
```
## Code Style Guidelines
- **TypeScript**: Strict type checking, ES modules, explicit return types
- **Naming**: PascalCase for classes/types, camelCase for functions/variables
- **Files**: Lowercase with hyphens, test files with `.test.ts` suffix
@ -20,6 +22,7 @@ npx jest -t "test name" # Run tests matching pattern
- **Comments**: JSDoc for public APIs, inline comments for complex logic
## Project Structure
- `/src`: Source code with client, server, and shared modules
- Tests alongside source files with `.test.ts` suffix
- Node.js >= 18 required
- Node.js >= 18 required

View file

@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
mcp-coc@anthropic.com.
<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
@ -116,7 +116,7 @@ the community.
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.
<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).
@ -124,5 +124,5 @@ 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.
<https://www.contributor-covenant.org/faq>. Translations are available at
<https://www.contributor-covenant.org/translations>.

View file

@ -1,9 +1,10 @@
# MCP TypeScript SDK ![NPM Version](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fsdk) ![MIT licensed](https://img.shields.io/npm/l/%40modelcontextprotocol%2Fsdk)
## Table of Contents
- [Overview](#overview)
- [Installation](#installation)
- [Quickstart](#quickstart)
- [Quickstart](#quick-start)
- [What is MCP?](#what-is-mcp)
- [Core Concepts](#core-concepts)
- [Server](#server)
@ -18,11 +19,14 @@
- [Echo Server](#echo-server)
- [SQLite Explorer](#sqlite-explorer)
- [Advanced Usage](#advanced-usage)
- [Dynamic Servers](#dynamic-servers)
- [Low-Level Server](#low-level-server)
- [Writing MCP Clients](#writing-mcp-clients)
- [Server Capabilities](#server-capabilities)
- [Proxy OAuth Server](#proxy-authorization-requests-upstream)
- [Proxy Authorization Requests Upstream](#proxy-authorization-requests-upstream)
- [Backwards Compatibility](#backwards-compatibility)
- [Documentation](#documentation)
- [Contributing](#contributing)
- [License](#license)
## Overview
@ -377,6 +381,7 @@ app.listen(PORT, () => {
```
This stateless approach is useful for:
- Simple API wrappers
- RESTful scenarios where each request is independent
- Horizontally scaled deployments without shared session state
@ -512,7 +517,7 @@ server.tool(
### Dynamic Servers
If you want to offer an initial set of tools/prompts/resources, but later add additional ones based on user action or external state change, you can add/update/remove them _after_ the Server is connected. This will automatically emit the corresponding `listChanged` notificaions:
If you want to offer an initial set of tools/prompts/resources, but later add additional ones based on user action or external state change, you can add/update/remove them _after_ the Server is connected. This will automatically emit the corresponding `listChanged` notifications:
```ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@ -543,7 +548,7 @@ putMessageTool.disable()
const upgradeAuthTool = server.tool(
"upgradeAuth",
{ permission: z.enum(["write', vadmin"])},
{ permission: z.enum(["write', admin"])},
// Any mutations here will automatically emit `listChanged` notifications
async ({ permission }) => {
const { ok, err, previous } = await upgradeAuthAndStoreToken(permission)
@ -686,7 +691,8 @@ You can proxy OAuth requests to an external authorization provider:
```typescript
import express from 'express';
import { ProxyOAuthServerProvider, mcpAuthRouter } from '@modelcontextprotocol/sdk';
import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js';
import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
const app = express();
@ -720,6 +726,7 @@ app.use(mcpAuthRouter({
```
This setup allows you to:
- Forward OAuth requests to an external provider
- Add custom token validation logic
- Manage client registrations
@ -833,7 +840,7 @@ app.listen(3000);
## Contributing
Issues and pull requests are welcome on GitHub at https://github.com/modelcontextprotocol/typescript-sdk.
Issues and pull requests are welcome on GitHub at <https://github.com/modelcontextprotocol/typescript-sdk>.
## License

View file

@ -1,4 +1,5 @@
# Security Policy
Thank you for helping us keep the SDKs and systems they interact with secure.
## Reporting Security Issues

21
package-lock.json generated
View file

@ -1,17 +1,18 @@
{
"name": "@modelcontextprotocol/sdk",
"version": "1.9.0",
"version": "1.11.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@modelcontextprotocol/sdk",
"version": "1.9.0",
"version": "1.11.4",
"license": "MIT",
"dependencies": {
"ajv": "^6.12.6",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.3",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
@ -2201,7 +2202,7 @@
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@ -3450,8 +3451,7 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-glob": {
"version": "3.3.2",
@ -3484,8 +3484,7 @@
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
@ -4883,7 +4882,7 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
@ -5557,7 +5556,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
@ -6444,7 +6443,7 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
}

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/sdk",
"version": "1.11.1",
"version": "1.12.0",
"description": "Model Context Protocol implementation for TypeScript",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@ -46,9 +46,10 @@
"client": "tsx src/cli.ts client"
},
"dependencies": {
"ajv": "^6.12.6",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.3",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",

View file

@ -4,6 +4,10 @@ import {
exchangeAuthorization,
refreshAuthorization,
registerClient,
discoverOAuthProtectedResourceMetadata,
extractResourceMetadataUrl,
auth,
type OAuthClientProvider,
} from "./auth.js";
// Mock fetch globally
@ -15,6 +19,165 @@ describe("OAuth Authorization", () => {
mockFetch.mockReset();
});
describe("extractResourceMetadataUrl", () => {
it("returns resource metadata url when present", async () => {
const resourceUrl = "https://resource.example.com/.well-known/oauth-protected-resource"
const mockResponse = {
headers: {
get: jest.fn((name) => name === "WWW-Authenticate" ? `Bearer realm="mcp", resource_metadata="${resourceUrl}"` : null),
}
} as unknown as Response
expect(extractResourceMetadataUrl(mockResponse)).toEqual(new URL(resourceUrl));
});
it("returns undefined if not bearer", async () => {
const resourceUrl = "https://resource.example.com/.well-known/oauth-protected-resource"
const mockResponse = {
headers: {
get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null),
}
} as unknown as Response
expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
});
it("returns undefined if resource_metadata not present", async () => {
const mockResponse = {
headers: {
get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp"` : null),
}
} as unknown as Response
expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
});
it("returns undefined on invalid url", async () => {
const resourceUrl = "invalid-url"
const mockResponse = {
headers: {
get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null),
}
} as unknown as Response
expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
});
});
describe("discoverOAuthProtectedResourceMetadata", () => {
const validMetadata = {
resource: "https://resource.example.com",
authorization_servers: ["https://auth.example.com"],
};
it("returns metadata when discovery succeeds", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validMetadata,
});
const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com");
expect(metadata).toEqual(validMetadata);
const calls = mockFetch.mock.calls;
expect(calls.length).toBe(1);
const [url] = calls[0];
expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
});
it("returns metadata when first fetch fails but second without MCP header succeeds", async () => {
// Set up a counter to control behavior
let callCount = 0;
// Mock implementation that changes behavior based on call count
mockFetch.mockImplementation((_url, _options) => {
callCount++;
if (callCount === 1) {
// First call with MCP header - fail with TypeError (simulating CORS error)
// We need to use TypeError specifically because that's what the implementation checks for
return Promise.reject(new TypeError("Network error"));
} else {
// Second call without header - succeed
return Promise.resolve({
ok: true,
status: 200,
json: async () => validMetadata
});
}
});
// Should succeed with the second call
const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com");
expect(metadata).toEqual(validMetadata);
// Verify both calls were made
expect(mockFetch).toHaveBeenCalledTimes(2);
// Verify first call had MCP header
expect(mockFetch.mock.calls[0][1]?.headers).toHaveProperty("MCP-Protocol-Version");
});
it("throws an error when all fetch attempts fail", async () => {
// Set up a counter to control behavior
let callCount = 0;
// Mock implementation that changes behavior based on call count
mockFetch.mockImplementation((_url, _options) => {
callCount++;
if (callCount === 1) {
// First call - fail with TypeError
return Promise.reject(new TypeError("First failure"));
} else {
// Second call - fail with different error
return Promise.reject(new Error("Second failure"));
}
});
// Should fail with the second error
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
.rejects.toThrow("Second failure");
// Verify both calls were made
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("throws on 404 errors", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
});
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
.rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
});
it("throws on non-404 errors", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
});
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
.rejects.toThrow("HTTP 500");
});
it("validates metadata schema", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
// Missing required fields
scopes_supported: ["email", "mcp"],
}),
});
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
.rejects.toThrow();
});
});
describe("discoverOAuthMetadata", () => {
const validMetadata = {
issuer: "https://auth.example.com",
@ -158,6 +321,7 @@ describe("OAuth Authorization", () => {
const { authorizationUrl, codeVerifier } = await startAuthorization(
"https://auth.example.com",
{
metadata: undefined,
clientInformation: validClientInfo,
redirectUrl: "http://localhost:3000/callback",
}
@ -177,6 +341,56 @@ describe("OAuth Authorization", () => {
expect(codeVerifier).toBe("test_verifier");
});
it("includes scope parameter when provided", async () => {
const { authorizationUrl } = await startAuthorization(
"https://auth.example.com",
{
clientInformation: validClientInfo,
redirectUrl: "http://localhost:3000/callback",
scope: "read write profile",
}
);
expect(authorizationUrl.searchParams.get("scope")).toBe("read write profile");
});
it("excludes scope parameter when not provided", async () => {
const { authorizationUrl } = await startAuthorization(
"https://auth.example.com",
{
clientInformation: validClientInfo,
redirectUrl: "http://localhost:3000/callback",
}
);
expect(authorizationUrl.searchParams.has("scope")).toBe(false);
});
it("includes state parameter when provided", async () => {
const { authorizationUrl } = await startAuthorization(
"https://auth.example.com",
{
clientInformation: validClientInfo,
redirectUrl: "http://localhost:3000/callback",
state: "foobar",
}
);
expect(authorizationUrl.searchParams.get("state")).toBe("foobar");
});
it("excludes state parameter when not provided", async () => {
const { authorizationUrl } = await startAuthorization(
"https://auth.example.com",
{
clientInformation: validClientInfo,
redirectUrl: "http://localhost:3000/callback",
}
);
expect(authorizationUrl.searchParams.has("state")).toBe(false);
});
it("uses metadata authorization_endpoint when provided", async () => {
const { authorizationUrl } = await startAuthorization(
"https://auth.example.com",
@ -317,6 +531,9 @@ describe("OAuth Authorization", () => {
access_token: "newaccess123",
token_type: "Bearer",
expires_in: 3600,
}
const validTokensWithNewRefreshToken = {
...validTokens,
refresh_token: "newrefresh123",
};
@ -331,7 +548,7 @@ describe("OAuth Authorization", () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validTokens,
json: async () => validTokensWithNewRefreshToken,
});
const tokens = await refreshAuthorization("https://auth.example.com", {
@ -339,7 +556,7 @@ describe("OAuth Authorization", () => {
refreshToken: "refresh123",
});
expect(tokens).toEqual(validTokens);
expect(tokens).toEqual(validTokensWithNewRefreshToken);
expect(mockFetch).toHaveBeenCalledWith(
expect.objectContaining({
href: "https://auth.example.com/token",
@ -359,6 +576,22 @@ describe("OAuth Authorization", () => {
expect(body.get("client_secret")).toBe("secret123");
});
it("exchanges refresh token for new tokens and keep existing refresh token if none is returned", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validTokens,
});
const refreshToken = "refresh123";
const tokens = await refreshAuthorization("https://auth.example.com", {
clientInformation: validClientInfo,
refreshToken,
});
expect(tokens).toEqual({ refresh_token: refreshToken, ...validTokens });
});
it("validates token response schema", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
@ -478,4 +711,101 @@ describe("OAuth Authorization", () => {
).rejects.toThrow("Dynamic client registration failed");
});
});
describe("auth function", () => {
const mockProvider: OAuthClientProvider = {
get redirectUrl() { return "http://localhost:3000/callback"; },
get clientMetadata() {
return {
redirect_uris: ["http://localhost:3000/callback"],
client_name: "Test Client",
};
},
clientInformation: jest.fn(),
tokens: jest.fn(),
saveTokens: jest.fn(),
redirectToAuthorization: jest.fn(),
saveCodeVerifier: jest.fn(),
codeVerifier: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it("falls back to /.well-known/oauth-authorization-server when no protected-resource-metadata", async () => {
// Setup: First call to protected resource metadata fails (404)
// Second call to auth server metadata succeeds
let callCount = 0;
mockFetch.mockImplementation((url) => {
callCount++;
const urlString = url.toString();
if (callCount === 1 && urlString.includes("/.well-known/oauth-protected-resource")) {
// First call - protected resource metadata fails with 404
return Promise.resolve({
ok: false,
status: 404,
});
} else if (callCount === 2 && urlString.includes("/.well-known/oauth-authorization-server")) {
// Second call - auth server metadata succeeds
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
registration_endpoint: "https://auth.example.com/register",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
});
} else if (callCount === 3 && urlString.includes("/register")) {
// Third call - client registration succeeds
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
client_id: "test-client-id",
client_secret: "test-client-secret",
client_id_issued_at: 1612137600,
client_secret_expires_at: 1612224000,
redirect_uris: ["http://localhost:3000/callback"],
client_name: "Test Client",
}),
});
}
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
});
// Mock provider methods
(mockProvider.clientInformation as jest.Mock).mockResolvedValue(undefined);
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
mockProvider.saveClientInformation = jest.fn();
// Call the auth function
const result = await auth(mockProvider, {
serverUrl: "https://resource.example.com",
});
// Verify the result
expect(result).toBe("REDIRECT");
// Verify the sequence of calls
expect(mockFetch).toHaveBeenCalledTimes(3);
// First call should be to protected resource metadata
expect(mockFetch.mock.calls[0][0].toString()).toBe(
"https://resource.example.com/.well-known/oauth-protected-resource"
);
// Second call should be to oauth metadata
expect(mockFetch.mock.calls[1][0].toString()).toBe(
"https://resource.example.com/.well-known/oauth-authorization-server"
);
});
});
});

View file

@ -1,11 +1,11 @@
import pkceChallenge from "pkce-challenge";
import { LATEST_PROTOCOL_VERSION } from "../types.js";
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull } from "../shared/auth.js";
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js";
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
/**
* Implements an end-to-end OAuth client to be used with one MCP server.
*
*
* This client relies upon a concept of an authorized "session," the exact
* meaning of which is application-defined. Tokens, authorization codes, and
* code verifiers should not cross different sessions.
@ -21,6 +21,11 @@ export interface OAuthClientProvider {
*/
get clientMetadata(): OAuthClientMetadata;
/**
* Returns a OAuth2 state parameter.
*/
state?(): string | Promise<string>;
/**
* Loads information about this OAuth client, as registered already with the
* server, or returns `undefined` if the client is not registered with the
@ -32,7 +37,7 @@ export interface OAuthClientProvider {
* If implemented, this permits the OAuth client to dynamically register with
* the server. Client information saved this way should later be read via
* `clientInformation()`.
*
*
* This method is not required to be implemented if client information is
* statically known (e.g., pre-registered).
*/
@ -78,14 +83,35 @@ export class UnauthorizedError extends Error {
/**
* Orchestrates the full auth flow with a server.
*
*
* This can be used as a single entry point for all authorization functionality,
* instead of linking together the other lower-level functions in this module.
*/
export async function auth(
provider: OAuthClientProvider,
{ serverUrl, authorizationCode }: { serverUrl: string | URL, authorizationCode?: string }): Promise<AuthResult> {
const metadata = await discoverOAuthMetadata(serverUrl);
{ serverUrl,
authorizationCode,
scope,
resourceMetadataUrl
}: {
serverUrl: string | URL;
authorizationCode?: string;
scope?: string;
resourceMetadataUrl?: URL }): Promise<AuthResult> {
let authorizationServerUrl = serverUrl;
try {
const resourceMetadata = await discoverOAuthProtectedResourceMetadata(
resourceMetadataUrl || serverUrl);
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
authorizationServerUrl = resourceMetadata.authorization_servers[0];
}
} catch (error) {
console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error)
}
const metadata = await discoverOAuthMetadata(authorizationServerUrl);
// Handle client registration if needed
let clientInformation = await Promise.resolve(provider.clientInformation());
@ -98,7 +124,7 @@ export async function auth(
throw new Error("OAuth client information must be saveable for dynamic registration");
}
const fullInformation = await registerClient(serverUrl, {
const fullInformation = await registerClient(authorizationServerUrl, {
metadata,
clientMetadata: provider.clientMetadata,
});
@ -110,7 +136,7 @@ export async function auth(
// Exchange authorization code for tokens
if (authorizationCode !== undefined) {
const codeVerifier = await provider.codeVerifier();
const tokens = await exchangeAuthorization(serverUrl, {
const tokens = await exchangeAuthorization(authorizationServerUrl, {
metadata,
clientInformation,
authorizationCode,
@ -128,7 +154,7 @@ export async function auth(
if (tokens?.refresh_token) {
try {
// Attempt to refresh the token
const newTokens = await refreshAuthorization(serverUrl, {
const newTokens = await refreshAuthorization(authorizationServerUrl, {
metadata,
clientInformation,
refreshToken: tokens.refresh_token,
@ -141,11 +167,15 @@ export async function auth(
}
}
const state = provider.state ? await provider.state() : undefined;
// Start new authorization flow
const { authorizationUrl, codeVerifier } = await startAuthorization(serverUrl, {
const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, {
metadata,
clientInformation,
redirectUrl: provider.redirectUrl
state,
redirectUrl: provider.redirectUrl,
scope: scope || provider.clientMetadata.scope,
});
await provider.saveCodeVerifier(codeVerifier);
@ -153,6 +183,82 @@ export async function auth(
return "REDIRECT";
}
/**
* Extract resource_metadata from response header.
*/
export function extractResourceMetadataUrl(res: Response): URL | undefined {
const authenticateHeader = res.headers.get("WWW-Authenticate");
if (!authenticateHeader) {
return undefined;
}
const [type, scheme] = authenticateHeader.split(' ');
if (type.toLowerCase() !== 'bearer' || !scheme) {
console.log("Invalid WWW-Authenticate header format, expected 'Bearer'");
return undefined;
}
const regex = /resource_metadata="([^"]*)"/;
const match = regex.exec(authenticateHeader);
if (!match) {
return undefined;
}
try {
return new URL(match[1]);
} catch {
console.log("Invalid resource metadata url: ", match[1]);
return undefined;
}
}
/**
* Looks up RFC 9728 OAuth 2.0 Protected Resource Metadata.
*
* If the server returns a 404 for the well-known endpoint, this function will
* return `undefined`. Any other errors will be thrown as exceptions.
*/
export async function discoverOAuthProtectedResourceMetadata(
serverUrl: string | URL,
opts?: { protocolVersion?: string, resourceMetadataUrl?: string | URL },
): Promise<OAuthProtectedResourceMetadata> {
let url: URL
if (opts?.resourceMetadataUrl) {
url = new URL(opts?.resourceMetadataUrl);
} else {
url = new URL("/.well-known/oauth-protected-resource", serverUrl);
}
let response: Response;
try {
response = await fetch(url, {
headers: {
"MCP-Protocol-Version": opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION
}
});
} catch (error) {
// CORS errors come back as TypeError
if (error instanceof TypeError) {
response = await fetch(url);
} else {
throw error;
}
}
if (response.status === 404) {
throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`);
}
if (!response.ok) {
throw new Error(
`HTTP ${response.status} trying to load well-known OAuth protected resource metadata.`,
);
}
return OAuthProtectedResourceMetadataSchema.parse(await response.json());
}
/**
* Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata.
*
@ -160,10 +266,10 @@ export async function auth(
* return `undefined`. Any other errors will be thrown as exceptions.
*/
export async function discoverOAuthMetadata(
serverUrl: string | URL,
authorizationServerUrl: string | URL,
opts?: { protocolVersion?: string },
): Promise<OAuthMetadata | undefined> {
const url = new URL("/.well-known/oauth-authorization-server", serverUrl);
const url = new URL("/.well-known/oauth-authorization-server", authorizationServerUrl);
let response: Response;
try {
response = await fetch(url, {
@ -197,15 +303,19 @@ export async function discoverOAuthMetadata(
* Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL.
*/
export async function startAuthorization(
serverUrl: string | URL,
authorizationServerUrl: string | URL,
{
metadata,
clientInformation,
redirectUrl,
scope,
state,
}: {
metadata?: OAuthMetadata;
clientInformation: OAuthClientInformation;
redirectUrl: string | URL;
scope?: string;
state?: string;
},
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
const responseType = "code";
@ -230,7 +340,7 @@ export async function startAuthorization(
);
}
} else {
authorizationUrl = new URL("/authorize", serverUrl);
authorizationUrl = new URL("/authorize", authorizationServerUrl);
}
// Generate PKCE challenge
@ -247,6 +357,14 @@ export async function startAuthorization(
);
authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl));
if (state) {
authorizationUrl.searchParams.set("state", state);
}
if (scope) {
authorizationUrl.searchParams.set("scope", scope);
}
return { authorizationUrl, codeVerifier };
}
@ -254,7 +372,7 @@ export async function startAuthorization(
* Exchanges an authorization code for an access token with the given server.
*/
export async function exchangeAuthorization(
serverUrl: string | URL,
authorizationServerUrl: string | URL,
{
metadata,
clientInformation,
@ -284,7 +402,7 @@ export async function exchangeAuthorization(
);
}
} else {
tokenUrl = new URL("/token", serverUrl);
tokenUrl = new URL("/token", authorizationServerUrl);
}
// Exchange code for tokens
@ -319,7 +437,7 @@ export async function exchangeAuthorization(
* Exchange a refresh token for an updated access token.
*/
export async function refreshAuthorization(
serverUrl: string | URL,
authorizationServerUrl: string | URL,
{
metadata,
clientInformation,
@ -345,7 +463,7 @@ export async function refreshAuthorization(
);
}
} else {
tokenUrl = new URL("/token", serverUrl);
tokenUrl = new URL("/token", authorizationServerUrl);
}
// Exchange refresh token
@ -366,19 +484,18 @@ export async function refreshAuthorization(
},
body: params,
});
if (!response.ok) {
throw new Error(`Token refresh failed: HTTP ${response.status}`);
}
return OAuthTokensSchema.parse(await response.json());
return OAuthTokensSchema.parse({ refresh_token: refreshToken, ...(await response.json()) });
}
/**
* Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591.
*/
export async function registerClient(
serverUrl: string | URL,
authorizationServerUrl: string | URL,
{
metadata,
clientMetadata,
@ -396,7 +513,7 @@ export async function registerClient(
registrationUrl = new URL(metadata.registration_endpoint);
} else {
registrationUrl = new URL("/register", serverUrl);
registrationUrl = new URL("/register", authorizationServerUrl);
}
const response = await fetch(registrationUrl, {
@ -412,4 +529,4 @@ export async function registerClient(
}
return OAuthClientInformationFullSchema.parse(await response.json());
}
}

View file

@ -12,6 +12,7 @@ import {
InitializeRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
CallToolRequestSchema,
CreateMessageRequestSchema,
ListRootsRequestSchema,
ErrorCode,
@ -20,6 +21,9 @@ import { Transport } from "../shared/transport.js";
import { Server } from "../server/index.js";
import { InMemoryTransport } from "../inMemory.js";
/***
* Test: Initialize with Matching Protocol Version
*/
test("should initialize with matching protocol version", async () => {
const clientTransport: Transport = {
start: jest.fn().mockResolvedValue(undefined),
@ -75,6 +79,9 @@ test("should initialize with matching protocol version", async () => {
expect(client.getInstructions()).toEqual("test instructions");
});
/***
* Test: Initialize with Supported Older Protocol Version
*/
test("should initialize with supported older protocol version", async () => {
const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1];
const clientTransport: Transport = {
@ -123,6 +130,9 @@ test("should initialize with supported older protocol version", async () => {
expect(client.getInstructions()).toBeUndefined();
});
/***
* Test: Reject Unsupported Protocol Version
*/
test("should reject unsupported protocol version", async () => {
const clientTransport: Transport = {
start: jest.fn().mockResolvedValue(undefined),
@ -165,6 +175,9 @@ test("should reject unsupported protocol version", async () => {
expect(clientTransport.close).toHaveBeenCalled();
});
/***
* Test: Connect New Client to Old Supported Server Version
*/
test("should connect new client to old, supported server version", async () => {
const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1];
const server = new Server(
@ -228,6 +241,9 @@ test("should connect new client to old, supported server version", async () => {
});
});
/***
* Test: Version Negotiation with Old Client and Newer Server
*/
test("should negotiate version when client is old, and newer server supports its version", async () => {
const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1];
const server = new Server(
@ -291,6 +307,9 @@ test("should negotiate version when client is old, and newer server supports its
});
});
/***
* Test: Throw when Old Client and Server Version Mismatch
*/
test("should throw when client is old, and server doesn't support its version", async () => {
const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1];
const FUTURE_VERSION = "FUTURE_VERSION";
@ -353,6 +372,9 @@ test("should throw when client is old, and server doesn't support its version",
});
/***
* Test: Respect Server Capabilities
*/
test("should respect server capabilities", async () => {
const server = new Server(
{
@ -433,6 +455,9 @@ test("should respect server capabilities", async () => {
).rejects.toThrow("Server does not support completions");
});
/***
* Test: Respect Client Notification Capabilities
*/
test("should respect client notification capabilities", async () => {
const server = new Server(
{
@ -489,6 +514,9 @@ test("should respect client notification capabilities", async () => {
);
});
/***
* Test: Respect Server Notification Capabilities
*/
test("should respect server notification capabilities", async () => {
const server = new Server(
{
@ -535,6 +563,9 @@ test("should respect server notification capabilities", async () => {
);
});
/***
* Test: Only Allow setRequestHandler for Declared Capabilities
*/
test("should only allow setRequestHandler for declared capabilities", () => {
const client = new Client(
{
@ -566,9 +597,10 @@ test("should only allow setRequestHandler for declared capabilities", () => {
}).toThrow("Client does not support roots capability");
});
/*
Test that custom request/notification/result schemas can be used with the Client class.
*/
/***
* Test: Type Checking
* Test that custom request/notification/result schemas can be used with the Client class.
*/
test("should typecheck", () => {
const GetWeatherRequestSchema = RequestSchema.extend({
method: z.literal("weather/get"),
@ -645,6 +677,9 @@ test("should typecheck", () => {
});
});
/***
* Test: Handle Client Cancelling a Request
*/
test("should handle client cancelling a request", async () => {
const server = new Server(
{
@ -700,6 +735,9 @@ test("should handle client cancelling a request", async () => {
await expect(listResourcesPromise).rejects.toBe("Cancelled by test");
});
/***
* Test: Handle Request Timeout
*/
test("should handle request timeout", async () => {
const server = new Server(
{
@ -754,3 +792,474 @@ test("should handle request timeout", async () => {
code: ErrorCode.RequestTimeout,
});
});
describe('outputSchema validation', () => {
/***
* Test: Validate structuredContent Against outputSchema
*/
test('should validate structuredContent against outputSchema', async () => {
const server = new Server({
name: 'test-server',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
// Set up server handlers
server.setRequestHandler(InitializeRequestSchema, async (request) => ({
protocolVersion: request.params.protocolVersion,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0',
}
}));
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'test-tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {},
},
outputSchema: {
type: 'object',
properties: {
result: { type: 'string' },
count: { type: 'number' },
},
required: ['result', 'count'],
additionalProperties: false,
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'test-tool') {
return {
structuredContent: { result: 'success', count: 42 },
};
}
throw new Error('Unknown tool');
});
const client = new Client({
name: 'test-client',
version: '1.0.0',
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
// List tools to cache the schemas
await client.listTools();
// Call the tool - should validate successfully
const result = await client.callTool({ name: 'test-tool' });
expect(result.structuredContent).toEqual({ result: 'success', count: 42 });
});
/***
* Test: Throw Error when structuredContent Does Not Match Schema
*/
test('should throw error when structuredContent does not match schema', async () => {
const server = new Server({
name: 'test-server',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
// Set up server handlers
server.setRequestHandler(InitializeRequestSchema, async (request) => ({
protocolVersion: request.params.protocolVersion,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0',
}
}));
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'test-tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {},
},
outputSchema: {
type: 'object',
properties: {
result: { type: 'string' },
count: { type: 'number' },
},
required: ['result', 'count'],
additionalProperties: false,
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'test-tool') {
// Return invalid structured content (count is string instead of number)
return {
structuredContent: { result: 'success', count: 'not a number' },
};
}
throw new Error('Unknown tool');
});
const client = new Client({
name: 'test-client',
version: '1.0.0',
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
// List tools to cache the schemas
await client.listTools();
// Call the tool - should throw validation error
await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow(
/Structured content does not match the tool's output schema/
);
});
/***
* Test: Throw Error when Tool with outputSchema Returns No structuredContent
*/
test('should throw error when tool with outputSchema returns no structuredContent', async () => {
const server = new Server({
name: 'test-server',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
// Set up server handlers
server.setRequestHandler(InitializeRequestSchema, async (request) => ({
protocolVersion: request.params.protocolVersion,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0',
}
}));
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'test-tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {},
},
outputSchema: {
type: 'object',
properties: {
result: { type: 'string' },
},
required: ['result'],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'test-tool') {
// Return content instead of structuredContent
return {
content: [{ type: 'text', text: 'This should be structured content' }],
};
}
throw new Error('Unknown tool');
});
const client = new Client({
name: 'test-client',
version: '1.0.0',
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
// List tools to cache the schemas
await client.listTools();
// Call the tool - should throw error
await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow(
/Tool test-tool has an output schema but did not return structured content/
);
});
/***
* Test: Handle Tools Without outputSchema Normally
*/
test('should handle tools without outputSchema normally', async () => {
const server = new Server({
name: 'test-server',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
// Set up server handlers
server.setRequestHandler(InitializeRequestSchema, async (request) => ({
protocolVersion: request.params.protocolVersion,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0',
}
}));
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'test-tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {},
},
// No outputSchema
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'test-tool') {
// Return regular content
return {
content: [{ type: 'text', text: 'Normal response' }],
};
}
throw new Error('Unknown tool');
});
const client = new Client({
name: 'test-client',
version: '1.0.0',
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
// List tools to cache the schemas
await client.listTools();
// Call the tool - should work normally without validation
const result = await client.callTool({ name: 'test-tool' });
expect(result.content).toEqual([{ type: 'text', text: 'Normal response' }]);
});
/***
* Test: Handle Complex JSON Schema Validation
*/
test('should handle complex JSON schema validation', async () => {
const server = new Server({
name: 'test-server',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
// Set up server handlers
server.setRequestHandler(InitializeRequestSchema, async (request) => ({
protocolVersion: request.params.protocolVersion,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0',
}
}));
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'complex-tool',
description: 'A tool with complex schema',
inputSchema: {
type: 'object',
properties: {},
},
outputSchema: {
type: 'object',
properties: {
name: { type: 'string', minLength: 3 },
age: { type: 'integer', minimum: 0, maximum: 120 },
active: { type: 'boolean' },
tags: {
type: 'array',
items: { type: 'string' },
minItems: 1,
},
metadata: {
type: 'object',
properties: {
created: { type: 'string' },
},
required: ['created'],
},
},
required: ['name', 'age', 'active', 'tags', 'metadata'],
additionalProperties: false,
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'complex-tool') {
return {
structuredContent: {
name: 'John Doe',
age: 30,
active: true,
tags: ['user', 'admin'],
metadata: {
created: '2023-01-01T00:00:00Z',
},
},
};
}
throw new Error('Unknown tool');
});
const client = new Client({
name: 'test-client',
version: '1.0.0',
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
// List tools to cache the schemas
await client.listTools();
// Call the tool - should validate successfully
const result = await client.callTool({ name: 'complex-tool' });
expect(result.structuredContent).toBeDefined();
const structuredContent = result.structuredContent as { name: string; age: number };
expect(structuredContent.name).toBe('John Doe');
expect(structuredContent.age).toBe(30);
});
/***
* Test: Fail Validation with Additional Properties When Not Allowed
*/
test('should fail validation with additional properties when not allowed', async () => {
const server = new Server({
name: 'test-server',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
// Set up server handlers
server.setRequestHandler(InitializeRequestSchema, async (request) => ({
protocolVersion: request.params.protocolVersion,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0',
}
}));
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'strict-tool',
description: 'A tool with strict schema',
inputSchema: {
type: 'object',
properties: {},
},
outputSchema: {
type: 'object',
properties: {
name: { type: 'string' },
},
required: ['name'],
additionalProperties: false,
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'strict-tool') {
// Return structured content with extra property
return {
structuredContent: {
name: 'John',
extraField: 'not allowed',
},
};
}
throw new Error('Unknown tool');
});
const client = new Client({
name: 'test-client',
version: '1.0.0',
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
// List tools to cache the schemas
await client.listTools();
// Call the tool - should throw validation error due to additional property
await expect(client.callTool({ name: 'strict-tool' })).rejects.toThrow(
/Structured content does not match the tool's output schema/
);
});
});

View file

@ -39,7 +39,12 @@ import {
SubscribeRequest,
SUPPORTED_PROTOCOL_VERSIONS,
UnsubscribeRequest,
Tool,
ErrorCode,
McpError,
} from "../types.js";
import Ajv from "ajv";
import type { ValidateFunction } from "ajv";
export type ClientOptions = ProtocolOptions & {
/**
@ -86,6 +91,8 @@ export class Client<
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.
@ -96,6 +103,7 @@ export class Client<
) {
super(options);
this._capabilities = options?.capabilities ?? {};
this._ajv = new Ajv();
}
/**
@ -413,22 +421,84 @@ export class Client<
| typeof CompatibilityCallToolResultSchema = CallToolResultSchema,
options?: RequestOptions,
) {
return this.request(
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 (error) {
console.warn(`Failed to compile output schema for tool ${tool.name}: ${error}`);
}
}
}
}
private getToolOutputValidator(toolName: string): ValidateFunction | undefined {
return this._cachedToolOutputValidators.get(toolName);
}
async listTools(
params?: ListToolsRequest["params"],
options?: RequestOptions,
) {
return this.request(
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() {

View file

@ -6,9 +6,11 @@ import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { OAuthTokens } from "../shared/auth.js";
describe("SSEClientTransport", () => {
let server: Server;
let resourceServer: Server;
let authServer: Server;
let transport: SSEClientTransport;
let baseUrl: URL;
let resourceBaseUrl: URL;
let authBaseUrl: URL;
let lastServerRequest: IncomingMessage;
let sendServerMessage: ((message: string) => void) | null = null;
@ -17,8 +19,26 @@ describe("SSEClientTransport", () => {
lastServerRequest = null as unknown as IncomingMessage;
sendServerMessage = null;
authServer = createServer((req, res) => {
if (req.url === "/.well-known/oauth-authorization-server") {
res.writeHead(200, {
"Content-Type": "application/json"
});
res.end(JSON.stringify({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
registration_endpoint: "https://auth.example.com/register",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}));
return;
}
res.writeHead(401).end();
});
// Create a test server that will receive the EventSource connection
server = createServer((req, res) => {
resourceServer = createServer((req, res) => {
lastServerRequest = req;
// Send SSE headers
@ -30,7 +50,7 @@ describe("SSEClientTransport", () => {
// Send the endpoint event
res.write("event: endpoint\n");
res.write(`data: ${baseUrl.href}\n\n`);
res.write(`data: ${resourceBaseUrl.href}\n\n`);
// Store reference to send function for tests
sendServerMessage = (message: string) => {
@ -51,9 +71,9 @@ describe("SSEClientTransport", () => {
});
// Start server on random port
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resourceServer.listen(0, "127.0.0.1", () => {
const addr = resourceServer.address() as AddressInfo;
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
done();
});
@ -62,14 +82,15 @@ describe("SSEClientTransport", () => {
afterEach(async () => {
await transport.close();
await server.close();
await resourceServer.close();
await authServer.close();
jest.clearAllMocks();
});
describe("connection handling", () => {
it("establishes SSE connection and receives endpoint", async () => {
transport = new SSEClientTransport(baseUrl);
transport = new SSEClientTransport(resourceBaseUrl);
await transport.start();
expect(lastServerRequest.headers.accept).toBe("text/event-stream");
@ -78,27 +99,27 @@ describe("SSEClientTransport", () => {
it("rejects if server returns non-200 status", async () => {
// Create a server that returns 403
await server.close();
await resourceServer.close();
server = createServer((req, res) => {
resourceServer = createServer((req, res) => {
res.writeHead(403);
res.end();
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resourceServer.listen(0, "127.0.0.1", () => {
const addr = resourceServer.address() as AddressInfo;
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl);
transport = new SSEClientTransport(resourceBaseUrl);
await expect(transport.start()).rejects.toThrow();
});
it("closes EventSource connection on close()", async () => {
transport = new SSEClientTransport(baseUrl);
transport = new SSEClientTransport(resourceBaseUrl);
await transport.start();
const closePromise = new Promise((resolve) => {
@ -113,7 +134,7 @@ describe("SSEClientTransport", () => {
describe("message handling", () => {
it("receives and parses JSON-RPC messages", async () => {
const receivedMessages: JSONRPCMessage[] = [];
transport = new SSEClientTransport(baseUrl);
transport = new SSEClientTransport(resourceBaseUrl);
transport.onmessage = (msg) => receivedMessages.push(msg);
await transport.start();
@ -136,7 +157,7 @@ describe("SSEClientTransport", () => {
it("handles malformed JSON messages", async () => {
const errors: Error[] = [];
transport = new SSEClientTransport(baseUrl);
transport = new SSEClientTransport(resourceBaseUrl);
transport.onerror = (err) => errors.push(err);
await transport.start();
@ -151,7 +172,7 @@ describe("SSEClientTransport", () => {
});
it("handles messages via POST requests", async () => {
transport = new SSEClientTransport(baseUrl);
transport = new SSEClientTransport(resourceBaseUrl);
await transport.start();
const testMessage: JSONRPCMessage = {
@ -179,9 +200,9 @@ describe("SSEClientTransport", () => {
it("handles POST request failures", async () => {
// Create a server that returns 500 for POST
await server.close();
await resourceServer.close();
server = createServer((req, res) => {
resourceServer = createServer((req, res) => {
if (req.method === "GET") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
@ -189,7 +210,7 @@ describe("SSEClientTransport", () => {
Connection: "keep-alive",
});
res.write("event: endpoint\n");
res.write(`data: ${baseUrl.href}\n\n`);
res.write(`data: ${resourceBaseUrl.href}\n\n`);
} else {
res.writeHead(500);
res.end("Internal error");
@ -197,14 +218,14 @@ describe("SSEClientTransport", () => {
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resourceServer.listen(0, "127.0.0.1", () => {
const addr = resourceServer.address() as AddressInfo;
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl);
transport = new SSEClientTransport(resourceBaseUrl);
await transport.start();
const testMessage: JSONRPCMessage = {
@ -229,7 +250,7 @@ describe("SSEClientTransport", () => {
return fetch(url.toString(), { ...init, headers });
};
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
eventSourceInit: {
fetch: fetchWithAuth,
},
@ -247,7 +268,7 @@ describe("SSEClientTransport", () => {
"X-Custom-Header": "custom-value",
};
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
requestInit: {
headers: customHeaders,
},
@ -319,7 +340,7 @@ describe("SSEClientTransport", () => {
token_type: "Bearer"
});
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
});
@ -335,7 +356,7 @@ describe("SSEClientTransport", () => {
token_type: "Bearer"
});
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
});
@ -355,27 +376,50 @@ describe("SSEClientTransport", () => {
});
it("attempts auth flow on 401 during SSE connection", async () => {
// Create server that returns 401s
await server.close();
server = createServer((req, res) => {
// Create server that returns 401s
resourceServer.close();
authServer.close();
// Start auth server on random port
await new Promise<void>(resolve => {
authServer.listen(0, "127.0.0.1", () => {
const addr = authServer.address() as AddressInfo;
authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
resourceServer = createServer((req, res) => {
lastServerRequest = req;
if (req.url === "/.well-known/oauth-protected-resource") {
res.writeHead(200, {
'Content-Type': 'application/json',
})
.end(JSON.stringify({
resource: "https://resource.example.com",
authorization_servers: [`${authBaseUrl}`],
}));
return;
}
if (req.url !== "/") {
res.writeHead(404).end();
res.writeHead(404).end();
} else {
res.writeHead(401).end();
}
});
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resourceServer.listen(0, "127.0.0.1", () => {
const addr = resourceServer.address() as AddressInfo;
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
});
@ -385,13 +429,33 @@ describe("SSEClientTransport", () => {
it("attempts auth flow on 401 during POST request", async () => {
// Create server that accepts SSE but returns 401 on POST
await server.close();
resourceServer.close();
authServer.close();
server = createServer((req, res) => {
await new Promise<void>(resolve => {
authServer.listen(0, "127.0.0.1", () => {
const addr = authServer.address() as AddressInfo;
authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
resourceServer = createServer((req, res) => {
lastServerRequest = req;
switch (req.method) {
case "GET":
if (req.url === "/.well-known/oauth-protected-resource") {
res.writeHead(200, {
'Content-Type': 'application/json',
})
.end(JSON.stringify({
resource: "https://resource.example.com",
authorization_servers: [`${authBaseUrl}`],
}));
return;
}
if (req.url !== "/") {
res.writeHead(404).end();
return;
@ -403,25 +467,25 @@ describe("SSEClientTransport", () => {
Connection: "keep-alive",
});
res.write("event: endpoint\n");
res.write(`data: ${baseUrl.href}\n\n`);
res.write(`data: ${resourceBaseUrl.href}\n\n`);
break;
case "POST":
res.writeHead(401);
res.end();
res.writeHead(401);
res.end();
break;
}
});
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resourceServer.listen(0, "127.0.0.1", () => {
const addr = resourceServer.address() as AddressInfo;
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
});
@ -448,7 +512,7 @@ describe("SSEClientTransport", () => {
"X-Custom-Header": "custom-value",
};
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
requestInit: {
headers: customHeaders,
@ -483,11 +547,14 @@ describe("SSEClientTransport", () => {
});
// Create server that returns 401 for expired token, then accepts new token
await server.close();
resourceServer.close();
authServer.close();
let connectionAttempts = 0;
server = createServer((req, res) => {
lastServerRequest = req;
authServer = createServer((req, res) => {
if (req.url === "/.well-known/oauth-authorization-server") {
res.writeHead(404).end();
return;
}
if (req.url === "/token" && req.method === "POST") {
// Handle token refresh request
@ -512,16 +579,44 @@ describe("SSEClientTransport", () => {
return;
}
res.writeHead(401).end();
});
// Start auth server on random port
await new Promise<void>(resolve => {
authServer.listen(0, "127.0.0.1", () => {
const addr = authServer.address() as AddressInfo;
authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
let connectionAttempts = 0;
resourceServer = createServer((req, res) => {
lastServerRequest = req;
if (req.url === "/.well-known/oauth-protected-resource") {
res.writeHead(200, {
'Content-Type': 'application/json',
})
.end(JSON.stringify({
resource: "https://resource.example.com",
authorization_servers: [`${authBaseUrl}`],
}));
return;
}
if (req.url !== "/") {
res.writeHead(404).end();
return;
}
const auth = req.headers.authorization;
if (auth === "Bearer expired-token") {
res.writeHead(401).end();
return;
}
const auth = req.headers.authorization;
if (auth === "Bearer expired-token") {
res.writeHead(401).end();
return;
}
if (auth === "Bearer new-token") {
res.writeHead(200, {
@ -530,23 +625,23 @@ describe("SSEClientTransport", () => {
Connection: "keep-alive",
});
res.write("event: endpoint\n");
res.write(`data: ${baseUrl.href}\n\n`);
res.write(`data: ${resourceBaseUrl.href}\n\n`);
connectionAttempts++;
return;
}
res.writeHead(401).end();
res.writeHead(401).end();
});
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resourceServer.listen(0, "127.0.0.1", () => {
const addr = resourceServer.address() as AddressInfo;
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
});
@ -573,12 +668,15 @@ describe("SSEClientTransport", () => {
currentTokens = tokens;
});
// Create server that accepts SSE but returns 401 on POST with expired token
await server.close();
// Create server that returns 401 for expired token, then accepts new token
resourceServer.close();
authServer.close();
let postAttempts = 0;
server = createServer((req, res) => {
lastServerRequest = req;
authServer = createServer((req, res) => {
if (req.url === "/.well-known/oauth-authorization-server") {
res.writeHead(404).end();
return;
}
if (req.url === "/token" && req.method === "POST") {
// Handle token refresh request
@ -603,6 +701,34 @@ describe("SSEClientTransport", () => {
return;
}
res.writeHead(401).end();
});
// Start auth server on random port
await new Promise<void>(resolve => {
authServer.listen(0, "127.0.0.1", () => {
const addr = authServer.address() as AddressInfo;
authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
let postAttempts = 0;
resourceServer = createServer((req, res) => {
lastServerRequest = req;
if (req.url === "/.well-known/oauth-protected-resource") {
res.writeHead(200, {
'Content-Type': 'application/json',
})
.end(JSON.stringify({
resource: "https://resource.example.com",
authorization_servers: [`${authBaseUrl}`],
}));
return;
}
switch (req.method) {
case "GET":
if (req.url !== "/") {
@ -616,7 +742,7 @@ describe("SSEClientTransport", () => {
Connection: "keep-alive",
});
res.write("event: endpoint\n");
res.write(`data: ${baseUrl.href}\n\n`);
res.write(`data: ${resourceBaseUrl.href}\n\n`);
break;
case "POST": {
@ -625,33 +751,33 @@ describe("SSEClientTransport", () => {
return;
}
const auth = req.headers.authorization;
if (auth === "Bearer expired-token") {
res.writeHead(401).end();
return;
}
if (auth === "Bearer new-token") {
res.writeHead(200).end();
postAttempts++;
return;
}
const auth = req.headers.authorization;
if (auth === "Bearer expired-token") {
res.writeHead(401).end();
return;
}
if (auth === "Bearer new-token") {
res.writeHead(200).end();
postAttempts++;
return;
}
res.writeHead(401).end();
break;
}
}
});
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resourceServer.listen(0, "127.0.0.1", () => {
const addr = resourceServer.address() as AddressInfo;
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
});
@ -688,10 +814,14 @@ describe("SSEClientTransport", () => {
});
// Create server that returns 401 for all tokens
await server.close();
resourceServer.close();
authServer.close();
server = createServer((req, res) => {
lastServerRequest = req;
authServer = createServer((req, res) => {
if (req.url === "/.well-known/oauth-authorization-server") {
res.writeHead(404).end();
return;
}
if (req.url === "/token" && req.method === "POST") {
// Handle token refresh request - always fail
@ -699,6 +829,34 @@ describe("SSEClientTransport", () => {
return;
}
res.writeHead(401).end();
});
// Start auth server on random port
await new Promise<void>(resolve => {
authServer.listen(0, "127.0.0.1", () => {
const addr = authServer.address() as AddressInfo;
authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
resourceServer = createServer((req, res) => {
lastServerRequest = req;
if (req.url === "/.well-known/oauth-protected-resource") {
res.writeHead(200, {
'Content-Type': 'application/json',
})
.end(JSON.stringify({
resource: "https://resource.example.com",
authorization_servers: [`${authBaseUrl}`],
}));
return;
}
if (req.url !== "/") {
res.writeHead(404).end();
return;
@ -707,14 +865,14 @@ describe("SSEClientTransport", () => {
});
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resourceServer.listen(0, "127.0.0.1", () => {
const addr = resourceServer.address() as AddressInfo;
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
});

View file

@ -1,7 +1,7 @@
import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource";
import { Transport } from "../shared/transport.js";
import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
export class SseError extends Error {
constructor(
@ -19,23 +19,23 @@ export class SseError extends Error {
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;
/**
* 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
@ -58,6 +58,7 @@ export class SSEClientTransport implements Transport {
private _endpoint?: URL;
private _abortController?: AbortController;
private _url: URL;
private _resourceMetadataUrl?: URL;
private _eventSourceInit?: EventSourceInit;
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
@ -71,6 +72,7 @@ export class SSEClientTransport implements Transport {
opts?: SSEClientTransportOptions,
) {
this._url = url;
this._resourceMetadataUrl = undefined;
this._eventSourceInit = opts?.eventSourceInit;
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
@ -83,7 +85,7 @@ export class SSEClientTransport implements Transport {
let result: AuthResult;
try {
result = await auth(this._authProvider, { serverUrl: this._url });
result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
} catch (error) {
this.onerror?.(error as Error);
throw error;
@ -193,7 +195,7 @@ export class SSEClientTransport implements Transport {
throw new UnauthorizedError("No auth provider");
}
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode });
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError("Failed to authorize");
}
@ -225,7 +227,10 @@ export class SSEClientTransport implements Transport {
const response = await fetch(this._endpoint, init);
if (!response.ok) {
if (response.status === 401 && this._authProvider) {
const result = await auth(this._authProvider, { serverUrl: this._url });
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError();
}

View file

@ -1,6 +1,6 @@
import { Transport } from "../shared/transport.js";
import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { EventSourceParserStream } from "eventsource-parser/stream";
// Default reconnection options for StreamableHTTP connections
@ -119,6 +119,7 @@ export type StreamableHTTPClientTransportOptions = {
export class StreamableHTTPClientTransport implements Transport {
private _abortController?: AbortController;
private _url: URL;
private _resourceMetadataUrl?: URL;
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
private _sessionId?: string;
@ -133,6 +134,7 @@ export class StreamableHTTPClientTransport implements Transport {
opts?: StreamableHTTPClientTransportOptions,
) {
this._url = url;
this._resourceMetadataUrl = undefined;
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
this._sessionId = opts?.sessionId;
@ -146,7 +148,7 @@ export class StreamableHTTPClientTransport implements Transport {
let result: AuthResult;
try {
result = await auth(this._authProvider, { serverUrl: this._url });
result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
} catch (error) {
this.onerror?.(error as Error);
throw error;
@ -225,7 +227,7 @@ export class StreamableHTTPClientTransport implements Transport {
/**
* Calculates the next reconnection delay using backoff algorithm
*
*
* @param attempt Current reconnection attempt count for the specific stream
* @returns Time to wait in milliseconds before next reconnection attempt
*/
@ -242,7 +244,7 @@ export class StreamableHTTPClientTransport implements Transport {
/**
* Schedule a reconnection attempt with exponential backoff
*
*
* @param lastEventId The ID of the last received event for resumability
* @param attemptCount Current reconnection attempt count for this specific stream
*/
@ -356,7 +358,7 @@ export class StreamableHTTPClientTransport implements Transport {
throw new UnauthorizedError("No auth provider");
}
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode });
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError("Failed to authorize");
}
@ -401,7 +403,10 @@ export class StreamableHTTPClientTransport implements Transport {
if (!response.ok) {
if (response.status === 401 && this._authProvider) {
const result = await auth(this._authProvider, { serverUrl: this._url });
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError();
}
@ -470,12 +475,12 @@ export class StreamableHTTPClientTransport implements Transport {
/**
* Terminates the current session by sending a DELETE request to the server.
*
*
* Clients that no longer need a particular session
* (e.g., because the user is leaving the client application) SHOULD send an
* HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly
* terminate the session.
*
*
* The server MAY respond with HTTP 405 Method Not Allowed, indicating that
* the server does not allow clients to terminate sessions.
*/

View file

@ -33,6 +33,12 @@ A full-featured interactive client that connects to a Streamable HTTP server, de
npx tsx src/examples/client/simpleStreamableHttp.ts
```
Example client with OAuth:
```bash
npx tsx src/examples/client/simpleOAuthClient.js
```
### Backwards Compatible Client
A client that implements backwards compatibility according to the [MCP specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility), allowing it to work with both new and legacy servers. This client demonstrates:
@ -67,6 +73,9 @@ A server that implements the Streamable HTTP transport (protocol version 2025-03
```bash
npx tsx src/examples/server/simpleStreamableHttp.ts
# To add a demo of authentication to this example, use:
npx tsx src/examples/server/simpleStreamableHttp.ts --oauth
```
##### JSON Response Mode Server

View file

@ -0,0 +1,422 @@
#!/usr/bin/env node
import { createServer } from 'node:http';
import { createInterface } from 'node:readline';
import { URL } from 'node:url';
import { exec } from 'node:child_process';
import { Client } from '../../client/index.js';
import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js';
import { OAuthClientInformation, OAuthClientInformationFull, OAuthClientMetadata, OAuthTokens } from '../../shared/auth.js';
import {
CallToolRequest,
ListToolsRequest,
CallToolResultSchema,
ListToolsResultSchema
} from '../../types.js';
import { OAuthClientProvider, UnauthorizedError } from '../../client/auth.js';
// Configuration
const DEFAULT_SERVER_URL = 'http://localhost:3000/mcp';
const CALLBACK_PORT = 8090; // Use different port than auth server (3001)
const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`;
/**
* In-memory OAuth client provider for demonstration purposes
* In production, you should persist tokens securely
*/
class InMemoryOAuthClientProvider implements OAuthClientProvider {
private _clientInformation?: OAuthClientInformationFull;
private _tokens?: OAuthTokens;
private _codeVerifier?: string;
constructor(
private readonly _redirectUrl: string | URL,
private readonly _clientMetadata: OAuthClientMetadata,
onRedirect?: (url: URL) => void
) {
this._onRedirect = onRedirect || ((url) => {
console.log(`Redirect to: ${url.toString()}`);
});
}
private _onRedirect: (url: URL) => void;
get redirectUrl(): string | URL {
return this._redirectUrl;
}
get clientMetadata(): OAuthClientMetadata {
return this._clientMetadata;
}
clientInformation(): OAuthClientInformation | undefined {
return this._clientInformation;
}
saveClientInformation(clientInformation: OAuthClientInformationFull): void {
this._clientInformation = clientInformation;
}
tokens(): OAuthTokens | undefined {
return this._tokens;
}
saveTokens(tokens: OAuthTokens): void {
this._tokens = tokens;
}
redirectToAuthorization(authorizationUrl: URL): void {
this._onRedirect(authorizationUrl);
}
saveCodeVerifier(codeVerifier: string): void {
this._codeVerifier = codeVerifier;
}
codeVerifier(): string {
if (!this._codeVerifier) {
throw new Error('No code verifier saved');
}
return this._codeVerifier;
}
}
/**
* Interactive MCP client with OAuth authentication
* Demonstrates the complete OAuth flow with browser-based authorization
*/
class InteractiveOAuthClient {
private client: Client | null = null;
private readonly rl = createInterface({
input: process.stdin,
output: process.stdout,
});
constructor(private serverUrl: string) { }
/**
* Prompts user for input via readline
*/
private async question(query: string): Promise<string> {
return new Promise((resolve) => {
this.rl.question(query, resolve);
});
}
/**
* Opens the authorization URL in the user's default browser
*/
private async openBrowser(url: string): Promise<void> {
console.log(`🌐 Opening browser for authorization: ${url}`);
const command = `open "${url}"`;
exec(command, (error) => {
if (error) {
console.error(`Failed to open browser: ${error.message}`);
console.log(`Please manually open: ${url}`);
}
});
}
/**
* Example OAuth callback handler - in production, use a more robust approach
* for handling callbacks and storing tokens
*/
/**
* Starts a temporary HTTP server to receive the OAuth callback
*/
private async waitForOAuthCallback(): Promise<string> {
return new Promise<string>((resolve, reject) => {
const server = createServer((req, res) => {
// Ignore favicon requests
if (req.url === '/favicon.ico') {
res.writeHead(404);
res.end();
return;
}
console.log(`📥 Received callback: ${req.url}`);
const parsedUrl = new URL(req.url || '', 'http://localhost');
const code = parsedUrl.searchParams.get('code');
const error = parsedUrl.searchParams.get('error');
if (code) {
console.log(`✅ Authorization code received: ${code?.substring(0, 10)}...`);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body>
<h1>Authorization Successful!</h1>
<p>You can close this window and return to the terminal.</p>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>
`);
resolve(code);
setTimeout(() => server.close(), 3000);
} else if (error) {
console.log(`❌ Authorization error: ${error}`);
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body>
<h1>Authorization Failed</h1>
<p>Error: ${error}</p>
</body>
</html>
`);
reject(new Error(`OAuth authorization failed: ${error}`));
} else {
console.log(`❌ No authorization code or error in callback`);
res.writeHead(400);
res.end('Bad request');
reject(new Error('No authorization code provided'));
}
});
server.listen(CALLBACK_PORT, () => {
console.log(`OAuth callback server started on http://localhost:${CALLBACK_PORT}`);
});
});
}
private async attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Promise<void> {
console.log('🚢 Creating transport with OAuth provider...');
const baseUrl = new URL(this.serverUrl);
const transport = new StreamableHTTPClientTransport(baseUrl, {
authProvider: oauthProvider
});
console.log('🚢 Transport created');
try {
console.log('🔌 Attempting connection (this will trigger OAuth redirect)...');
await this.client!.connect(transport);
console.log('✅ Connected successfully');
} catch (error) {
if (error instanceof UnauthorizedError) {
console.log('🔐 OAuth required - waiting for authorization...');
const callbackPromise = this.waitForOAuthCallback();
const authCode = await callbackPromise;
await transport.finishAuth(authCode);
console.log('🔐 Authorization code received:', authCode);
console.log('🔌 Reconnecting with authenticated transport...');
await this.attemptConnection(oauthProvider);
} else {
console.error('❌ Connection failed with non-auth error:', error);
throw error;
}
}
}
/**
* Establishes connection to the MCP server with OAuth authentication
*/
async connect(): Promise<void> {
console.log(`🔗 Attempting to connect to ${this.serverUrl}...`);
const clientMetadata: OAuthClientMetadata = {
client_name: 'Simple OAuth MCP Client',
redirect_uris: [CALLBACK_URL],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_post',
scope: 'mcp:tools'
};
console.log('🔐 Creating OAuth provider...');
const oauthProvider = new InMemoryOAuthClientProvider(
CALLBACK_URL,
clientMetadata,
(redirectUrl: URL) => {
console.log(`📌 OAuth redirect handler called - opening browser`);
console.log(`Opening browser to: ${redirectUrl.toString()}`);
this.openBrowser(redirectUrl.toString());
}
);
console.log('🔐 OAuth provider created');
console.log('👤 Creating MCP client...');
this.client = new Client({
name: 'simple-oauth-client',
version: '1.0.0',
}, { capabilities: {} });
console.log('👤 Client created');
console.log('🔐 Starting OAuth flow...');
await this.attemptConnection(oauthProvider);
// Start interactive loop
await this.interactiveLoop();
}
/**
* Main interactive loop for user commands
*/
async interactiveLoop(): Promise<void> {
console.log('\n🎯 Interactive MCP Client with OAuth');
console.log('Commands:');
console.log(' list - List available tools');
console.log(' call <tool_name> [args] - Call a tool');
console.log(' quit - Exit the client');
console.log();
while (true) {
try {
const command = await this.question('mcp> ');
if (!command.trim()) {
continue;
}
if (command === 'quit') {
break;
} else if (command === 'list') {
await this.listTools();
} else if (command.startsWith('call ')) {
await this.handleCallTool(command);
} else {
console.log('❌ Unknown command. Try \'list\', \'call <tool_name>\', or \'quit\'');
}
} catch (error) {
if (error instanceof Error && error.message === 'SIGINT') {
console.log('\n\n👋 Goodbye!');
break;
}
console.error('❌ Error:', error);
}
}
}
private async listTools(): Promise<void> {
if (!this.client) {
console.log('❌ Not connected to server');
return;
}
try {
const request: ListToolsRequest = {
method: 'tools/list',
params: {},
};
const result = await this.client.request(request, ListToolsResultSchema);
if (result.tools && result.tools.length > 0) {
console.log('\n📋 Available tools:');
result.tools.forEach((tool, index) => {
console.log(`${index + 1}. ${tool.name}`);
if (tool.description) {
console.log(` Description: ${tool.description}`);
}
console.log();
});
} else {
console.log('No tools available');
}
} catch (error) {
console.error('❌ Failed to list tools:', error);
}
}
private async handleCallTool(command: string): Promise<void> {
const parts = command.split(/\s+/);
const toolName = parts[1];
if (!toolName) {
console.log('❌ Please specify a tool name');
return;
}
// Parse arguments (simple JSON-like format)
let toolArgs: Record<string, unknown> = {};
if (parts.length > 2) {
const argsString = parts.slice(2).join(' ');
try {
toolArgs = JSON.parse(argsString);
} catch {
console.log('❌ Invalid arguments format (expected JSON)');
return;
}
}
await this.callTool(toolName, toolArgs);
}
private async callTool(toolName: string, toolArgs: Record<string, unknown>): Promise<void> {
if (!this.client) {
console.log('❌ Not connected to server');
return;
}
try {
const request: CallToolRequest = {
method: 'tools/call',
params: {
name: toolName,
arguments: toolArgs,
},
};
const result = await this.client.request(request, CallToolResultSchema);
console.log(`\n🔧 Tool '${toolName}' result:`);
if (result.content) {
result.content.forEach((content) => {
if (content.type === 'text') {
console.log(content.text);
} else {
console.log(content);
}
});
} else {
console.log(result);
}
} catch (error) {
console.error(`❌ Failed to call tool '${toolName}':`, error);
}
}
close(): void {
this.rl.close();
if (this.client) {
// Note: Client doesn't have a close method in the current implementation
// This would typically close the transport connection
}
}
}
/**
* Main entry point
*/
async function main(): Promise<void> {
const serverUrl = process.env.MCP_SERVER_URL || DEFAULT_SERVER_URL;
console.log('🚀 Simple MCP OAuth Client');
console.log(`Connecting to: ${serverUrl}`);
console.log();
const client = new InteractiveOAuthClient(serverUrl);
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('\n\n👋 Goodbye!');
client.close();
process.exit(0);
});
try {
await client.connect();
} catch (error) {
console.error('Failed to start client:', error);
process.exit(1);
} finally {
client.close();
}
}
// Run if this file is executed directly
main().catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});

View file

@ -55,6 +55,7 @@ function printHelp(): void {
console.log(' greet [name] - Call the greet tool');
console.log(' multi-greet [name] - Call the multi-greet tool with notifications');
console.log(' start-notifications [interval] [count] - Start periodic notifications');
console.log(' run-notifications-tool-with-resumability [interval] [count] - Run notification tool with resumability');
console.log(' list-prompts - List available prompts');
console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments');
console.log(' list-resources - List available resources');
@ -121,6 +122,13 @@ function commandLoop(): void {
break;
}
case 'run-notifications-tool-with-resumability': {
const interval = args[1] ? parseInt(args[1], 10) : 2000;
const count = args[2] ? parseInt(args[2], 10) : 10;
await runNotificationsToolWithResumability(interval, count);
break;
}
case 'list-prompts':
await listPrompts();
break;
@ -264,12 +272,12 @@ async function terminateSession(): Promise<void> {
console.log('Terminating session with ID:', transport.sessionId);
await transport.terminateSession();
console.log('Session terminated successfully');
// Check if sessionId was cleared after termination
if (!transport.sessionId) {
console.log('Session ID has been cleared');
sessionId = undefined;
// Also close the transport and clear client objects
await transport.close();
console.log('Transport closed after session termination');
@ -333,12 +341,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<vo
};
console.log(`Calling tool '${name}' with args:`, args);
const onLastEventIdUpdate = (event: string) => {
notificationsToolLastEventId = event;
};
const result = await client.request(request, CallToolResultSchema, {
resumptionToken: notificationsToolLastEventId, onresumptiontoken: onLastEventIdUpdate
});
const result = await client.request(request, CallToolResultSchema);
console.log('Tool result:');
result.content.forEach(item => {
@ -353,6 +356,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<vo
}
}
async function callGreetTool(name: string): Promise<void> {
await callTool('greet', { name });
}
@ -367,6 +371,47 @@ async function startNotifications(interval: number, count: number): Promise<void
await callTool('start-notification-stream', { interval, count });
}
async function runNotificationsToolWithResumability(interval: number, count: number): Promise<void> {
if (!client) {
console.log('Not connected to server.');
return;
}
try {
console.log(`Starting notification stream with resumability: interval=${interval}ms, count=${count || 'unlimited'}`);
console.log(`Using resumption token: ${notificationsToolLastEventId || 'none'}`);
const request: CallToolRequest = {
method: 'tools/call',
params: {
name: 'start-notification-stream',
arguments: { interval, count }
}
};
const onLastEventIdUpdate = (event: string) => {
notificationsToolLastEventId = event;
console.log(`Updated resumption token: ${event}`);
};
const result = await client.request(request, CallToolResultSchema, {
resumptionToken: notificationsToolLastEventId,
onresumptiontoken: onLastEventIdUpdate
});
console.log('Tool result:');
result.content.forEach(item => {
if (item.type === 'text') {
console.log(` ${item.text}`);
} else {
console.log(` ${item.type} content:`, item);
}
});
} catch (error) {
console.log(`Error starting notification stream: ${error}`);
}
}
async function listPrompts(): Promise<void> {
if (!client) {
console.log('Not connected to server.');
@ -456,7 +501,7 @@ async function cleanup(): Promise<void> {
console.error('Error terminating session:', error);
}
}
// Then close the transport
await transport.close();
} catch (error) {

View file

@ -0,0 +1,200 @@
import { randomUUID } from 'node:crypto';
import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js';
import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js';
import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from 'src/shared/auth.js';
import express, { Request, Response } from "express";
import { AuthInfo } from 'src/server/auth/types.js';
import { createOAuthMetadata, mcpAuthRouter } from 'src/server/auth/router.js';
export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore {
private clients = new Map<string, OAuthClientInformationFull>();
async getClient(clientId: string) {
return this.clients.get(clientId);
}
async registerClient(clientMetadata: OAuthClientInformationFull) {
this.clients.set(clientMetadata.client_id, clientMetadata);
return clientMetadata;
}
}
/**
* 🚨 DEMO ONLY - NOT FOR PRODUCTION
*
* This example demonstrates MCP OAuth flow but lacks some of the features required for production use,
* for example:
* - Persistent token storage
* - Rate limiting
*/
export class DemoInMemoryAuthProvider implements OAuthServerProvider {
clientsStore = new DemoInMemoryClientsStore();
private codes = new Map<string, {
params: AuthorizationParams,
client: OAuthClientInformationFull}>();
private tokens = new Map<string, AuthInfo>();
async authorize(
client: OAuthClientInformationFull,
params: AuthorizationParams,
res: Response
): Promise<void> {
const code = randomUUID();
const searchParams = new URLSearchParams({
code,
});
if (params.state !== undefined) {
searchParams.set('state', params.state);
}
this.codes.set(code, {
client,
params
});
const targetUrl = new URL(client.redirect_uris[0]);
targetUrl.search = searchParams.toString();
res.redirect(targetUrl.toString());
}
async challengeForAuthorizationCode(
client: OAuthClientInformationFull,
authorizationCode: string
): Promise<string> {
// Store the challenge with the code data
const codeData = this.codes.get(authorizationCode);
if (!codeData) {
throw new Error('Invalid authorization code');
}
return codeData.params.codeChallenge;
}
async exchangeAuthorizationCode(
client: OAuthClientInformationFull,
authorizationCode: string,
// Note: code verifier is checked in token.ts by default
// it's unused here for that reason.
_codeVerifier?: string
): Promise<OAuthTokens> {
const codeData = this.codes.get(authorizationCode);
if (!codeData) {
throw new Error('Invalid authorization code');
}
if (codeData.client.client_id !== client.client_id) {
throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`);
}
this.codes.delete(authorizationCode);
const token = randomUUID();
const tokenData = {
token,
clientId: client.client_id,
scopes: codeData.params.scopes || [],
expiresAt: Date.now() + 3600000, // 1 hour
type: 'access'
};
this.tokens.set(token, tokenData);
return {
access_token: token,
token_type: 'bearer',
expires_in: 3600,
scope: (codeData.params.scopes || []).join(' '),
};
}
async exchangeRefreshToken(
_client: OAuthClientInformationFull,
_refreshToken: string,
_scopes?: string[]
): Promise<OAuthTokens> {
throw new Error('Not implemented for example demo');
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
const tokenData = this.tokens.get(token);
if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) {
throw new Error('Invalid or expired token');
}
return {
token,
clientId: tokenData.clientId,
scopes: tokenData.scopes,
expiresAt: Math.floor(tokenData.expiresAt / 1000),
};
}
}
export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => {
// Create separate auth server app
// NOTE: This is a separate app on a separate port to illustrate
// how to separate an OAuth Authorization Server from a Resource
// server in the SDK. The SDK is not intended to be provide a standalone
// authorization server.
const provider = new DemoInMemoryAuthProvider();
const authApp = express();
authApp.use(express.json());
// For introspection requests
authApp.use(express.urlencoded());
// Add OAuth routes to the auth server
// NOTE: this will also add a protected resource metadata route,
// but it won't be used, so leave it.
authApp.use(mcpAuthRouter({
provider,
issuerUrl: authServerUrl,
scopesSupported: ['mcp:tools'],
}));
authApp.post('/introspect', async (req: Request, res: Response) => {
try {
const { token } = req.body;
if (!token) {
res.status(400).json({ error: 'Token is required' });
return;
}
const tokenInfo = await provider.verifyAccessToken(token);
res.json({
active: true,
client_id: tokenInfo.clientId,
scope: tokenInfo.scopes.join(' '),
exp: tokenInfo.expiresAt
});
return
} catch (error) {
res.status(401).json({
active: false,
error: 'Unauthorized',
error_description: `Invalid token: ${error}`
});
}
});
const auth_port = authServerUrl.port;
// Start the auth server
authApp.listen(auth_port, () => {
console.log(`OAuth Authorization Server listening on port ${auth_port}`);
});
// Note: we could fetch this from the server, but then we end up
// with some top level async which gets annoying.
const oauthMetadata: OAuthMetadata = createOAuthMetadata({
provider,
issuerUrl: authServerUrl,
scopesSupported: ['mcp:tools'],
})
oauthMetadata.introspection_endpoint = new URL("/introspect", authServerUrl).href;
return oauthMetadata;
}

View file

@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* Example MCP server using the high-level McpServer API with outputSchema
* This demonstrates how to easily create tools with structured output
*/
import { McpServer } from "../../server/mcp.js";
import { StdioServerTransport } from "../../server/stdio.js";
import { z } from "zod";
const server = new McpServer(
{
name: "mcp-output-schema-high-level-example",
version: "1.0.0",
}
);
// Define a tool with structured output - Weather data
server.registerTool(
"get_weather",
{
description: "Get weather information for a city",
inputSchema: {
city: z.string().describe("City name"),
country: z.string().describe("Country code (e.g., US, UK)")
},
outputSchema: {
temperature: z.object({
celsius: z.number(),
fahrenheit: z.number()
}),
conditions: z.enum(["sunny", "cloudy", "rainy", "stormy", "snowy"]),
humidity: z.number().min(0).max(100),
wind: z.object({
speed_kmh: z.number(),
direction: z.string()
})
},
},
async ({ city, country }) => {
// Parameters are available but not used in this example
void city;
void country;
// Simulate weather API call
const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10;
const conditions = ["sunny", "cloudy", "rainy", "stormy", "snowy"][Math.floor(Math.random() * 5)];
const structuredContent = {
temperature: {
celsius: temp_c,
fahrenheit: Math.round((temp_c * 9 / 5 + 32) * 10) / 10
},
conditions,
humidity: Math.round(Math.random() * 100),
wind: {
speed_kmh: Math.round(Math.random() * 50),
direction: ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Math.floor(Math.random() * 8)]
}
};
return {
content: [{
type: "text",
text: JSON.stringify(structuredContent, null, 2)
}],
structuredContent
};
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("High-level Output Schema Example Server running on stdio");
}
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});

View file

@ -3,8 +3,15 @@ import { randomUUID } from 'node:crypto';
import { z } from 'zod';
import { McpServer } from '../../server/mcp.js';
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js';
import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js';
import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js';
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
import { setupAuthServer } from './demoInMemoryOAuthProvider.js';
import { OAuthMetadata } from 'src/shared/auth.js';
// Check for OAuth flag
const useOAuth = process.argv.includes('--oauth');
// Create an MCP server with implementation details
const getServer = () => {
@ -40,7 +47,7 @@ const getServer = () => {
name: z.string().describe('Name to greet'),
},
{
title: 'Multiple Greeting Tool',
title: 'Multiple Greeting Tool',
readOnlyHint: true,
openWorldHint: false
},
@ -159,14 +166,79 @@ const getServer = () => {
return server;
};
const MCP_PORT = 3000;
const AUTH_PORT = 3001;
const app = express();
app.use(express.json());
// Set up OAuth if enabled
let authMiddleware = null;
if (useOAuth) {
// Create auth middleware for MCP endpoints
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`);
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl);
const tokenVerifier = {
verifyAccessToken: async (token: string) => {
const endpoint = oauthMetadata.introspection_endpoint;
if (!endpoint) {
throw new Error('No token verification endpoint available in metadata');
}
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
token: token
}).toString()
});
if (!response.ok) {
throw new Error(`Invalid or expired token: ${await response.text()}`);
}
const data = await response.json();
// Convert the response to AuthInfo format
return {
token,
clientId: data.client_id,
scopes: data.scope ? data.scope.split(' ') : [],
expiresAt: data.exp,
};
}
}
// Add metadata routes to the main MCP server
app.use(mcpAuthMetadataRouter({
oauthMetadata,
resourceServerUrl: mcpServerUrl,
scopesSupported: ['mcp:tools'],
resourceName: 'MCP Demo Server',
}));
authMiddleware = requireBearerAuth({
verifier: tokenVerifier,
requiredScopes: ['mcp:tools'],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
});
}
// Map to store transports by session ID
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
app.post('/mcp', async (req: Request, res: Response) => {
// MCP POST endpoint with optional auth
const mcpPostHandler = async (req: Request, res: Response) => {
console.log('Received MCP request:', req.body);
if (useOAuth && req.auth) {
console.log('Authenticated user:', req.auth);
}
try {
// Check for existing session ID
const sessionId = req.headers['mcp-session-id'] as string | undefined;
@ -234,16 +306,27 @@ app.post('/mcp', async (req: Request, res: Response) => {
});
}
}
});
};
// Set up routes with conditional auth middleware
if (useOAuth && authMiddleware) {
app.post('/mcp', authMiddleware, mcpPostHandler);
} else {
app.post('/mcp', mcpPostHandler);
}
// Handle GET requests for SSE streams (using built-in support from StreamableHTTP)
app.get('/mcp', async (req: Request, res: Response) => {
const mcpGetHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}
if (useOAuth && req.auth) {
console.log('Authenticated SSE connection from user:', req.auth);
}
// Check for Last-Event-ID header for resumability
const lastEventId = req.headers['last-event-id'] as string | undefined;
if (lastEventId) {
@ -254,10 +337,17 @@ app.get('/mcp', async (req: Request, res: Response) => {
const transport = transports[sessionId];
await transport.handleRequest(req, res);
});
};
// Set up GET route with conditional auth middleware
if (useOAuth && authMiddleware) {
app.get('/mcp', authMiddleware, mcpGetHandler);
} else {
app.get('/mcp', mcpGetHandler);
}
// Handle DELETE requests for session termination (according to MCP spec)
app.delete('/mcp', async (req: Request, res: Response) => {
const mcpDeleteHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
@ -275,12 +365,17 @@ app.delete('/mcp', async (req: Request, res: Response) => {
res.status(500).send('Error processing session termination');
}
}
});
};
// Start the server
const PORT = 3000;
app.listen(PORT, () => {
console.log(`MCP Streamable HTTP Server listening on port ${PORT}`);
// Set up DELETE route with conditional auth middleware
if (useOAuth && authMiddleware) {
app.delete('/mcp', authMiddleware, mcpDeleteHandler);
} else {
app.delete('/mcp', mcpDeleteHandler);
}
app.listen(MCP_PORT, () => {
console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`);
});
// Handle server shutdown

View file

@ -1,9 +1,9 @@
import express, { RequestHandler } from "express";
import { OAuthMetadata } from "../../../shared/auth.js";
import { OAuthMetadata, OAuthProtectedResourceMetadata } from "../../../shared/auth.js";
import cors from 'cors';
import { allowedMethods } from "../middleware/allowedMethods.js";
export function metadataHandler(metadata: OAuthMetadata): RequestHandler {
export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler {
// Nested router so we can configure middleware and restrict HTTP method
const router = express.Router();
@ -16,4 +16,4 @@ export function metadataHandler(metadata: OAuthMetadata): RequestHandler {
});
return router;
}
}

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