eslint fixes in tests (#1418)
This commit is contained in:
parent
55a28ed645
commit
bf6f16c8be
20 changed files with 219 additions and 184 deletions
|
|
@ -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.
|
||||
|
|
|
|||
28
.github/pages/index.html
vendored
28
.github/pages/index.html
vendored
|
|
@ -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';
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
---
|
||||
title: Documents
|
||||
children:
|
||||
- ./server.md
|
||||
- ./client.md
|
||||
- ./capabilities.md
|
||||
- ./faq.md
|
||||
- ./server.md
|
||||
- ./client.md
|
||||
- ./capabilities.md
|
||||
- ./faq.md
|
||||
---
|
||||
|
||||
# Documents
|
||||
|
|
|
|||
332
packages/client/test/client/authExtensions.test.ts
Normal file
332
packages/client/test/client/authExtensions.test.ts
Normal 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/
|
||||
);
|
||||
});
|
||||
});
|
||||
155
packages/client/test/client/crossSpawn.test.ts
Normal file
155
packages/client/test/client/crossSpawn.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
937
packages/core/test/experimental/inMemory.test.ts
Normal file
937
packages/core/test/experimental/inMemory.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
90
packages/core/test/shared/authUtils.test.ts
Normal file
90
packages/core/test/shared/authUtils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
190
packages/core/test/shared/protocolTransportHandling.test.ts
Normal file
190
packages/core/test/shared/protocolTransportHandling.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
182
packages/middleware/express/test/express.test.ts
Normal file
182
packages/middleware/express/test/express.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
109
packages/middleware/hono/test/hono.test.ts
Normal file
109
packages/middleware/hono/test/hono.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue