eslint fixes in tests (#1418)

This commit is contained in:
Konstantin Konstantinov 2026-01-22 14:26:48 +02:00 • committed by GitHub
commit bf6f16c8be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 219 additions and 184 deletions

View file

@ -1,5 +1,5 @@
---
"@modelcontextprotocol/node": patch
'@modelcontextprotocol/node': patch
---
Prevent Hono from overriding global Response object by passing `overrideGlobalObjects: false` to `getRequestListener()`. This fixes compatibility with frameworks like Next.js whose response classes extend the native Response.

View file

@ -124,7 +124,7 @@
</head>
<body>
<h1>MCP TypeScript SDK</h1>
<div class="redirect-notice">
<p>Redirecting to <a href="{{ site.data.latest_version }}/">latest documentation ({{ site.data.latest_version }})</a>...</p>
</div>
@ -142,31 +142,39 @@
<script>
// Redirect to latest version
setTimeout(function() {
setTimeout(function () {
window.location.href = '{{ site.data.latest_version }}/';
}, 1000);
// Load and display package versions from versions.json
fetch('{{ site.data.latest_version }}/versions.json')
.then(function(response) {
.then(function (response) {
if (!response.ok) throw new Error('versions.json not found');
return response.json();
})
.then(function(data) {
.then(function (data) {
var container = document.getElementById('package-versions');
var html = '<p>Packages included in the latest documentation:</p><ul class="package-list">';
for (var pkg in data.packages) {
html += '<li><span class="package-name">' + pkg + '</span>: <span class="package-version">' + data.packages[pkg] + '</span></li>';
html +=
'<li><span class="package-name">' +
pkg +
'</span>: <span class="package-version">' +
data.packages[pkg] +
'</span></li>';
}
html += '</ul>';
html += '<p style="font-size: 0.85rem; color: #666; margin-top: 1rem;">Generated from tag: ' + data.generated_from_tag + '</p>';
html +=
'<p style="font-size: 0.85rem; color: #666; margin-top: 1rem;">Generated from tag: ' +
data.generated_from_tag +
'</p>';
container.innerHTML = html;
container.classList.add('loaded');
})
.catch(function(err) {
.catch(function (err) {
// versions.json may not exist for older docs
document.getElementById('package-versions').style.display = 'none';
});

View file

@ -79,6 +79,13 @@ export default defineConfig(
]
}
},
{
// Disable consistent-function-scoping in test files where helper functions are common
files: ['**/*.test.ts', '**/*.test.tsx', '**/test/**'],
rules: {
'unicorn/consistent-function-scoping': 'off'
}
},
{
// Ignore generated protocol types everywhere
ignores: ['**/spec.types.ts']

View file

@ -1,10 +1,10 @@
---
title: Documents
children:
- ./server.md
- ./client.md
- ./capabilities.md
- ./faq.md
- ./server.md
- ./client.md
- ./capabilities.md
- ./faq.md
---
# Documents

View file

@ -0,0 +1,332 @@
import { createMockOAuthFetch } from '@modelcontextprotocol/test-helpers';
import { describe, expect, it } from 'vitest';
import { auth } from '../../src/client/auth.js';
import {
ClientCredentialsProvider,
createPrivateKeyJwtAuth,
PrivateKeyJwtProvider,
StaticPrivateKeyJwtProvider
} from '../../src/client/authExtensions.js';
const RESOURCE_SERVER_URL = 'https://resource.example.com/';
const AUTH_SERVER_URL = 'https://auth.example.com';
describe('auth-extensions providers (end-to-end with auth())', () => {
it('authenticates using ClientCredentialsProvider with client_secret_basic', async () => {
const provider = new ClientCredentialsProvider({
clientId: 'my-client',
clientSecret: 'my-secret',
clientName: 'test-client'
});
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL,
onTokenRequest: async (_url, init) => {
const params = init?.body as URLSearchParams;
expect(params).toBeInstanceOf(URLSearchParams);
expect(params.get('grant_type')).toBe('client_credentials');
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
expect(params.get('client_assertion')).toBeNull();
const headers = new Headers(init?.headers);
const authHeader = headers.get('Authorization');
expect(authHeader).toBeTruthy();
const expectedCredentials = Buffer.from('my-client:my-secret').toString('base64');
expect(authHeader).toBe(`Basic ${expectedCredentials}`);
}
});
const result = await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
});
expect(result).toBe('AUTHORIZED');
const tokens = provider.tokens();
expect(tokens).toBeTruthy();
expect(tokens?.access_token).toBe('test-access-token');
});
it('authenticates using PrivateKeyJwtProvider with private_key_jwt', async () => {
const provider = new PrivateKeyJwtProvider({
clientId: 'client-id',
privateKey: 'a-string-secret-at-least-256-bits-long',
algorithm: 'HS256',
clientName: 'private-key-jwt-client'
});
let assertionFromRequest: string | null = null;
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL,
onTokenRequest: async (_url, init) => {
const params = init?.body as URLSearchParams;
expect(params).toBeInstanceOf(URLSearchParams);
expect(params.get('grant_type')).toBe('client_credentials');
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
assertionFromRequest = params.get('client_assertion');
expect(assertionFromRequest).toBeTruthy();
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
const parts = assertionFromRequest!.split('.');
expect(parts).toHaveLength(3);
const headers = new Headers(init?.headers);
expect(headers.get('Authorization')).toBeNull();
}
});
const result = await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
});
expect(result).toBe('AUTHORIZED');
const tokens = provider.tokens();
expect(tokens).toBeTruthy();
expect(tokens?.access_token).toBe('test-access-token');
expect(assertionFromRequest).toBeTruthy();
});
it('fails when PrivateKeyJwtProvider is configured with an unsupported algorithm', async () => {
const provider = new PrivateKeyJwtProvider({
clientId: 'client-id',
privateKey: 'a-string-secret-at-least-256-bits-long',
algorithm: 'none',
clientName: 'private-key-jwt-client'
});
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL
});
await expect(
auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
})
).rejects.toThrow('Unsupported algorithm none');
});
it('authenticates using StaticPrivateKeyJwtProvider with static client assertion', async () => {
const staticAssertion = 'header.payload.signature';
const provider = new StaticPrivateKeyJwtProvider({
clientId: 'static-client',
jwtBearerAssertion: staticAssertion,
clientName: 'static-private-key-jwt-client'
});
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL,
onTokenRequest: async (_url, init) => {
const params = init?.body as URLSearchParams;
expect(params).toBeInstanceOf(URLSearchParams);
expect(params.get('grant_type')).toBe('client_credentials');
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
expect(params.get('client_assertion')).toBe(staticAssertion);
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
const headers = new Headers(init?.headers);
expect(headers.get('Authorization')).toBeNull();
}
});
const result = await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
});
expect(result).toBe('AUTHORIZED');
const tokens = provider.tokens();
expect(tokens).toBeTruthy();
expect(tokens?.access_token).toBe('test-access-token');
});
});
describe('createPrivateKeyJwtAuth', () => {
const baseOptions = {
issuer: 'client-id',
subject: 'client-id',
privateKey: 'a-string-secret-at-least-256-bits-long',
alg: 'HS256'
};
it('creates an addClientAuthentication function that sets JWT assertion params', async () => {
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
const headers = new Headers();
const params = new URLSearchParams();
await addClientAuth(headers, params, 'https://auth.example.com/token', undefined);
expect(params.get('client_assertion')).toBeTruthy();
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
// Verify JWT structure (three dot-separated segments)
const assertion = params.get('client_assertion')!;
const parts = assertion.split('.');
expect(parts).toHaveLength(3);
});
it('throws when globalThis.crypto is not available', async () => {
// Temporarily remove globalThis.crypto to simulate older Node.js runtimes
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const globalAny = globalThis as any;
const originalCrypto = globalAny.crypto;
// Use delete so that typeof globalThis.crypto === 'undefined'
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete globalAny.crypto;
try {
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
const params = new URLSearchParams();
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
'crypto is not available, please ensure you add have Web Crypto API support for older Node.js versions'
);
} finally {
// Restore original crypto to avoid affecting other tests
globalAny.crypto = originalCrypto;
}
});
it('creates a signed JWT when using a Uint8Array HMAC key', async () => {
const secret = new TextEncoder().encode('a-string-secret-at-least-256-bits-long');
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: secret,
alg: 'HS256'
});
const params = new URLSearchParams();
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
const assertion = params.get('client_assertion')!;
const parts = assertion.split('.');
expect(parts).toHaveLength(3);
});
it('creates a signed JWT when using a symmetric JWK key', async () => {
const jwk: Record<string, unknown> = {
kty: 'oct',
// "a-string-secret-at-least-256-bits-long" base64url-encoded
k: 'YS1zdHJpbmctc2VjcmV0LWF0LWxlYXN0LTI1Ni1iaXRzLWxvbmc',
alg: 'HS256'
};
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: jwk,
alg: 'HS256'
});
const params = new URLSearchParams();
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
const assertion = params.get('client_assertion')!;
const parts = assertion.split('.');
expect(parts).toHaveLength(3);
});
it('creates a signed JWT when using an RSA PEM private key', async () => {
// Generate an RSA key pair on the fly
const jose = await import('jose');
const { privateKey } = await jose.generateKeyPair('RS256', { extractable: true });
const pem = await jose.exportPKCS8(privateKey);
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: pem,
alg: 'RS256'
});
const params = new URLSearchParams();
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
const assertion = params.get('client_assertion')!;
const parts = assertion.split('.');
expect(parts).toHaveLength(3);
});
it('uses metadata.issuer as audience when available', async () => {
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
const params = new URLSearchParams();
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', {
issuer: 'https://issuer.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
response_types_supported: ['code']
});
const assertion = params.get('client_assertion')!;
// Decode the payload to verify audience
const [, payloadB64] = assertion.split('.');
const payload = JSON.parse(Buffer.from(payloadB64!, 'base64url').toString());
expect(payload.aud).toBe('https://issuer.example.com');
});
it('throws when using an unsupported algorithm', async () => {
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: 'a-string-secret-at-least-256-bits-long',
alg: 'none'
});
const params = new URLSearchParams();
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
'Unsupported algorithm none'
);
});
it('throws when jose cannot import an invalid RSA PEM key', async () => {
const badPem = '-----BEGIN PRIVATE KEY-----\nnot-a-valid-key\n-----END PRIVATE KEY-----';
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: badPem,
alg: 'RS256'
});
const params = new URLSearchParams();
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
/Invalid character/
);
});
it('throws when jose cannot import a mismatched JWK key', async () => {
const jwk: Record<string, unknown> = {
kty: 'oct',
k: 'c2VjcmV0LWtleQ', // "secret-key" base64url
alg: 'HS256'
};
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: jwk,
// Ask for an RSA algorithm with an octet key, which should cause jose.importJWK to fail
alg: 'RS256'
});
const params = new URLSearchParams();
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
/Key for the RS256 algorithm must be one of type CryptoKey, KeyObject, or JSON Web Key/
);
});
});

View file

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

View file

@ -1018,9 +1018,9 @@ describe('createMiddleware', () => {
if (response.headers.get('content-type')?.includes('application/json')) {
const data = (await response.json()) as Record<string, unknown>;
const transformed = { ...data, timestamp: 123456789 };
const transformed = { ...data, timestamp: 123_456_789 };
return new Response(JSON.stringify(transformed), {
return Response.json(transformed, {
status: response.status,
statusText: response.statusText,
headers: response.headers
@ -1036,7 +1036,7 @@ describe('createMiddleware', () => {
expect(result).toEqual({
data: 'original',
timestamp: 123456789
timestamp: 123_456_789
});
});

View file

@ -300,9 +300,9 @@ describe('SSEClientTransport', () => {
await transport.start();
const originalFetch = global.fetch;
const originalFetch = globalThis.fetch;
try {
global.fetch = vi.fn().mockResolvedValue({ ok: true });
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true });
const message: JSONRPCMessage = {
jsonrpc: '2.0',
@ -313,7 +313,7 @@ describe('SSEClientTransport', () => {
await transport.send(message);
const calledHeaders = (global.fetch as Mock).mock.calls[0]![1].headers;
const calledHeaders = (globalThis.fetch as Mock).mock.calls[0]![1].headers;
expect(calledHeaders.get('Authorization')).toBe('Bearer test-token');
expect(calledHeaders.get('X-Custom-Header')).toBe('custom-value');
expect(calledHeaders.get('content-type')).toBe('application/json');
@ -322,10 +322,10 @@ describe('SSEClientTransport', () => {
await transport.send(message);
const updatedHeaders = (global.fetch as Mock).mock.calls[1]![1].headers;
const updatedHeaders = (globalThis.fetch as Mock).mock.calls[1]![1].headers;
expect(updatedHeaders.get('X-Custom-Header')).toBe('updated-value');
} finally {
global.fetch = originalFetch;
globalThis.fetch = originalFetch;
}
});
@ -343,9 +343,9 @@ describe('SSEClientTransport', () => {
await transport.start();
const originalFetch = global.fetch;
const originalFetch = globalThis.fetch;
try {
global.fetch = vi.fn().mockResolvedValue({ ok: true });
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true });
const message: JSONRPCMessage = {
jsonrpc: '2.0',
@ -356,7 +356,7 @@ describe('SSEClientTransport', () => {
await transport.send(message);
const calledHeaders = (global.fetch as Mock).mock.calls[0]![1].headers;
const calledHeaders = (globalThis.fetch as Mock).mock.calls[0]![1].headers;
expect(calledHeaders.get('Authorization')).toBe('Bearer test-token');
expect(calledHeaders.get('X-Custom-Header')).toBe('custom-value');
expect(calledHeaders.get('content-type')).toBe('application/json');
@ -365,10 +365,10 @@ describe('SSEClientTransport', () => {
await transport.send(message);
const updatedHeaders = (global.fetch as Mock).mock.calls[1]![1].headers;
const updatedHeaders = (globalThis.fetch as Mock).mock.calls[1]![1].headers;
expect(updatedHeaders.get('X-Custom-Header')).toBe('updated-value');
} finally {
global.fetch = originalFetch;
globalThis.fetch = originalFetch;
}
});
@ -384,24 +384,24 @@ describe('SSEClientTransport', () => {
await transport.start();
const originalFetch = global.fetch;
const originalFetch = globalThis.fetch;
try {
global.fetch = vi.fn().mockResolvedValue({ ok: true });
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true });
await transport.send({ jsonrpc: '2.0', id: '1', method: 'test', params: {} });
const calledHeaders = (global.fetch as Mock).mock.calls[0]![1].headers;
const calledHeaders = (globalThis.fetch as Mock).mock.calls[0]![1].headers;
expect(calledHeaders.get('Authorization')).toBe('Bearer test-token');
expect(calledHeaders.get('X-Custom-Header')).toBe('custom-value');
expect(calledHeaders.get('content-type')).toBe('application/json');
} finally {
global.fetch = originalFetch;
globalThis.fetch = originalFetch;
}
});
});
describe('auth handling', () => {
const authServerMetadataUrls = ['/.well-known/oauth-authorization-server', '/.well-known/openid-configuration'];
const authServerMetadataUrls = new Set(['/.well-known/oauth-authorization-server', '/.well-known/openid-configuration']);
let mockAuthProvider: Mocked<OAuthClientProvider>;
@ -516,10 +516,10 @@ describe('SSEClientTransport', () => {
return;
}
if (req.url !== '/') {
res.writeHead(404).end();
} else {
if (req.url === '/') {
res.writeHead(401).end();
} else {
res.writeHead(404).end();
}
});
@ -550,7 +550,7 @@ describe('SSEClientTransport', () => {
lastServerRequest = req;
switch (req.method) {
case 'GET':
case 'GET': {
if (req.url === '/.well-known/oauth-protected-resource') {
res.writeHead(200, {
'Content-Type': 'application/json'
@ -576,11 +576,13 @@ describe('SSEClientTransport', () => {
res.write('event: endpoint\n');
res.write(`data: ${resourceBaseUrl.href}\n\n`);
break;
}
case 'POST':
case 'POST': {
res.writeHead(401);
res.end();
break;
}
}
});
@ -658,7 +660,7 @@ describe('SSEClientTransport', () => {
authServer.close();
authServer = createServer((req, res) => {
if (req.url && authServerMetadataUrls.includes(req.url)) {
if (req.url && authServerMetadataUrls.has(req.url)) {
res.writeHead(404).end();
return;
}
@ -786,7 +788,7 @@ describe('SSEClientTransport', () => {
authServer.close();
authServer = createServer((req, res) => {
if (req.url && authServerMetadataUrls.includes(req.url)) {
if (req.url && authServerMetadataUrls.has(req.url)) {
res.writeHead(404).end();
return;
}
@ -849,7 +851,7 @@ describe('SSEClientTransport', () => {
}
switch (req.method) {
case 'GET':
case 'GET': {
if (req.url !== '/') {
res.writeHead(404).end();
return;
@ -863,6 +865,7 @@ describe('SSEClientTransport', () => {
res.write('event: endpoint\n');
res.write(`data: ${resourceBaseUrl.href}\n\n`);
break;
}
case 'POST': {
if (req.url !== '/') {
@ -937,7 +940,7 @@ describe('SSEClientTransport', () => {
authServer.close();
authServer = createServer((req, res) => {
if (req.url && authServerMetadataUrls.includes(req.url)) {
if (req.url && authServerMetadataUrls.has(req.url)) {
res.writeHead(404).end();
return;
}
@ -1309,7 +1312,7 @@ describe('SSEClientTransport', () => {
});
// Spy on global fetch to detect unauthorized usage
globalFetchSpy = vi.spyOn(global, 'fetch');
globalFetchSpy = vi.spyOn(globalThis, 'fetch');
// Create mock auth provider with default configuration
mockAuthProvider = createMockAuthProvider({
@ -1386,7 +1389,7 @@ describe('SSEClientTransport', () => {
// Set up resource server that accepts SSE connection but returns 401 on POST
resourceServerHandler.mockImplementation((req: IncomingMessage, res: ServerResponse) => {
switch (req.method) {
case 'GET':
case 'GET': {
if (req.url === '/') {
// Accept SSE connection
res.writeHead(200, {
@ -1399,8 +1402,9 @@ describe('SSEClientTransport', () => {
return;
}
break;
}
case 'POST':
case 'POST': {
if (req.url === '/') {
// Return 401 to trigger auth retry
res.writeHead(401, {
@ -1410,6 +1414,7 @@ describe('SSEClientTransport', () => {
return;
}
break;
}
}
res.writeHead(404).end();

View file

@ -1,6 +1,6 @@
import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core';
import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '@modelcontextprotocol/core';
import { type Mock, type Mocked } from 'vitest';
import type { Mock, Mocked } from 'vitest';
import type { OAuthClientProvider } from '../../src/client/auth.js';
import { UnauthorizedError } from '../../src/client/auth.js';
@ -28,7 +28,7 @@ describe('StreamableHTTPClientTransport', () => {
invalidateCredentials: vi.fn()
};
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { authProvider: mockAuthProvider });
vi.spyOn(global, 'fetch');
vi.spyOn(globalThis, 'fetch');
});
afterEach(async () => {
@ -44,7 +44,7 @@ describe('StreamableHTTPClientTransport', () => {
id: 'test-id'
};
(global.fetch as Mock).mockResolvedValueOnce({
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers()
@ -52,7 +52,7 @@ describe('StreamableHTTPClientTransport', () => {
await transport.send(message);
expect(global.fetch).toHaveBeenCalledWith(
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: 'POST',
@ -68,7 +68,7 @@ describe('StreamableHTTPClientTransport', () => {
{ jsonrpc: '2.0', method: 'test2', params: {}, id: 'id2' }
];
(global.fetch as Mock).mockResolvedValueOnce({
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
@ -77,7 +77,7 @@ describe('StreamableHTTPClientTransport', () => {
await transport.send(messages);
expect(global.fetch).toHaveBeenCalledWith(
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: 'POST',
@ -98,7 +98,7 @@ describe('StreamableHTTPClientTransport', () => {
id: 'init-id'
};
(global.fetch as Mock).mockResolvedValueOnce({
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' })
@ -107,7 +107,7 @@ describe('StreamableHTTPClientTransport', () => {
await transport.send(message);
// Send a second message that should include the session ID
(global.fetch as Mock).mockResolvedValueOnce({
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers()
@ -116,8 +116,8 @@ describe('StreamableHTTPClientTransport', () => {
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
// Check that second request included session ID header
const calls = (global.fetch as Mock).mock.calls;
const lastCall = calls[calls.length - 1]!;
const calls = (globalThis.fetch as Mock).mock.calls;
const lastCall = calls.at(-1)!;
expect(lastCall[1].headers).toBeDefined();
expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id');
});
@ -134,7 +134,7 @@ describe('StreamableHTTPClientTransport', () => {
id: 'init-id'
};
(global.fetch as Mock).mockResolvedValueOnce({
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' })
@ -144,7 +144,7 @@ describe('StreamableHTTPClientTransport', () => {
expect(transport.sessionId).toBe('test-session-id');
// Now terminate the session
(global.fetch as Mock).mockResolvedValueOnce({
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers()
@ -153,8 +153,8 @@ describe('StreamableHTTPClientTransport', () => {
await transport.terminateSession();
// Verify the DELETE request was sent with the session ID
const calls = (global.fetch as Mock).mock.calls;
const lastCall = calls[calls.length - 1]!;
const calls = (globalThis.fetch as Mock).mock.calls;
const lastCall = calls.at(-1)!;
expect(lastCall[1].method).toBe('DELETE');
expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id');
@ -174,7 +174,7 @@ describe('StreamableHTTPClientTransport', () => {
id: 'init-id'
};
(global.fetch as Mock).mockResolvedValueOnce({
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' })
@ -183,7 +183,7 @@ describe('StreamableHTTPClientTransport', () => {
await transport.send(message);
// Now terminate the session, but server responds with 405
(global.fetch as Mock).mockResolvedValueOnce({
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 405,
statusText: 'Method Not Allowed',
@ -201,7 +201,7 @@ describe('StreamableHTTPClientTransport', () => {
id: 'test-id'
};
(global.fetch as Mock).mockResolvedValueOnce({
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
@ -230,7 +230,7 @@ describe('StreamableHTTPClientTransport', () => {
id: 'test-id'
};
(global.fetch as Mock).mockResolvedValueOnce({
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
@ -247,7 +247,7 @@ describe('StreamableHTTPClientTransport', () => {
it('should attempt initial GET connection and handle 405 gracefully', async () => {
// Mock the server not supporting GET for SSE (returning 405)
(global.fetch as Mock).mockResolvedValueOnce({
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 405,
statusText: 'Method Not Allowed'
@ -258,7 +258,7 @@ describe('StreamableHTTPClientTransport', () => {
await transport.start();
await expect(transport['_startOrAuthSse']({})).resolves.not.toThrow('Failed to open SSE stream: Method Not Allowed');
// Check that GET was attempted
expect(global.fetch).toHaveBeenCalledWith(
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: 'GET',
@ -267,14 +267,14 @@ describe('StreamableHTTPClientTransport', () => {
);
// Verify transport still works after 405
(global.fetch as Mock).mockResolvedValueOnce({
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers()
});
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
it('should handle successful initial GET connection for SSE', async () => {
@ -289,7 +289,7 @@ describe('StreamableHTTPClientTransport', () => {
});
// Mock successful GET connection
(global.fetch as Mock).mockResolvedValueOnce({
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
@ -326,7 +326,7 @@ describe('StreamableHTTPClientTransport', () => {
});
};
(global.fetch as Mock)
(globalThis.fetch as Mock)
.mockResolvedValueOnce({
ok: true,
status: 200,
@ -376,7 +376,7 @@ describe('StreamableHTTPClientTransport', () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 500,
maxReconnectionDelay: 10000,
maxReconnectionDelay: 10_000,
reconnectionDelayGrowFactor: 2,
maxRetries: 5
}
@ -396,7 +396,7 @@ describe('StreamableHTTPClientTransport', () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
// Mock fetch to verify headers sent
const fetchSpy = global.fetch as Mock;
const fetchSpy = globalThis.fetch as Mock;
fetchSpy.mockReset();
fetchSpy.mockResolvedValue({
ok: true,
@ -444,7 +444,7 @@ describe('StreamableHTTPClientTransport', () => {
const errorSpy = vi.fn();
transport.onerror = errorSpy;
(global.fetch as Mock).mockResolvedValueOnce({
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/plain' }),
@ -477,7 +477,7 @@ describe('StreamableHTTPClientTransport', () => {
expect(customFetch).toHaveBeenCalled();
// Global fetch should never have been called
expect(global.fetch).not.toHaveBeenCalled();
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it('should always send specified custom headers', async () => {
@ -493,7 +493,7 @@ describe('StreamableHTTPClientTransport', () => {
let actualReqInit: RequestInit = {};
(global.fetch as Mock).mockImplementation(async (_url, reqInit) => {
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
actualReqInit = reqInit;
return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } });
});
@ -509,7 +509,7 @@ describe('StreamableHTTPClientTransport', () => {
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('SecondCustomValue');
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
it('should always send specified custom headers (Headers class)', async () => {
@ -525,7 +525,7 @@ describe('StreamableHTTPClientTransport', () => {
let actualReqInit: RequestInit = {};
(global.fetch as Mock).mockImplementation(async (_url, reqInit) => {
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
actualReqInit = reqInit;
return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } });
});
@ -541,7 +541,7 @@ describe('StreamableHTTPClientTransport', () => {
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('SecondCustomValue');
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
it('should always send specified custom headers (array of tuples)', async () => {
@ -556,7 +556,7 @@ describe('StreamableHTTPClientTransport', () => {
let actualReqInit: RequestInit = {};
(global.fetch as Mock).mockImplementation(async (_url, reqInit) => {
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
actualReqInit = reqInit;
return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } });
});
@ -608,18 +608,22 @@ describe('StreamableHTTPClientTransport', () => {
id: 'test-id'
};
(global.fetch as Mock)
(globalThis.fetch as Mock)
.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
headers: new Headers(),
text: async () => Promise.reject('dont read my body')
text: async () => {
throw 'dont read my body';
}
})
.mockResolvedValue({
ok: false,
status: 404,
text: async () => Promise.reject('dont read my body')
text: async () => {
throw 'dont read my body';
}
});
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
@ -634,7 +638,7 @@ describe('StreamableHTTPClientTransport', () => {
id: 'test-id'
};
const fetchMock = global.fetch as Mock;
const fetchMock = globalThis.fetch as Mock;
fetchMock
// First call: returns 403 with insufficient_scope
.mockResolvedValueOnce({
@ -685,7 +689,7 @@ describe('StreamableHTTPClientTransport', () => {
};
// Mock fetch calls to always return 403 with insufficient_scope
const fetchMock = global.fetch as Mock;
const fetchMock = globalThis.fetch as Mock;
fetchMock.mockResolvedValue({
ok: false,
status: 403,
@ -745,7 +749,7 @@ describe('StreamableHTTPClientTransport', () => {
}
});
const fetchMock = global.fetch as Mock;
const fetchMock = globalThis.fetch as Mock;
// Mock the initial GET request, which will fail.
fetchMock.mockResolvedValueOnce({
ok: true,
@ -799,7 +803,7 @@ describe('StreamableHTTPClientTransport', () => {
}
});
const fetchMock = global.fetch as Mock;
const fetchMock = globalThis.fetch as Mock;
// Mock the POST request. It returns a streaming content-type but a failing body.
fetchMock.mockResolvedValueOnce({
ok: true,
@ -854,7 +858,7 @@ describe('StreamableHTTPClientTransport', () => {
}
});
const fetchMock = global.fetch as Mock;
const fetchMock = globalThis.fetch as Mock;
// First call: POST returns streaming response with priming event
fetchMock.mockResolvedValueOnce({
ok: true,
@ -917,7 +921,7 @@ describe('StreamableHTTPClientTransport', () => {
}
});
const fetchMock = global.fetch as Mock;
const fetchMock = globalThis.fetch as Mock;
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
@ -966,7 +970,7 @@ describe('StreamableHTTPClientTransport', () => {
}
});
const fetchMock = global.fetch as Mock;
const fetchMock = globalThis.fetch as Mock;
// POST request returns streaming response
fetchMock.mockResolvedValueOnce({
@ -1016,7 +1020,7 @@ describe('StreamableHTTPClientTransport', () => {
}
});
const fetchMock = global.fetch as Mock;
const fetchMock = globalThis.fetch as Mock;
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
@ -1065,9 +1069,11 @@ describe('StreamableHTTPClientTransport', () => {
status: 401,
statusText: 'Unauthorized',
headers: new Headers(),
text: async () => Promise.reject('dont read my body')
text: async () => {
throw 'dont read my body';
}
};
(global.fetch as Mock)
(globalThis.fetch as Mock)
// Initial connection
.mockResolvedValueOnce(unauthedResponse)
// Resource discovery, path aware
@ -1120,9 +1126,11 @@ describe('StreamableHTTPClientTransport', () => {
status: 401,
statusText: 'Unauthorized',
headers: new Headers(),
text: async () => Promise.reject('dont read my body')
text: async () => {
throw 'dont read my body';
}
};
(global.fetch as Mock)
(globalThis.fetch as Mock)
// Initial connection
.mockResolvedValueOnce(unauthedResponse)
// Resource discovery, path aware
@ -1147,7 +1155,9 @@ describe('StreamableHTTPClientTransport', () => {
.mockResolvedValue({
ok: false,
status: 404,
text: async () => Promise.reject('dont read my body')
text: async () => {
throw 'dont read my body';
}
});
// As above, just ensure the auth flow completes without unhandled
@ -1174,9 +1184,11 @@ describe('StreamableHTTPClientTransport', () => {
status: 401,
statusText: 'Unauthorized',
headers: new Headers(),
text: async () => Promise.reject('dont read my body')
text: async () => {
throw 'dont read my body';
}
};
(global.fetch as Mock)
(globalThis.fetch as Mock)
// Initial connection
.mockResolvedValueOnce(unauthedResponse)
// Resource discovery, path aware
@ -1201,7 +1213,9 @@ describe('StreamableHTTPClientTransport', () => {
.mockResolvedValue({
ok: false,
status: 404,
text: async () => Promise.reject('dont read my body')
text: async () => {
throw 'dont read my body';
}
});
// Behavior for InvalidGrantError during auth is covered in dedicated OAuth
@ -1217,7 +1231,9 @@ describe('StreamableHTTPClientTransport', () => {
status: 401,
statusText: 'Unauthorized',
headers: new Headers(),
text: async () => Promise.reject('dont read my body')
text: async () => {
throw 'dont read my body';
}
};
// Create custom fetch
@ -1278,7 +1294,7 @@ describe('StreamableHTTPClientTransport', () => {
expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled();
// Global fetch should never have been called
expect(global.fetch).not.toHaveBeenCalled();
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it('uses custom fetch in finishAuth method - no global fetch fallback', async () => {
@ -1353,14 +1369,14 @@ describe('StreamableHTTPClientTransport', () => {
});
// Global fetch should never have been called
expect(global.fetch).not.toHaveBeenCalled();
expect(globalThis.fetch).not.toHaveBeenCalled();
});
});
describe('SSE retry field handling', () => {
beforeEach(() => {
vi.useFakeTimers();
(global.fetch as Mock).mockReset();
(globalThis.fetch as Mock).mockReset();
});
afterEach(() => vi.useRealTimers());
@ -1387,7 +1403,7 @@ describe('StreamableHTTPClientTransport', () => {
}
});
const fetchMock = global.fetch as Mock;
const fetchMock = globalThis.fetch as Mock;
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
@ -1463,7 +1479,7 @@ describe('StreamableHTTPClientTransport', () => {
}
});
const fetchMock = global.fetch as Mock;
const fetchMock = globalThis.fetch as Mock;
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
@ -1582,10 +1598,12 @@ describe('StreamableHTTPClientTransport', () => {
status: 401,
statusText: 'Unauthorized',
headers: new Headers(),
text: async () => Promise.reject('dont read my body')
text: async () => {
throw 'dont read my body';
}
};
(global.fetch as Mock)
(globalThis.fetch as Mock)
// First request - 401, triggers auth flow
.mockResolvedValueOnce(unauthedResponse)
// Resource discovery, path aware

View file

@ -0,0 +1,937 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { QueuedMessage } from '../../src/experimental/tasks/interfaces.js';
import { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../src/experimental/tasks/stores/inMemory.js';
import type { Request, TaskCreationParams } from '../../src/types/types.js';
describe('InMemoryTaskStore', () => {
let store: InMemoryTaskStore;
beforeEach(() => {
store = new InMemoryTaskStore();
});
afterEach(() => {
store.cleanup();
});
describe('createTask', () => {
it('should create a new task with working status', async () => {
const taskParams: TaskCreationParams = {
ttl: 60_000
};
const request: Request = {
method: 'tools/call',
params: { name: 'test-tool' }
};
const task = await store.createTask(taskParams, 123, request);
expect(task).toBeDefined();
expect(task.taskId).toBeDefined();
expect(typeof task.taskId).toBe('string');
expect(task.taskId.length).toBeGreaterThan(0);
expect(task.status).toBe('working');
expect(task.ttl).toBe(60_000);
expect(task.pollInterval).toBeDefined();
expect(task.createdAt).toBeDefined();
expect(new Date(task.createdAt).getTime()).toBeGreaterThan(0);
});
it('should create task without ttl', async () => {
const taskParams: TaskCreationParams = {};
const request: Request = {
method: 'tools/call',
params: {}
};
const task = await store.createTask(taskParams, 456, request);
expect(task).toBeDefined();
expect(task.ttl).toBeNull();
});
it('should generate unique taskIds', async () => {
const taskParams: TaskCreationParams = {};
const request: Request = {
method: 'tools/call',
params: {}
};
const task1 = await store.createTask(taskParams, 789, request);
const task2 = await store.createTask(taskParams, 790, request);
expect(task1.taskId).not.toBe(task2.taskId);
});
});
describe('getTask', () => {
it('should return null for non-existent task', async () => {
const task = await store.getTask('non-existent');
expect(task).toBeNull();
});
it('should return task state', async () => {
const taskParams: TaskCreationParams = {};
const request: Request = {
method: 'tools/call',
params: {}
};
const createdTask = await store.createTask(taskParams, 111, request);
await store.updateTaskStatus(createdTask.taskId, 'working');
const task = await store.getTask(createdTask.taskId);
expect(task).toBeDefined();
expect(task?.status).toBe('working');
});
});
describe('updateTaskStatus', () => {
let taskId: string;
beforeEach(async () => {
const taskParams: TaskCreationParams = {};
const createdTask = await store.createTask(taskParams, 222, {
method: 'tools/call',
params: {}
});
taskId = createdTask.taskId;
});
it('should keep task status as working', async () => {
const task = await store.getTask(taskId);
expect(task?.status).toBe('working');
});
it('should update task status to input_required', async () => {
await store.updateTaskStatus(taskId, 'input_required');
const task = await store.getTask(taskId);
expect(task?.status).toBe('input_required');
});
it('should update task status to completed', async () => {
await store.updateTaskStatus(taskId, 'completed');
const task = await store.getTask(taskId);
expect(task?.status).toBe('completed');
});
it('should update task status to failed with error', async () => {
await store.updateTaskStatus(taskId, 'failed', 'Something went wrong');
const task = await store.getTask(taskId);
expect(task?.status).toBe('failed');
expect(task?.statusMessage).toBe('Something went wrong');
});
it('should update task status to cancelled', async () => {
await store.updateTaskStatus(taskId, 'cancelled');
const task = await store.getTask(taskId);
expect(task?.status).toBe('cancelled');
});
it('should throw if task not found', async () => {
await expect(store.updateTaskStatus('non-existent', 'working')).rejects.toThrow('Task with ID non-existent not found');
});
describe('status lifecycle validation', () => {
it('should allow transition from working to input_required', async () => {
await store.updateTaskStatus(taskId, 'input_required');
const task = await store.getTask(taskId);
expect(task?.status).toBe('input_required');
});
it('should allow transition from working to completed', async () => {
await store.updateTaskStatus(taskId, 'completed');
const task = await store.getTask(taskId);
expect(task?.status).toBe('completed');
});
it('should allow transition from working to failed', async () => {
await store.updateTaskStatus(taskId, 'failed');
const task = await store.getTask(taskId);
expect(task?.status).toBe('failed');
});
it('should allow transition from working to cancelled', async () => {
await store.updateTaskStatus(taskId, 'cancelled');
const task = await store.getTask(taskId);
expect(task?.status).toBe('cancelled');
});
it('should allow transition from input_required to working', async () => {
await store.updateTaskStatus(taskId, 'input_required');
await store.updateTaskStatus(taskId, 'working');
const task = await store.getTask(taskId);
expect(task?.status).toBe('working');
});
it('should allow transition from input_required to completed', async () => {
await store.updateTaskStatus(taskId, 'input_required');
await store.updateTaskStatus(taskId, 'completed');
const task = await store.getTask(taskId);
expect(task?.status).toBe('completed');
});
it('should allow transition from input_required to failed', async () => {
await store.updateTaskStatus(taskId, 'input_required');
await store.updateTaskStatus(taskId, 'failed');
const task = await store.getTask(taskId);
expect(task?.status).toBe('failed');
});
it('should allow transition from input_required to cancelled', async () => {
await store.updateTaskStatus(taskId, 'input_required');
await store.updateTaskStatus(taskId, 'cancelled');
const task = await store.getTask(taskId);
expect(task?.status).toBe('cancelled');
});
it('should reject transition from completed to any other status', async () => {
await store.updateTaskStatus(taskId, 'completed');
await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'failed')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'cancelled')).rejects.toThrow('Cannot update task');
});
it('should reject transition from failed to any other status', async () => {
await store.updateTaskStatus(taskId, 'failed');
await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'completed')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'cancelled')).rejects.toThrow('Cannot update task');
});
it('should reject transition from cancelled to any other status', async () => {
await store.updateTaskStatus(taskId, 'cancelled');
await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'completed')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'failed')).rejects.toThrow('Cannot update task');
});
});
});
describe('storeTaskResult', () => {
let taskId: string;
beforeEach(async () => {
const taskParams: TaskCreationParams = {
ttl: 60_000
};
const createdTask = await store.createTask(taskParams, 333, {
method: 'tools/call',
params: {}
});
taskId = createdTask.taskId;
});
it('should store task result and set status to completed', async () => {
const result = {
content: [{ type: 'text' as const, text: 'Success!' }]
};
await store.storeTaskResult(taskId, 'completed', result);
const task = await store.getTask(taskId);
expect(task?.status).toBe('completed');
const storedResult = await store.getTaskResult(taskId);
expect(storedResult).toStrictEqual(result);
});
it('should throw if task not found', async () => {
await expect(store.storeTaskResult('non-existent', 'completed', {})).rejects.toThrow('Task with ID non-existent not found');
});
it('should reject storing result for task already in completed status', async () => {
// First complete the task
const firstResult = {
content: [{ type: 'text' as const, text: 'First result' }]
};
await store.storeTaskResult(taskId, 'completed', firstResult);
// Try to store result again (should fail)
const secondResult = {
content: [{ type: 'text' as const, text: 'Second result' }]
};
await expect(store.storeTaskResult(taskId, 'completed', secondResult)).rejects.toThrow('Cannot store result for task');
});
it('should store result with failed status', async () => {
const result = {
content: [{ type: 'text' as const, text: 'Error details' }],
isError: true
};
await store.storeTaskResult(taskId, 'failed', result);
const task = await store.getTask(taskId);
expect(task?.status).toBe('failed');
const storedResult = await store.getTaskResult(taskId);
expect(storedResult).toStrictEqual(result);
});
it('should reject storing result for task already in failed status', async () => {
// First fail the task
const firstResult = {
content: [{ type: 'text' as const, text: 'First error' }],
isError: true
};
await store.storeTaskResult(taskId, 'failed', firstResult);
// Try to store result again (should fail)
const secondResult = {
content: [{ type: 'text' as const, text: 'Second error' }],
isError: true
};
await expect(store.storeTaskResult(taskId, 'failed', secondResult)).rejects.toThrow('Cannot store result for task');
});
it('should reject storing result for cancelled task', async () => {
// Mark task as cancelled
await store.updateTaskStatus(taskId, 'cancelled');
// Try to store result (should fail)
const result = {
content: [{ type: 'text' as const, text: 'Cancellation result' }]
};
await expect(store.storeTaskResult(taskId, 'completed', result)).rejects.toThrow('Cannot store result for task');
});
it('should allow storing result from input_required status', async () => {
await store.updateTaskStatus(taskId, 'input_required');
const result = {
content: [{ type: 'text' as const, text: 'Success!' }]
};
await store.storeTaskResult(taskId, 'completed', result);
const task = await store.getTask(taskId);
expect(task?.status).toBe('completed');
});
});
describe('getTaskResult', () => {
it('should throw if task not found', async () => {
await expect(store.getTaskResult('non-existent')).rejects.toThrow('Task with ID non-existent not found');
});
it('should throw if task has no result stored', async () => {
const taskParams: TaskCreationParams = {};
const createdTask = await store.createTask(taskParams, 444, {
method: 'tools/call',
params: {}
});
await expect(store.getTaskResult(createdTask.taskId)).rejects.toThrow(`Task ${createdTask.taskId} has no result stored`);
});
it('should return stored result', async () => {
const taskParams: TaskCreationParams = {};
const createdTask = await store.createTask(taskParams, 555, {
method: 'tools/call',
params: {}
});
const result = {
content: [{ type: 'text' as const, text: 'Result data' }]
};
await store.storeTaskResult(createdTask.taskId, 'completed', result);
const retrieved = await store.getTaskResult(createdTask.taskId);
expect(retrieved).toStrictEqual(result);
});
});
describe('ttl cleanup', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should cleanup task after ttl duration', async () => {
const taskParams: TaskCreationParams = {
ttl: 1000
};
const createdTask = await store.createTask(taskParams, 666, {
method: 'tools/call',
params: {}
});
// Task should exist initially
let task = await store.getTask(createdTask.taskId);
expect(task).toBeDefined();
// Fast-forward past ttl
vi.advanceTimersByTime(1001);
// Task should be cleaned up
task = await store.getTask(createdTask.taskId);
expect(task).toBeNull();
});
it('should reset cleanup timer when result is stored', async () => {
const taskParams: TaskCreationParams = {
ttl: 1000
};
const createdTask = await store.createTask(taskParams, 777, {
method: 'tools/call',
params: {}
});
// Fast-forward 500ms
vi.advanceTimersByTime(500);
// Store result (should reset timer)
await store.storeTaskResult(createdTask.taskId, 'completed', {
content: [{ type: 'text' as const, text: 'Done' }]
});
// Fast-forward another 500ms (total 1000ms since creation, but timer was reset)
vi.advanceTimersByTime(500);
// Task should still exist
const task = await store.getTask(createdTask.taskId);
expect(task).toBeDefined();
// Fast-forward remaining time
vi.advanceTimersByTime(501);
// Now task should be cleaned up
const cleanedTask = await store.getTask(createdTask.taskId);
expect(cleanedTask).toBeNull();
});
it('should not cleanup tasks without ttl', async () => {
const taskParams: TaskCreationParams = {};
const createdTask = await store.createTask(taskParams, 888, {
method: 'tools/call',
params: {}
});
// Fast-forward a long time
vi.advanceTimersByTime(100_000);
// Task should still exist
const task = await store.getTask(createdTask.taskId);
expect(task).toBeDefined();
});
it('should start cleanup timer when task reaches terminal state', async () => {
const taskParams: TaskCreationParams = {
ttl: 1000
};
const createdTask = await store.createTask(taskParams, 999, {
method: 'tools/call',
params: {}
});
// Task in non-terminal state, fast-forward
vi.advanceTimersByTime(1001);
// Task should be cleaned up
let task = await store.getTask(createdTask.taskId);
expect(task).toBeNull();
// Create another task
const taskParams2: TaskCreationParams = {
ttl: 2000
};
const createdTask2 = await store.createTask(taskParams2, 1000, {
method: 'tools/call',
params: {}
});
// Update to terminal state
await store.updateTaskStatus(createdTask2.taskId, 'completed');
// Fast-forward past original ttl
vi.advanceTimersByTime(2001);
// Task should be cleaned up
task = await store.getTask(createdTask2.taskId);
expect(task).toBeNull();
});
it('should return actual TTL in task response', async () => {
// Test that the TaskStore returns the actual TTL it will use
// This implementation uses the requested TTL as-is, but implementations
// MAY override it (e.g., enforce maximum TTL limits)
const requestedTtl = 5000;
const taskParams: TaskCreationParams = {
ttl: requestedTtl
};
const createdTask = await store.createTask(taskParams, 1111, {
method: 'tools/call',
params: {}
});
// The returned task should include the actual TTL that will be used
expect(createdTask.ttl).toBe(requestedTtl);
// Verify the task is cleaned up after the actual TTL
vi.advanceTimersByTime(requestedTtl + 1);
const task = await store.getTask(createdTask.taskId);
expect(task).toBeNull();
});
it('should support null TTL for unlimited lifetime', async () => {
// Test that null TTL means unlimited lifetime
const taskParams: TaskCreationParams = {
ttl: null
};
const createdTask = await store.createTask(taskParams, 2222, {
method: 'tools/call',
params: {}
});
// The returned task should have null TTL
expect(createdTask.ttl).toBeNull();
// Task should not be cleaned up even after a long time
vi.advanceTimersByTime(100_000);
const task = await store.getTask(createdTask.taskId);
expect(task).toBeDefined();
expect(task?.taskId).toBe(createdTask.taskId);
});
it('should cleanup tasks regardless of status', async () => {
// Test that TTL cleanup happens regardless of task status
const taskParams: TaskCreationParams = {
ttl: 1000
};
// Create tasks in different statuses
const workingTask = await store.createTask(taskParams, 3333, {
method: 'tools/call',
params: {}
});
const completedTask = await store.createTask(taskParams, 4444, {
method: 'tools/call',
params: {}
});
await store.storeTaskResult(completedTask.taskId, 'completed', {
content: [{ type: 'text' as const, text: 'Done' }]
});
const failedTask = await store.createTask(taskParams, 5555, {
method: 'tools/call',
params: {}
});
await store.storeTaskResult(failedTask.taskId, 'failed', {
content: [{ type: 'text' as const, text: 'Error' }]
});
// Fast-forward past TTL
vi.advanceTimersByTime(1001);
// All tasks should be cleaned up regardless of status
expect(await store.getTask(workingTask.taskId)).toBeNull();
expect(await store.getTask(completedTask.taskId)).toBeNull();
expect(await store.getTask(failedTask.taskId)).toBeNull();
});
});
describe('getAllTasks', () => {
it('should return all tasks', async () => {
await store.createTask({}, 1, {
method: 'tools/call',
params: {}
});
await store.createTask({}, 2, {
method: 'tools/call',
params: {}
});
await store.createTask({}, 3, {
method: 'tools/call',
params: {}
});
const tasks = store.getAllTasks();
expect(tasks).toHaveLength(3);
// Verify all tasks have unique IDs
const taskIds = tasks.map(t => t.taskId);
expect(new Set(taskIds).size).toBe(3);
});
it('should return empty array when no tasks', () => {
const tasks = store.getAllTasks();
expect(tasks).toStrictEqual([]);
});
});
describe('listTasks', () => {
it('should return empty list when no tasks', async () => {
const result = await store.listTasks();
expect(result.tasks).toStrictEqual([]);
expect(result.nextCursor).toBeUndefined();
});
it('should return all tasks when less than page size', async () => {
await store.createTask({}, 1, {
method: 'tools/call',
params: {}
});
await store.createTask({}, 2, {
method: 'tools/call',
params: {}
});
await store.createTask({}, 3, {
method: 'tools/call',
params: {}
});
const result = await store.listTasks();
expect(result.tasks).toHaveLength(3);
expect(result.nextCursor).toBeUndefined();
});
it('should paginate when more than page size', async () => {
// Create 15 tasks (page size is 10)
for (let i = 1; i <= 15; i++) {
await store.createTask({}, i, {
method: 'tools/call',
params: {}
});
}
// Get first page
const page1 = await store.listTasks();
expect(page1.tasks).toHaveLength(10);
expect(page1.nextCursor).toBeDefined();
// Get second page using cursor
const page2 = await store.listTasks(page1.nextCursor);
expect(page2.tasks).toHaveLength(5);
expect(page2.nextCursor).toBeUndefined();
});
it('should throw error for invalid cursor', async () => {
await store.createTask({}, 1, {
method: 'tools/call',
params: {}
});
await expect(store.listTasks('non-existent-cursor')).rejects.toThrow('Invalid cursor: non-existent-cursor');
});
it('should continue from cursor correctly', async () => {
// Create 5 tasks
for (let i = 1; i <= 5; i++) {
await store.createTask({}, i, {
method: 'tools/call',
params: {}
});
}
// Get first 3 tasks
const allTaskIds = store.getAllTasks().map(t => t.taskId);
const result = await store.listTasks(allTaskIds[2]);
// Should get tasks after the third task
expect(result.tasks).toHaveLength(2);
});
});
describe('cleanup', () => {
it('should clear all timers and tasks', async () => {
await store.createTask({ ttl: 1000 }, 1, {
method: 'tools/call',
params: {}
});
await store.createTask({ ttl: 2000 }, 2, {
method: 'tools/call',
params: {}
});
expect(store.getAllTasks()).toHaveLength(2);
store.cleanup();
expect(store.getAllTasks()).toHaveLength(0);
});
});
});
describe('InMemoryTaskMessageQueue', () => {
let queue: InMemoryTaskMessageQueue;
beforeEach(() => {
queue = new InMemoryTaskMessageQueue();
});
describe('enqueue and dequeue', () => {
it('should enqueue and dequeue request messages', async () => {
const requestMessage: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'test-tool', arguments: {} }
},
timestamp: Date.now()
};
await queue.enqueue('task-1', requestMessage);
const dequeued = await queue.dequeue('task-1');
expect(dequeued).toStrictEqual(requestMessage);
});
it('should enqueue and dequeue notification messages', async () => {
const notificationMessage: QueuedMessage = {
type: 'notification',
message: {
jsonrpc: '2.0',
method: 'notifications/progress',
params: { progress: 50, total: 100 }
},
timestamp: Date.now()
};
await queue.enqueue('task-2', notificationMessage);
const dequeued = await queue.dequeue('task-2');
expect(dequeued).toStrictEqual(notificationMessage);
});
it('should enqueue and dequeue response messages', async () => {
const responseMessage: QueuedMessage = {
type: 'response',
message: {
jsonrpc: '2.0',
id: 42,
result: { content: [{ type: 'text', text: 'Success' }] }
},
timestamp: Date.now()
};
await queue.enqueue('task-3', responseMessage);
const dequeued = await queue.dequeue('task-3');
expect(dequeued).toStrictEqual(responseMessage);
});
it('should return undefined when dequeuing from empty queue', async () => {
const dequeued = await queue.dequeue('task-empty');
expect(dequeued).toBeUndefined();
});
it('should maintain FIFO order for mixed message types', async () => {
const request: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {}
},
timestamp: 1000
};
const notification: QueuedMessage = {
type: 'notification',
message: {
jsonrpc: '2.0',
method: 'notifications/progress',
params: {}
},
timestamp: 2000
};
const response: QueuedMessage = {
type: 'response',
message: {
jsonrpc: '2.0',
id: 1,
result: {}
},
timestamp: 3000
};
await queue.enqueue('task-fifo', request);
await queue.enqueue('task-fifo', notification);
await queue.enqueue('task-fifo', response);
expect(await queue.dequeue('task-fifo')).toStrictEqual(request);
expect(await queue.dequeue('task-fifo')).toStrictEqual(notification);
expect(await queue.dequeue('task-fifo')).toStrictEqual(response);
expect(await queue.dequeue('task-fifo')).toBeUndefined();
});
});
describe('dequeueAll', () => {
it('should dequeue all messages including responses', async () => {
const request: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {}
},
timestamp: 1000
};
const response: QueuedMessage = {
type: 'response',
message: {
jsonrpc: '2.0',
id: 1,
result: {}
},
timestamp: 2000
};
const notification: QueuedMessage = {
type: 'notification',
message: {
jsonrpc: '2.0',
method: 'notifications/progress',
params: {}
},
timestamp: 3000
};
await queue.enqueue('task-all', request);
await queue.enqueue('task-all', response);
await queue.enqueue('task-all', notification);
const all = await queue.dequeueAll('task-all');
expect(all).toHaveLength(3);
expect(all[0]).toStrictEqual(request);
expect(all[1]).toStrictEqual(response);
expect(all[2]).toStrictEqual(notification);
});
it('should return empty array for non-existent task', async () => {
const all = await queue.dequeueAll('non-existent');
expect(all).toStrictEqual([]);
});
it('should clear the queue after dequeueAll', async () => {
const message: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'test',
params: {}
},
timestamp: Date.now()
};
await queue.enqueue('task-clear', message);
await queue.dequeueAll('task-clear');
const dequeued = await queue.dequeue('task-clear');
expect(dequeued).toBeUndefined();
});
});
describe('queue size limits', () => {
it('should throw when maxSize is exceeded', async () => {
const message: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'test',
params: {}
},
timestamp: Date.now()
};
await queue.enqueue('task-limit', message, undefined, 2);
await queue.enqueue('task-limit', message, undefined, 2);
await expect(queue.enqueue('task-limit', message, undefined, 2)).rejects.toThrow('Task message queue overflow');
});
it('should allow enqueue when under maxSize', async () => {
const message: QueuedMessage = {
type: 'response',
message: {
jsonrpc: '2.0',
id: 1,
result: {}
},
timestamp: Date.now()
};
await expect(queue.enqueue('task-ok', message, undefined, 5)).resolves.toBeUndefined();
});
});
describe('task isolation', () => {
it('should isolate messages between different tasks', async () => {
const message1: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'test1',
params: {}
},
timestamp: 1000
};
const message2: QueuedMessage = {
type: 'response',
message: {
jsonrpc: '2.0',
id: 2,
result: {}
},
timestamp: 2000
};
await queue.enqueue('task-a', message1);
await queue.enqueue('task-b', message2);
expect(await queue.dequeue('task-a')).toStrictEqual(message1);
expect(await queue.dequeue('task-b')).toStrictEqual(message2);
expect(await queue.dequeue('task-a')).toBeUndefined();
expect(await queue.dequeue('task-b')).toBeUndefined();
});
});
describe('response message error handling', () => {
it('should handle response messages with errors', async () => {
const errorResponse: QueuedMessage = {
type: 'error',
message: {
jsonrpc: '2.0',
id: 1,
error: {
code: -32_600,
message: 'Invalid Request'
}
},
timestamp: Date.now()
};
await queue.enqueue('task-error', errorResponse);
const dequeued = await queue.dequeue('task-error');
expect(dequeued).toStrictEqual(errorResponse);
expect(dequeued?.type).toBe('error');
});
});
});

View file

@ -0,0 +1,90 @@
import { checkResourceAllowed, resourceUrlFromServerUrl } from '../../src/shared/authUtils.js';
describe('auth-utils', () => {
describe('resourceUrlFromServerUrl', () => {
it('should remove fragments', () => {
expect(resourceUrlFromServerUrl(new URL('https://example.com/path#fragment')).href).toBe('https://example.com/path');
expect(resourceUrlFromServerUrl(new URL('https://example.com#fragment')).href).toBe('https://example.com/');
expect(resourceUrlFromServerUrl(new URL('https://example.com/path?query=1#fragment')).href).toBe(
'https://example.com/path?query=1'
);
});
it('should return URL unchanged if no fragment', () => {
expect(resourceUrlFromServerUrl(new URL('https://example.com')).href).toBe('https://example.com/');
expect(resourceUrlFromServerUrl(new URL('https://example.com/path')).href).toBe('https://example.com/path');
expect(resourceUrlFromServerUrl(new URL('https://example.com/path?query=1')).href).toBe('https://example.com/path?query=1');
});
it('should keep everything else unchanged', () => {
// Case sensitivity preserved
expect(resourceUrlFromServerUrl(new URL('https://EXAMPLE.COM/PATH')).href).toBe('https://example.com/PATH');
// Ports preserved
expect(resourceUrlFromServerUrl(new URL('https://example.com:443/path')).href).toBe('https://example.com/path');
expect(resourceUrlFromServerUrl(new URL('https://example.com:8080/path')).href).toBe('https://example.com:8080/path');
// Query parameters preserved
expect(resourceUrlFromServerUrl(new URL('https://example.com?foo=bar&baz=qux')).href).toBe(
'https://example.com/?foo=bar&baz=qux'
);
// Trailing slashes preserved
expect(resourceUrlFromServerUrl(new URL('https://example.com/')).href).toBe('https://example.com/');
expect(resourceUrlFromServerUrl(new URL('https://example.com/path/')).href).toBe('https://example.com/path/');
});
});
describe('resourceMatches', () => {
it('should match identical URLs', () => {
expect(
checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.com/path' })
).toBe(true);
expect(checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/' })).toBe(
true
);
});
it('should not match URLs with different paths', () => {
expect(
checkResourceAllowed({ requestedResource: 'https://example.com/path1', configuredResource: 'https://example.com/path2' })
).toBe(false);
expect(
checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/path' })
).toBe(false);
});
it('should not match URLs with different domains', () => {
expect(
checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.org/path' })
).toBe(false);
});
it('should not match URLs with different ports', () => {
expect(
checkResourceAllowed({ requestedResource: 'https://example.com:8080/path', configuredResource: 'https://example.com/path' })
).toBe(false);
});
it('should not match URLs where one path is a sub-path of another', () => {
expect(
checkResourceAllowed({ requestedResource: 'https://example.com/mcpxxxx', configuredResource: 'https://example.com/mcp' })
).toBe(false);
expect(
checkResourceAllowed({
requestedResource: 'https://example.com/folder',
configuredResource: 'https://example.com/folder/subfolder'
})
).toBe(false);
expect(
checkResourceAllowed({ requestedResource: 'https://example.com/api/v1', configuredResource: 'https://example.com/api' })
).toBe(true);
});
it('should handle trailing slashes vs no trailing slashes', () => {
expect(
checkResourceAllowed({ requestedResource: 'https://example.com/mcp/', configuredResource: 'https://example.com/mcp' })
).toBe(true);
expect(
checkResourceAllowed({ requestedResource: 'https://example.com/folder', configuredResource: 'https://example.com/folder/' })
).toBe(false);
});
});
});

View file

@ -0,0 +1,190 @@
import { beforeEach, describe, expect, test } from 'vitest';
import * as z from 'zod/v4';
import { Protocol } from '../../src/shared/protocol.js';
import type { Transport } from '../../src/shared/transport.js';
import type { JSONRPCMessage, Notification, Request, Result } from '../../src/types/types.js';
// Mock Transport class
class MockTransport implements Transport {
id: string;
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: unknown) => void;
sentMessages: JSONRPCMessage[] = [];
constructor(id: string) {
this.id = id;
}
async start(): Promise<void> {}
async close(): Promise<void> {
this.onclose?.();
}
async send(message: JSONRPCMessage): Promise<void> {
this.sentMessages.push(message);
}
}
describe('Protocol transport handling bug', () => {
let protocol: Protocol<Request, Notification, Result>;
let transportA: MockTransport;
let transportB: MockTransport;
beforeEach(() => {
protocol = new (class extends Protocol<Request, Notification, Result> {
protected assertCapabilityForMethod(): void {}
protected assertNotificationCapability(): void {}
protected assertRequestHandlerCapability(): void {}
protected assertTaskCapability(): void {}
protected assertTaskHandlerCapability(): void {}
})();
transportA = new MockTransport('A');
transportB = new MockTransport('B');
});
test('should send response to the correct transport when multiple clients are connected', async () => {
// Set up a request handler that simulates processing time
let resolveHandler: (value: Result) => void;
const handlerPromise = new Promise<Result>(resolve => {
resolveHandler = resolve;
});
const TestRequestSchema = z.object({
method: z.literal('test/method'),
params: z
.object({
from: z.string()
})
.optional()
});
protocol.setRequestHandler(TestRequestSchema, async request => {
console.log(`Processing request from ${request.params?.from}`);
return handlerPromise;
});
// Client A connects and sends a request
await protocol.connect(transportA);
const requestFromA = {
jsonrpc: '2.0' as const,
method: 'test/method',
params: { from: 'clientA' },
id: 1
};
// Simulate client A sending a request
transportA.onmessage?.(requestFromA);
// While A's request is being processed, client B connects
// This overwrites the transport reference in the protocol
await protocol.connect(transportB);
const requestFromB = {
jsonrpc: '2.0' as const,
method: 'test/method',
params: { from: 'clientB' },
id: 2
};
// Client B sends its own request
transportB.onmessage?.(requestFromB);
// Now complete A's request
resolveHandler!({ data: 'responseForA' } as Result);
// Wait for async operations to complete
await new Promise(resolve => setTimeout(resolve, 10));
// Check where the responses went
console.log('Transport A received:', transportA.sentMessages);
console.log('Transport B received:', transportB.sentMessages);
// FIXED: Each transport now receives its own response
// Transport A should receive response for request ID 1
expect(transportA.sentMessages.length).toBe(1);
expect(transportA.sentMessages[0]).toMatchObject({
jsonrpc: '2.0',
id: 1,
result: { data: 'responseForA' }
});
// Transport B should only receive its own response (when implemented)
expect(transportB.sentMessages.length).toBe(1);
expect(transportB.sentMessages[0]).toMatchObject({
jsonrpc: '2.0',
id: 2,
result: { data: 'responseForA' } // Same handler result in this test
});
});
test('demonstrates the timing issue with multiple rapid connections', async () => {
const delays: number[] = [];
const results: { transport: string; response: JSONRPCMessage[] }[] = [];
const DelayedRequestSchema = z.object({
method: z.literal('test/delayed'),
params: z
.object({
delay: z.number(),
client: z.string()
})
.optional()
});
// Set up handler with variable delay
protocol.setRequestHandler(DelayedRequestSchema, async (request, extra) => {
const delay = request.params?.delay || 0;
delays.push(delay);
await new Promise(resolve => setTimeout(resolve, delay));
return {
processedBy: `handler-${extra.requestId}`,
delay: delay
} as Result;
});
// Rapid succession of connections and requests
await protocol.connect(transportA);
transportA.onmessage?.({
jsonrpc: '2.0' as const,
method: 'test/delayed',
params: { delay: 50, client: 'A' },
id: 1
});
// Connect B while A is processing
setTimeout(async () => {
await protocol.connect(transportB);
transportB.onmessage?.({
jsonrpc: '2.0' as const,
method: 'test/delayed',
params: { delay: 10, client: 'B' },
id: 2
});
}, 10);
// Wait for all processing
await new Promise(resolve => setTimeout(resolve, 100));
// Collect results
if (transportA.sentMessages.length > 0) {
results.push({ transport: 'A', response: transportA.sentMessages });
}
if (transportB.sentMessages.length > 0) {
results.push({ transport: 'B', response: transportB.sentMessages });
}
console.log('Timing test results:', results);
// FIXED: Each transport receives its own responses
expect(transportA.sentMessages.length).toBe(1);
expect(transportB.sentMessages.length).toBe(1);
});
});

View file

@ -200,7 +200,7 @@ describe('UriTemplate', () => {
describe('security and edge cases', () => {
it('should handle extremely long input strings', () => {
const longString = 'x'.repeat(100000);
const longString = 'x'.repeat(100_000);
const template = new UriTemplate(`/api/{param}`);
expect(template.expand({ param: longString })).toBe(`/api/${longString}`);
expect(template.match(`/api/${longString}`)).toEqual({ param: longString });
@ -234,7 +234,7 @@ describe('UriTemplate', () => {
it('should handle pathological regex patterns', () => {
const template = new UriTemplate('/api/{param}');
// Create a string that could cause catastrophic backtracking
const input = '/api/' + 'a'.repeat(100000);
const input = '/api/' + 'a'.repeat(100_000);
expect(() => template.match(input)).not.toThrow();
});
@ -273,15 +273,14 @@ describe('UriTemplate', () => {
it('should handle maximum template expression limit', () => {
// Create a template with many expressions
const expressions = Array(10000).fill('{param}').join('');
const expressions = Array.from({ length: 10_000 }).fill('{param}').join('');
expect(() => new UriTemplate(expressions)).not.toThrow();
});
it('should handle maximum variable name length', () => {
const longName = 'a'.repeat(10000);
const longName = 'a'.repeat(10_000);
const template = new UriTemplate(`{${longName}}`);
const vars: Record<string, string> = {};
vars[longName] = 'value';
const vars: Record<string, string> = { [longName]: 'value' };
expect(() => template.expand(vars)).not.toThrow();
});

View file

@ -707,8 +707,8 @@ function extractExportedTypes(source: string): string[] {
}
describe('Spec Types', () => {
const specTypes = extractExportedTypes(fs.readFileSync(SPEC_TYPES_FILE, 'utf-8'));
const sdkTypes = extractExportedTypes(fs.readFileSync(SDK_TYPES_FILE, 'utf-8'));
const specTypes = extractExportedTypes(fs.readFileSync(SPEC_TYPES_FILE, 'utf8'));
const sdkTypes = extractExportedTypes(fs.readFileSync(SDK_TYPES_FILE, 'utf8'));
const typesToCheck = specTypes.filter(type => !MISSING_SDK_TYPES.includes(type));
it('should define some expected types', () => {

View file

@ -912,7 +912,7 @@ describe('Types', () => {
test('should validate all new stop reasons', () => {
const stopReasons = ['endTurn', 'stopSequence', 'maxTokens', 'toolUse', 'refusal', 'other'];
stopReasons.forEach(stopReason => {
for (const stopReason of stopReasons) {
const result = {
model: 'test',
role: 'assistant',
@ -922,7 +922,7 @@ describe('Types', () => {
const parseResult = CreateMessageResultSchema.safeParse(result);
expect(parseResult.success).toBe(true);
});
}
});
test('should allow custom stop reason string', () => {

View file

@ -0,0 +1,182 @@
import type { NextFunction, Request, Response } from 'express';
import { vi } from 'vitest';
import { createMcpExpressApp } from '../src/express.js';
import { hostHeaderValidation, localhostHostValidation } from '../src/middleware/hostHeaderValidation.js';
// Helper to create mock Express request/response/next
function createMockReqResNext(host?: string) {
const req = {
headers: {
host
}
} as Request;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis()
} as unknown as Response;
const next = vi.fn() as NextFunction;
return { req, res, next };
}
describe('@modelcontextprotocol/express', () => {
describe('hostHeaderValidation', () => {
test('should block invalid Host header', () => {
const middleware = hostHeaderValidation(['localhost']);
const { req, res, next } = createMockReqResNext('evil.com:3000');
middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
jsonrpc: '2.0',
error: expect.objectContaining({
code: -32_000
}),
id: null
})
);
expect(next).not.toHaveBeenCalled();
});
test('should allow valid Host header', () => {
const middleware = hostHeaderValidation(['localhost']);
const { req, res, next } = createMockReqResNext('localhost:3000');
middleware(req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});
test('should handle multiple allowed hostnames', () => {
const middleware = hostHeaderValidation(['localhost', '127.0.0.1', 'myapp.local']);
const { req: req1, res: res1, next: next1 } = createMockReqResNext('127.0.0.1:8080');
const { req: req2, res: res2, next: next2 } = createMockReqResNext('myapp.local');
middleware(req1, res1, next1);
middleware(req2, res2, next2);
expect(next1).toHaveBeenCalled();
expect(next2).toHaveBeenCalled();
});
});
describe('localhostHostValidation', () => {
test('should allow localhost', () => {
const middleware = localhostHostValidation();
const { req, res, next } = createMockReqResNext('localhost:3000');
middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should allow 127.0.0.1', () => {
const middleware = localhostHostValidation();
const { req, res, next } = createMockReqResNext('127.0.0.1:3000');
middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should allow [::1] (IPv6 localhost)', () => {
const middleware = localhostHostValidation();
const { req, res, next } = createMockReqResNext('[::1]:3000');
middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should block non-localhost hosts', () => {
const middleware = localhostHostValidation();
const { req, res, next } = createMockReqResNext('evil.com:3000');
middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
});
describe('createMcpExpressApp', () => {
test('should enable localhost DNS rebinding protection by default', () => {
const app = createMcpExpressApp();
// The app should be a valid Express application
expect(app).toBeDefined();
expect(typeof app.use).toBe('function');
expect(typeof app.get).toBe('function');
expect(typeof app.post).toBe('function');
});
test('should apply DNS rebinding protection for localhost host', () => {
const app = createMcpExpressApp({ host: 'localhost' });
expect(app).toBeDefined();
});
test('should apply DNS rebinding protection for ::1 host', () => {
const app = createMcpExpressApp({ host: '::1' });
expect(app).toBeDefined();
});
test('should use allowedHosts when provided', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] });
warn.mockRestore();
expect(app).toBeDefined();
});
test('should warn when binding to 0.0.0.0 without allowedHosts', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
createMcpExpressApp({ host: '0.0.0.0' });
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Warning: Server is binding to 0.0.0.0 without DNS rebinding protection')
);
warn.mockRestore();
});
test('should warn when binding to :: without allowedHosts', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
createMcpExpressApp({ host: '::' });
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Warning: Server is binding to :: without DNS rebinding protection'));
warn.mockRestore();
});
test('should not warn for 0.0.0.0 when allowedHosts is provided', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] });
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
test('should not apply host validation for non-localhost hosts without allowedHosts', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
// For arbitrary hosts (not 0.0.0.0 or ::), no validation is applied and no warning
const app = createMcpExpressApp({ host: '192.168.1.1' });
expect(warn).not.toHaveBeenCalled();
expect(app).toBeDefined();
warn.mockRestore();
});
});
});

View file

@ -0,0 +1,109 @@
import type { Context } from 'hono';
import { Hono } from 'hono';
import { vi } from 'vitest';
import { createMcpHonoApp } from '../src/hono.js';
import { hostHeaderValidation } from '../src/middleware/hostHeaderValidation.js';
describe('@modelcontextprotocol/hono', () => {
test('hostHeaderValidation blocks invalid Host and allows valid Host', async () => {
const app = new Hono();
app.use('*', hostHeaderValidation(['localhost']));
app.get('/health', c => c.text('ok'));
const bad = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } });
expect(bad.status).toBe(403);
expect(await bad.json()).toEqual(
expect.objectContaining({
jsonrpc: '2.0',
error: expect.objectContaining({
code: -32_000
}),
id: null
})
);
const good = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000' } });
expect(good.status).toBe(200);
expect(await good.text()).toBe('ok');
});
test('createMcpHonoApp enables localhost DNS rebinding protection by default', async () => {
const app = createMcpHonoApp();
app.get('/health', c => c.text('ok'));
const bad = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } });
expect(bad.status).toBe(403);
const good = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000' } });
expect(good.status).toBe(200);
});
test('createMcpHonoApp uses allowedHosts when provided (even when binding to 0.0.0.0)', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const app = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] });
warn.mockRestore();
app.get('/health', c => c.text('ok'));
const bad = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } });
expect(bad.status).toBe(403);
const good = await app.request('http://localhost/health', { headers: { Host: 'myapp.local:3000' } });
expect(good.status).toBe(200);
});
test('createMcpHonoApp does not apply host validation for 0.0.0.0 without allowedHosts', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const app = createMcpHonoApp({ host: '0.0.0.0' });
warn.mockRestore();
app.get('/health', c => c.text('ok'));
const res = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } });
expect(res.status).toBe(200);
});
test('createMcpHonoApp parses JSON bodies into parsedBody (express.json()-like)', async () => {
const app = createMcpHonoApp();
app.post('/echo', (c: Context) => c.json(c.get('parsedBody')));
const res = await app.request('http://localhost/echo', {
method: 'POST',
headers: { Host: 'localhost:3000', 'content-type': 'application/json' },
body: JSON.stringify({ a: 1 })
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ a: 1 });
});
test('createMcpHonoApp returns 400 on invalid JSON', async () => {
const app = createMcpHonoApp();
app.post('/echo', (c: Context) => c.text('ok'));
const res = await app.request('http://localhost/echo', {
method: 'POST',
headers: { Host: 'localhost:3000', 'content-type': 'application/json' },
body: '{"a":'
});
expect(res.status).toBe(400);
expect(await res.text()).toBe('Invalid JSON');
});
test('createMcpHonoApp does not override parsedBody if upstream middleware set it', async () => {
const app = createMcpHonoApp();
app.use('/echo', async (c: Context, next) => {
c.set('parsedBody', { preset: true });
return await next();
});
app.post('/echo', (c: Context) => c.json(c.get('parsedBody')));
const res = await app.request('http://localhost/echo', {
method: 'POST',
headers: { Host: 'localhost:3000', 'content-type': 'application/json' },
body: JSON.stringify({ a: 1 })
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ preset: true });
});
});

View file

@ -26,7 +26,7 @@ async function getFreePort() {
srv.listen(0, () => {
const address = srv.address()!;
if (typeof address === 'string') {
throw new Error('Unexpected address type: ' + typeof address);
throw new TypeError('Unexpected address type: ' + typeof address);
}
const port = (address as AddressInfo).port;
srv.close(_err => res(port));
@ -152,12 +152,13 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
/**
* Helper to create and start test HTTP server with MCP setup
*/
async function createTestServer(config: TestServerConfig = { sessionIdGenerator: () => randomUUID() }): Promise<{
async function createTestServer(config?: TestServerConfig): Promise<{
server: Server;
transport: NodeStreamableHTTPServerTransport;
mcpServer: McpServer;
baseUrl: URL;
}> {
config ??= { sessionIdGenerator: () => randomUUID() };
const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } });
mcpServer.tool(
@ -182,11 +183,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
const server = createServer(async (req, res) => {
try {
if (config.customRequestHandler) {
await config.customRequestHandler(req, res);
} else {
await transport.handleRequest(req, res);
}
await (config.customRequestHandler ? config.customRequestHandler(req, res) : transport.handleRequest(req, res));
} catch (error) {
console.error('Error handling request:', error);
if (!res.headersSent) res.writeHead(500).end();
@ -299,7 +296,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(400);
const errorData = await response.json();
expectErrorResponse(errorData, -32600, /Server already initialized/);
expectErrorResponse(errorData, -32_600, /Server already initialized/);
});
it('should reject batch initialize request', async () => {
@ -320,7 +317,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(400);
const errorData = await response.json();
expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/);
expectErrorResponse(errorData, -32_600, /Only one initialization request is allowed/);
});
it('should handle post requests via sse response correctly', async () => {
@ -338,7 +335,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
const dataLine = eventLines.find(line => line.startsWith('data:'));
expect(dataLine).toBeDefined();
const eventData = JSON.parse(dataLine!.substring(5));
const eventData = JSON.parse(dataLine!.slice(5));
expect(eventData).toMatchObject({
jsonrpc: '2.0',
result: expect.objectContaining({
@ -376,7 +373,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
const dataLine = eventLines.find(line => line.startsWith('data:'));
expect(dataLine).toBeDefined();
const eventData = JSON.parse(dataLine!.substring(5));
const eventData = JSON.parse(dataLine!.slice(5));
expect(eventData).toMatchObject({
jsonrpc: '2.0',
result: {
@ -436,7 +433,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
const dataLine = eventLines.find(line => line.startsWith('data:'));
expect(dataLine).toBeDefined();
const eventData = JSON.parse(dataLine!.substring(5));
const eventData = JSON.parse(dataLine!.slice(5));
expect(eventData).toMatchObject({
jsonrpc: '2.0',
@ -469,7 +466,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(400);
const errorData = (await response.json()) as JSONRPCErrorResponse;
expectErrorResponse(errorData, -32000, /Bad Request/);
expectErrorResponse(errorData, -32_000, /Bad Request/);
expect(errorData.id).toBeNull();
});
@ -482,7 +479,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(404);
const errorData = await response.json();
expectErrorResponse(errorData, -32001, /Session not found/);
expectErrorResponse(errorData, -32_001, /Session not found/);
});
it('should establish standalone SSE stream and receive server-initiated messages', async () => {
@ -519,7 +516,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
const dataLine = eventLines.find(line => line.startsWith('data:'));
expect(dataLine).toBeDefined();
const eventData = JSON.parse(dataLine!.substring(5));
const eventData = JSON.parse(dataLine!.slice(5));
expect(eventData).toMatchObject({
jsonrpc: '2.0',
method: 'notifications/message',
@ -587,7 +584,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
// Should be rejected
expect(secondStream.status).toBe(409); // Conflict
const errorData = await secondStream.json();
expectErrorResponse(errorData, -32000, /Only one SSE stream is allowed per session/);
expectErrorResponse(errorData, -32_000, /Only one SSE stream is allowed per session/);
});
it('should reject GET requests without Accept: text/event-stream header', async () => {
@ -605,7 +602,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(406);
const errorData = await response.json();
expectErrorResponse(errorData, -32000, /Client must accept text\/event-stream/);
expectErrorResponse(errorData, -32_000, /Client must accept text\/event-stream/);
});
it('should reject POST requests without proper Accept header', async () => {
@ -624,7 +621,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(406);
const errorData = await response.json();
expectErrorResponse(errorData, -32000, /Client must accept both application\/json and text\/event-stream/);
expectErrorResponse(errorData, -32_000, /Client must accept both application\/json and text\/event-stream/);
});
it('should reject unsupported Content-Type', async () => {
@ -643,7 +640,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(415);
const errorData = await response.json();
expectErrorResponse(errorData, -32000, /Content-Type must be application\/json/);
expectErrorResponse(errorData, -32_000, /Content-Type must be application\/json/);
});
it('should handle JSON-RPC batch notification messages with 202 response', async () => {
@ -701,7 +698,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(400);
const errorData = await response.json();
expectErrorResponse(errorData, -32700, /Parse error/);
expectErrorResponse(errorData, -32_700, /Parse error/);
});
it('should include error data in parse error response for unexpected errors', async () => {
@ -721,7 +718,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(400);
const errorData = (await response.json()) as JSONRPCErrorResponse;
expectErrorResponse(errorData, -32700, /Parse error/);
expectErrorResponse(errorData, -32_700, /Parse error/);
// The error message should contain details about what went wrong
expect(errorData.error.message).toContain('Invalid JSON');
});
@ -759,7 +756,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(400);
const errorData = await response.json();
expectErrorResponse(errorData, -32000, /Server not initialized/);
expectErrorResponse(errorData, -32_000, /Server not initialized/);
// Cleanup
await stopTestServer({ server: uninitializedServer, transport: uninitializedTransport });
@ -878,7 +875,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(404);
const errorData = await response.json();
expectErrorResponse(errorData, -32001, /Session not found/);
expectErrorResponse(errorData, -32_001, /Session not found/);
});
describe('protocol version header validation', () => {
@ -926,7 +923,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(400);
const errorData = await response.json();
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version: .+ \(supported versions: .+\)/);
expectErrorResponse(errorData, -32_000, /Bad Request: Unsupported protocol version: .+ \(supported versions: .+\)/);
});
it('should accept when protocol version differs from negotiated version', async () => {
@ -963,7 +960,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(400);
const errorData = await response.json();
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/);
expectErrorResponse(errorData, -32_000, /Bad Request: Unsupported protocol version/);
});
it('should reject unsupported protocol version on DELETE requests', async () => {
@ -980,7 +977,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(400);
const errorData = await response.json();
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/);
expectErrorResponse(errorData, -32_000, /Bad Request: Unsupported protocol version/);
});
});
});
@ -1032,7 +1029,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
const dataLine = eventLines.find(line => line.startsWith('data:'));
expect(dataLine).toBeDefined();
const eventData = JSON.parse(dataLine!.substring(5));
const eventData = JSON.parse(dataLine!.slice(5));
expect(eventData).toMatchObject({
jsonrpc: '2.0',
result: {
@ -1068,7 +1065,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
const dataLine = eventLines.find(line => line.startsWith('data:'));
expect(dataLine).toBeDefined();
const eventData = JSON.parse(dataLine!.substring(5));
const eventData = JSON.parse(dataLine!.slice(5));
expect(eventData).toMatchObject({
jsonrpc: '2.0',
result: {
@ -1183,11 +1180,11 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
const result = await createTestServer({
customRequestHandler: async (req, res) => {
try {
if (parsedBody !== null) {
if (parsedBody === null) {
await transport.handleRequest(req, res);
} else {
await transport.handleRequest(req, res, parsedBody);
parsedBody = null; // Reset after use
} else {
await transport.handleRequest(req, res);
}
} catch (error) {
console.error('Error handling request:', error);
@ -2334,7 +2331,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
// Verify we received the notification that was sent while disconnected
expect(allText).toContain('Missed while disconnected');
}, 15000);
}, 15_000);
});
// Test onsessionclosed callback
@ -2508,8 +2505,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
initializationOrder.push('async-start');
// Simulate async operation
await new Promise(resolve => setTimeout(resolve, 10));
initializationOrder.push('async-end');
initializationOrder.push(sessionId);
initializationOrder.push('async-end', sessionId);
}
});
@ -2563,8 +2559,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
closureOrder.push('async-close-start');
// Simulate async operation
await new Promise(resolve => setTimeout(resolve, 10));
closureOrder.push('async-close-end');
closureOrder.push(sessionId);
closureOrder.push('async-close-end', sessionId);
}
});

View file

@ -105,7 +105,7 @@ function parseSSEData(text: string): unknown {
if (!dataLine) {
throw new Error('No data line found in SSE event');
}
return JSON.parse(dataLine.substring(5).trim());
return JSON.parse(dataLine.slice(5).trim());
}
function expectErrorResponse(data: unknown, expectedCode: number, expectedMessagePattern: RegExp): void {
@ -183,7 +183,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(400);
const errorData = await response.json();
expectErrorResponse(errorData, -32600, /Server already initialized/);
expectErrorResponse(errorData, -32_600, /Server already initialized/);
});
it('should reject batch initialize request', async () => {
@ -205,7 +205,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(400);
const errorData = await response.json();
expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/);
expectErrorResponse(errorData, -32_600, /Only one initialization request is allowed/);
});
});
@ -278,7 +278,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(400);
const errorData = (await response.json()) as JSONRPCErrorResponse;
expectErrorResponse(errorData, -32000, /Bad Request/);
expectErrorResponse(errorData, -32_000, /Bad Request/);
expect(errorData.id).toBeNull();
});
@ -290,7 +290,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(404);
const errorData = await response.json();
expectErrorResponse(errorData, -32001, /Session not found/);
expectErrorResponse(errorData, -32_001, /Session not found/);
});
it('should reject request with wrong Accept header', async () => {
@ -299,7 +299,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(406);
const errorData = await response.json();
expectErrorResponse(errorData, -32000, /Not Acceptable/);
expectErrorResponse(errorData, -32_000, /Not Acceptable/);
});
it('should reject request with wrong Content-Type header', async () => {
@ -315,7 +315,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(415);
const errorData = await response.json();
expectErrorResponse(errorData, -32000, /Unsupported Media Type/);
expectErrorResponse(errorData, -32_000, /Unsupported Media Type/);
});
it('should reject invalid JSON', async () => {
@ -331,7 +331,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(400);
const errorData = await response.json();
expectErrorResponse(errorData, -32700, /Parse error.*Invalid JSON/);
expectErrorResponse(errorData, -32_700, /Parse error.*Invalid JSON/);
});
it('should accept notifications without session and return 202', async () => {
@ -369,7 +369,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(406);
const errorData = await response.json();
expectErrorResponse(errorData, -32000, /Not Acceptable/);
expectErrorResponse(errorData, -32_000, /Not Acceptable/);
});
it('should reject second standalone SSE stream', async () => {
@ -386,7 +386,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response2.status).toBe(409);
const errorData = await response2.json();
expectErrorResponse(errorData, -32000, /Conflict/);
expectErrorResponse(errorData, -32_000, /Conflict/);
});
});
@ -628,7 +628,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
// Replay events after lastEventId for the same stream
const streamId = lastEvent.streamId;
const entries = Array.from(storedEvents.entries());
const entries = [...storedEvents.entries()];
let foundLast = false;
for (const [eventId, event] of entries) {
@ -739,7 +739,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(response.status).toBe(400);
const errorData = await response.json();
expectErrorResponse(errorData, -32000, /Unsupported protocol version/);
expectErrorResponse(errorData, -32_000, /Unsupported protocol version/);
});
});

View file

@ -1,30 +1,33 @@
import { OptionDefaults } from "typedoc";
import { findWorkspacePackages } from "@pnpm/workspace.find-packages";
import { readWorkspaceManifest } from "@pnpm/workspace.read-manifest";
import { OptionDefaults } from 'typedoc';
import { findWorkspacePackages } from '@pnpm/workspace.find-packages';
import { readWorkspaceManifest } from '@pnpm/workspace.read-manifest';
// Read workspace manifest and find all public packages
const workspaceManifest = await readWorkspaceManifest(process.cwd());
const packages = await findWorkspacePackages(process.cwd(), {
patterns: workspaceManifest?.packages,
patterns: workspaceManifest?.packages
});
const publicPackages = packages.filter(p => p.manifest.private !== true);
const entryPoints = publicPackages.map(p => p.rootDir);
console.log('Typedoc selected public packages:', publicPackages.map(p => p.manifest.name));
console.log(
'Typedoc selected public packages:',
publicPackages.map(p => p.manifest.name)
);
export default {
name: "MCP TypeScript SDK",
entryPointStrategy: "packages",
name: 'MCP TypeScript SDK',
entryPointStrategy: 'packages',
entryPoints,
packageOptions: {
blockTags: [...OptionDefaults.blockTags, "@format"]
blockTags: [...OptionDefaults.blockTags, '@format']
},
projectDocuments: ["docs/documents.md"],
projectDocuments: ['docs/documents.md'],
navigation: {
compactFolders: true,
includeFolders: false,
includeFolders: false
},
headings: {
readme: false,
},
readme: false
}
};