Relocate tests under /test (#1220)

This commit is contained in:
Konstantin Konstantinov 2025-12-06 01:56:12 +02:00 • committed by GitHub
commit 5a9de1c638
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 630 additions and 524 deletions

View file

@ -1,373 +0,0 @@
import { describe, it, expect } from 'vitest';
import { auth } from './auth.js';
import {
ClientCredentialsProvider,
PrivateKeyJwtProvider,
StaticPrivateKeyJwtProvider,
createPrivateKeyJwtAuth
} from './auth-extensions.js';
import type { FetchLike } from '../shared/transport.js';
const RESOURCE_SERVER_URL = 'https://resource.example.com/';
const AUTH_SERVER_URL = 'https://auth.example.com';
function createMockFetch(onTokenRequest?: (url: URL, init: RequestInit | undefined) => void | Promise<void>): FetchLike {
return async (input: string | URL, init?: RequestInit): Promise<Response> => {
const url = input instanceof URL ? input : new URL(input);
// Protected resource metadata discovery
if (url.origin === RESOURCE_SERVER_URL.slice(0, -1) && url.pathname === '/.well-known/oauth-protected-resource') {
return new Response(
JSON.stringify({
resource: RESOURCE_SERVER_URL,
authorization_servers: [AUTH_SERVER_URL]
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
}
// Authorization server metadata discovery
if (url.origin === AUTH_SERVER_URL && url.pathname === '/.well-known/oauth-authorization-server') {
return new Response(
JSON.stringify({
issuer: AUTH_SERVER_URL,
authorization_endpoint: `${AUTH_SERVER_URL}/authorize`,
token_endpoint: `${AUTH_SERVER_URL}/token`,
response_types_supported: ['code'],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'private_key_jwt']
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
}
// Token endpoint
if (url.origin === AUTH_SERVER_URL && url.pathname === '/token') {
if (onTokenRequest) {
await onTokenRequest(url, init);
}
return new Response(
JSON.stringify({
access_token: 'test-access-token',
token_type: 'Bearer'
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
}
throw new Error(`Unexpected URL in mock fetch: ${url.toString()}`);
};
}
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 = createMockFetch(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 = createMockFetch(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 = createMockFetch();
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 = createMockFetch(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/
);
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,153 +0,0 @@
import { StdioClientTransport, getDefaultEnvironment } from './stdio.js';
import spawn from 'cross-spawn';
import { JSONRPCMessage } from '../types.js';
import { ChildProcess } from 'node:child_process';
import { Mock, MockedFunction } from 'vitest';
// 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();
});
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,285 +0,0 @@
import { Response } from 'express';
import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from './demoInMemoryOAuthProvider.js';
import { AuthorizationParams } from '../../server/auth/provider.js';
import { OAuthClientInformationFull } from '../../shared/auth.js';
import { InvalidRequestError } from '../../server/auth/errors.js';
describe('DemoInMemoryAuthProvider', () => {
let provider: DemoInMemoryAuthProvider;
let mockResponse: Response & { getRedirectUrl: () => string };
const createMockResponse = (): Response & { getRedirectUrl: () => string } => {
let capturedRedirectUrl: string | undefined;
const mockRedirect = vi.fn().mockImplementation((url: string | number, status?: number) => {
if (typeof url === 'string') {
capturedRedirectUrl = url;
} else if (typeof status === 'string') {
capturedRedirectUrl = status;
}
return mockResponse;
});
const mockResponse = {
redirect: mockRedirect,
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis(),
getRedirectUrl: () => {
if (capturedRedirectUrl === undefined) {
throw new Error('No redirect URL was captured. Ensure redirect() was called first.');
}
return capturedRedirectUrl;
}
} as unknown as Response & { getRedirectUrl: () => string };
return mockResponse;
};
beforeEach(() => {
provider = new DemoInMemoryAuthProvider();
mockResponse = createMockResponse();
});
describe('authorize', () => {
const validClient: OAuthClientInformationFull = {
client_id: 'test-client',
client_secret: 'test-secret',
redirect_uris: ['https://example.com/callback', 'https://example.com/callback2'],
scope: 'test-scope'
};
it('should redirect to the requested redirect_uri when valid', async () => {
const params: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
state: 'test-state',
codeChallenge: 'test-challenge',
scopes: ['test-scope']
};
await provider.authorize(validClient, params, mockResponse);
expect(mockResponse.redirect).toHaveBeenCalled();
expect(mockResponse.getRedirectUrl()).toBeDefined();
const url = new URL(mockResponse.getRedirectUrl());
expect(url.origin + url.pathname).toBe('https://example.com/callback');
expect(url.searchParams.get('state')).toBe('test-state');
expect(url.searchParams.has('code')).toBe(true);
});
it('should throw InvalidRequestError for unregistered redirect_uri', async () => {
const params: AuthorizationParams = {
redirectUri: 'https://evil.com/callback',
state: 'test-state',
codeChallenge: 'test-challenge',
scopes: ['test-scope']
};
await expect(provider.authorize(validClient, params, mockResponse)).rejects.toThrow(InvalidRequestError);
await expect(provider.authorize(validClient, params, mockResponse)).rejects.toThrow('Unregistered redirect_uri');
expect(mockResponse.redirect).not.toHaveBeenCalled();
});
it('should generate unique authorization codes for multiple requests', async () => {
const params1: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
state: 'state-1',
codeChallenge: 'challenge-1',
scopes: ['test-scope']
};
const params2: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
state: 'state-2',
codeChallenge: 'challenge-2',
scopes: ['test-scope']
};
await provider.authorize(validClient, params1, mockResponse);
const firstRedirectUrl = mockResponse.getRedirectUrl();
const firstCode = new URL(firstRedirectUrl).searchParams.get('code');
// Reset the mock for the second call
mockResponse = createMockResponse();
await provider.authorize(validClient, params2, mockResponse);
const secondRedirectUrl = mockResponse.getRedirectUrl();
const secondCode = new URL(secondRedirectUrl).searchParams.get('code');
expect(firstCode).toBeDefined();
expect(secondCode).toBeDefined();
expect(firstCode).not.toBe(secondCode);
});
it('should handle params without state', async () => {
const params: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge',
scopes: ['test-scope']
};
await provider.authorize(validClient, params, mockResponse);
expect(mockResponse.redirect).toHaveBeenCalled();
expect(mockResponse.getRedirectUrl()).toBeDefined();
const url = new URL(mockResponse.getRedirectUrl());
expect(url.searchParams.has('state')).toBe(false);
expect(url.searchParams.has('code')).toBe(true);
});
});
describe('challengeForAuthorizationCode', () => {
const validClient: OAuthClientInformationFull = {
client_id: 'test-client',
client_secret: 'test-secret',
redirect_uris: ['https://example.com/callback'],
scope: 'test-scope'
};
it('should return the code challenge for a valid authorization code', async () => {
const params: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
state: 'test-state',
codeChallenge: 'test-challenge-value',
scopes: ['test-scope']
};
await provider.authorize(validClient, params, mockResponse);
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
const challenge = await provider.challengeForAuthorizationCode(validClient, code);
expect(challenge).toBe('test-challenge-value');
});
it('should throw error for invalid authorization code', async () => {
await expect(provider.challengeForAuthorizationCode(validClient, 'invalid-code')).rejects.toThrow('Invalid authorization code');
});
});
describe('exchangeAuthorizationCode', () => {
const validClient: OAuthClientInformationFull = {
client_id: 'test-client',
client_secret: 'test-secret',
redirect_uris: ['https://example.com/callback'],
scope: 'test-scope'
};
it('should exchange valid authorization code for tokens', async () => {
const params: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
state: 'test-state',
codeChallenge: 'test-challenge',
scopes: ['test-scope', 'other-scope']
};
await provider.authorize(validClient, params, mockResponse);
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(validClient, code);
expect(tokens).toEqual({
access_token: expect.any(String),
token_type: 'bearer',
expires_in: 3600,
scope: 'test-scope other-scope'
});
});
it('should throw error for invalid authorization code', async () => {
await expect(provider.exchangeAuthorizationCode(validClient, 'invalid-code')).rejects.toThrow('Invalid authorization code');
});
it('should throw error when client_id does not match', async () => {
const params: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
state: 'test-state',
codeChallenge: 'test-challenge',
scopes: ['test-scope']
};
await provider.authorize(validClient, params, mockResponse);
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
const differentClient: OAuthClientInformationFull = {
client_id: 'different-client',
client_secret: 'different-secret',
redirect_uris: ['https://example.com/callback'],
scope: 'test-scope'
};
await expect(provider.exchangeAuthorizationCode(differentClient, code)).rejects.toThrow(
'Authorization code was not issued to this client'
);
});
it('should delete authorization code after successful exchange', async () => {
const params: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
state: 'test-state',
codeChallenge: 'test-challenge',
scopes: ['test-scope']
};
await provider.authorize(validClient, params, mockResponse);
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
// First exchange should succeed
await provider.exchangeAuthorizationCode(validClient, code);
// Second exchange should fail
await expect(provider.exchangeAuthorizationCode(validClient, code)).rejects.toThrow('Invalid authorization code');
});
it('should validate resource when validateResource is provided', async () => {
const validateResource = vi.fn().mockReturnValue(false);
const strictProvider = new DemoInMemoryAuthProvider(validateResource);
const params: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
state: 'test-state',
codeChallenge: 'test-challenge',
scopes: ['test-scope'],
resource: new URL('https://invalid-resource.com')
};
await strictProvider.authorize(validClient, params, mockResponse);
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
await expect(strictProvider.exchangeAuthorizationCode(validClient, code)).rejects.toThrow(
'Invalid resource: https://invalid-resource.com/'
);
expect(validateResource).toHaveBeenCalledWith(params.resource);
});
});
describe('DemoInMemoryClientsStore', () => {
let store: DemoInMemoryClientsStore;
beforeEach(() => {
store = new DemoInMemoryClientsStore();
});
it('should register and retrieve client', async () => {
const client: OAuthClientInformationFull = {
client_id: 'test-client',
client_secret: 'test-secret',
redirect_uris: ['https://example.com/callback'],
scope: 'test-scope'
};
await store.registerClient(client);
const retrieved = await store.getClient('test-client');
expect(retrieved).toEqual(client);
});
it('should return undefined for non-existent client', async () => {
const retrieved = await store.getClient('non-existent');
expect(retrieved).toBeUndefined();
});
});
});

View file

@ -1,936 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { InMemoryTaskStore, InMemoryTaskMessageQueue } from './in-memory.js';
import { TaskCreationParams, Request } from '../../../types.js';
import { QueuedMessage } from '../interfaces.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: 60000
};
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(60000);
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: 60000
};
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).toEqual(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).toEqual(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).toEqual(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(100000);
// 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(100000);
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).toEqual([]);
});
});
describe('listTasks', () => {
it('should return empty list when no tasks', async () => {
const result = await store.listTasks();
expect(result.tasks).toEqual([]);
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 = Array.from(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).toEqual(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).toEqual(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).toEqual(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')).toEqual(request);
expect(await queue.dequeue('task-fifo')).toEqual(notification);
expect(await queue.dequeue('task-fifo')).toEqual(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]).toEqual(request);
expect(all[1]).toEqual(response);
expect(all[2]).toEqual(notification);
});
it('should return empty array for non-existent task', async () => {
const all = await queue.dequeueAll('non-existent');
expect(all).toEqual([]);
});
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')).toEqual(message1);
expect(await queue.dequeue('task-b')).toEqual(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: -32600,
message: 'Invalid Request'
}
},
timestamp: Date.now()
};
await queue.enqueue('task-error', errorResponse);
const dequeued = await queue.dequeue('task-error');
expect(dequeued).toEqual(errorResponse);
expect(dequeued?.type).toBe('error');
});
});
});

View file

@ -1,174 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { InMemoryTransport } from '../../inMemory.js';
import { Client } from '../../client/index.js';
import { Server } from '../../server/index.js';
import { InMemoryTaskStore, InMemoryTaskMessageQueue } from './stores/in-memory.js';
import { ErrorCode, McpError } from '../../types.js';
describe('Task Listing with Pagination', () => {
let client: Client;
let server: Server;
let taskStore: InMemoryTaskStore;
let clientTransport: InMemoryTransport;
let serverTransport: InMemoryTransport;
beforeEach(async () => {
taskStore = new InMemoryTaskStore();
[clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
client = new Client(
{
name: 'test-client',
version: '1.0.0'
},
{
capabilities: {
tasks: {
list: {},
requests: {
tools: {
call: {}
}
}
}
}
}
);
server = new Server(
{
name: 'test-server',
version: '1.0.0'
},
{
capabilities: {
tasks: {
list: {},
requests: {
tools: {
call: {}
}
}
}
},
taskStore,
taskMessageQueue: new InMemoryTaskMessageQueue()
}
);
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
});
afterEach(async () => {
taskStore.cleanup();
await client.close();
await server.close();
});
it('should return empty list when no tasks exist', async () => {
const result = await client.experimental.tasks.listTasks();
expect(result.tasks).toEqual([]);
expect(result.nextCursor).toBeUndefined();
});
it('should return all tasks when less than page size', async () => {
// Create 3 tasks
for (let i = 0; i < 3; i++) {
await taskStore.createTask({}, i, {
method: 'tools/call',
params: { name: 'test-tool' }
});
}
const result = await client.experimental.tasks.listTasks();
expect(result.tasks).toHaveLength(3);
expect(result.nextCursor).toBeUndefined();
});
it('should paginate when more than page size exists', async () => {
// Create 15 tasks (page size is 10 in InMemoryTaskStore)
for (let i = 0; i < 15; i++) {
await taskStore.createTask({}, i, {
method: 'tools/call',
params: { name: 'test-tool' }
});
}
// Get first page
const page1 = await client.experimental.tasks.listTasks();
expect(page1.tasks).toHaveLength(10);
expect(page1.nextCursor).toBeDefined();
// Get second page using cursor
const page2 = await client.experimental.tasks.listTasks(page1.nextCursor);
expect(page2.tasks).toHaveLength(5);
expect(page2.nextCursor).toBeUndefined();
});
it('should treat cursor as opaque token', async () => {
// Create 5 tasks
for (let i = 0; i < 5; i++) {
await taskStore.createTask({}, i, {
method: 'tools/call',
params: { name: 'test-tool' }
});
}
// Get all tasks to get a valid cursor
const allTasks = taskStore.getAllTasks();
const validCursor = allTasks[2].taskId;
// Use the cursor - should work even though we don't know its internal structure
const result = await client.experimental.tasks.listTasks(validCursor);
expect(result.tasks).toHaveLength(2);
});
it('should return error code -32602 for invalid cursor', async () => {
await taskStore.createTask({}, 1, {
method: 'tools/call',
params: { name: 'test-tool' }
});
// Try to use an invalid cursor - should return -32602 (Invalid params) per MCP spec
await expect(client.experimental.tasks.listTasks('invalid-cursor')).rejects.toSatisfy((error: McpError) => {
expect(error).toBeInstanceOf(McpError);
expect(error.code).toBe(ErrorCode.InvalidParams);
expect(error.message).toContain('Invalid cursor');
return true;
});
});
it('should ensure tasks accessible via tasks/get are also accessible via tasks/list', async () => {
// Create a task
const task = await taskStore.createTask({}, 1, {
method: 'tools/call',
params: { name: 'test-tool' }
});
// Verify it's accessible via tasks/get
const getResult = await client.experimental.tasks.getTask(task.taskId);
expect(getResult.taskId).toBe(task.taskId);
// Verify it's also accessible via tasks/list
const listResult = await client.experimental.tasks.listTasks();
expect(listResult.tasks).toHaveLength(1);
expect(listResult.tasks[0].taskId).toBe(task.taskId);
});
it('should not include related-task metadata in list response', async () => {
// Create a task
await taskStore.createTask({}, 1, {
method: 'tools/call',
params: { name: 'test-tool' }
});
const result = await client.experimental.tasks.listTasks();
// The response should have _meta but not include related-task metadata
expect(result._meta).toBeDefined();
expect(result._meta?.['io.modelcontextprotocol/related-task']).toBeUndefined();
});
});

View file

@ -1,117 +0,0 @@
import { describe, it, expect } from 'vitest';
import { isTerminal } from './interfaces.js';
import type { Task } from '../../types.js';
describe('Task utility functions', () => {
describe('isTerminal', () => {
it('should return true for completed status', () => {
expect(isTerminal('completed')).toBe(true);
});
it('should return true for failed status', () => {
expect(isTerminal('failed')).toBe(true);
});
it('should return true for cancelled status', () => {
expect(isTerminal('cancelled')).toBe(true);
});
it('should return false for working status', () => {
expect(isTerminal('working')).toBe(false);
});
it('should return false for input_required status', () => {
expect(isTerminal('input_required')).toBe(false);
});
});
});
describe('Task Schema Validation', () => {
it('should validate task with ttl field', () => {
const createdAt = new Date().toISOString();
const task: Task = {
taskId: 'test-123',
status: 'working',
ttl: 60000,
createdAt,
lastUpdatedAt: createdAt,
pollInterval: 1000
};
expect(task.ttl).toBe(60000);
expect(task.createdAt).toBeDefined();
expect(typeof task.createdAt).toBe('string');
});
it('should validate task with null ttl', () => {
const createdAt = new Date().toISOString();
const task: Task = {
taskId: 'test-456',
status: 'completed',
ttl: null,
createdAt,
lastUpdatedAt: createdAt
};
expect(task.ttl).toBeNull();
});
it('should validate task with statusMessage field', () => {
const createdAt = new Date().toISOString();
const task: Task = {
taskId: 'test-789',
status: 'failed',
ttl: null,
createdAt,
lastUpdatedAt: createdAt,
statusMessage: 'Operation failed due to timeout'
};
expect(task.statusMessage).toBe('Operation failed due to timeout');
});
it('should validate task with createdAt in ISO 8601 format', () => {
const now = new Date();
const createdAt = now.toISOString();
const task: Task = {
taskId: 'test-iso',
status: 'working',
ttl: 30000,
createdAt,
lastUpdatedAt: createdAt
};
expect(task.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(new Date(task.createdAt).getTime()).toBe(now.getTime());
});
it('should validate task with lastUpdatedAt in ISO 8601 format', () => {
const now = new Date();
const createdAt = now.toISOString();
const task: Task = {
taskId: 'test-iso',
status: 'working',
ttl: 30000,
createdAt,
lastUpdatedAt: createdAt
};
expect(task.lastUpdatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});
it('should validate all task statuses', () => {
const statuses: Task['status'][] = ['working', 'input_required', 'completed', 'failed', 'cancelled'];
const createdAt = new Date().toISOString();
statuses.forEach(status => {
const task: Task = {
taskId: `test-${status}`,
status,
ttl: null,
createdAt,
lastUpdatedAt: createdAt
};
expect(task.status).toBe(status);
});
});
});

View file

@ -1,119 +0,0 @@
import { InMemoryTransport } from './inMemory.js';
import { JSONRPCMessage } from './types.js';
import { AuthInfo } from './server/auth/types.js';
describe('InMemoryTransport', () => {
let clientTransport: InMemoryTransport;
let serverTransport: InMemoryTransport;
beforeEach(() => {
[clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
});
test('should create linked pair', () => {
expect(clientTransport).toBeDefined();
expect(serverTransport).toBeDefined();
});
test('should start without error', async () => {
await expect(clientTransport.start()).resolves.not.toThrow();
await expect(serverTransport.start()).resolves.not.toThrow();
});
test('should send message from client to server', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
id: 1
};
let receivedMessage: JSONRPCMessage | undefined;
serverTransport.onmessage = msg => {
receivedMessage = msg;
};
await clientTransport.send(message);
expect(receivedMessage).toEqual(message);
});
test('should send message with auth info from client to server', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
id: 1
};
const authInfo: AuthInfo = {
token: 'test-token',
clientId: 'test-client',
scopes: ['read', 'write'],
expiresAt: Date.now() / 1000 + 3600
};
let receivedMessage: JSONRPCMessage | undefined;
let receivedAuthInfo: AuthInfo | undefined;
serverTransport.onmessage = (msg, extra) => {
receivedMessage = msg;
receivedAuthInfo = extra?.authInfo;
};
await clientTransport.send(message, { authInfo });
expect(receivedMessage).toEqual(message);
expect(receivedAuthInfo).toEqual(authInfo);
});
test('should send message from server to client', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
id: 1
};
let receivedMessage: JSONRPCMessage | undefined;
clientTransport.onmessage = msg => {
receivedMessage = msg;
};
await serverTransport.send(message);
expect(receivedMessage).toEqual(message);
});
test('should handle close', async () => {
let clientClosed = false;
let serverClosed = false;
clientTransport.onclose = () => {
clientClosed = true;
};
serverTransport.onclose = () => {
serverClosed = true;
};
await clientTransport.close();
expect(clientClosed).toBe(true);
expect(serverClosed).toBe(true);
});
test('should throw error when sending after close', async () => {
await clientTransport.close();
await expect(clientTransport.send({ jsonrpc: '2.0', method: 'test', id: 1 })).rejects.toThrow('Not connected');
});
test('should queue messages sent before start', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
id: 1
};
let receivedMessage: JSONRPCMessage | undefined;
serverTransport.onmessage = msg => {
receivedMessage = msg;
};
await clientTransport.send(message);
await serverTransport.start();
expect(receivedMessage).toEqual(message);
});
});

View file

@ -1,113 +0,0 @@
import path from 'node:path';
import { Readable, Writable } from 'node:stream';
import { Client } from '../client/index.js';
import { StdioClientTransport } from '../client/stdio.js';
import { Server } from '../server/index.js';
import { StdioServerTransport } from '../server/stdio.js';
import { LoggingMessageNotificationSchema } from '../types.js';
const FIXTURES_DIR = path.resolve(__dirname, '../__fixtures__');
describe('Process cleanup', () => {
vi.setConfig({ testTimeout: 5000 }); // 5 second timeout
it('server should exit cleanly after closing transport', async () => {
const server = new Server(
{
name: 'test-server',
version: '1.0.0'
},
{
capabilities: {}
}
);
const mockReadable = new Readable({
read() {
this.push(null); // signal EOF
}
}),
mockWritable = new Writable({
write(chunk, encoding, callback) {
callback();
}
});
// Attach mock streams to process for the server transport
const transport = new StdioServerTransport(mockReadable, mockWritable);
await server.connect(transport);
// Close the transport
await transport.close();
// ensure a proper disposal mock streams
mockReadable.destroy();
mockWritable.destroy();
// If we reach here without hanging, the test passes
// The test runner will fail if the process hangs
expect(true).toBe(true);
});
it('onclose should be called exactly once', async () => {
const client = new Client({
name: 'test-client',
version: '1.0.0'
});
const transport = new StdioClientTransport({
command: 'node',
args: ['--import', 'tsx', 'testServer.ts'],
cwd: FIXTURES_DIR
});
await client.connect(transport);
let onCloseWasCalled = 0;
client.onclose = () => {
onCloseWasCalled++;
};
await client.close();
// A short delay to allow the close event to propagate
await new Promise(resolve => setTimeout(resolve, 50));
expect(onCloseWasCalled).toBe(1);
});
it('should exit cleanly for a server that hangs', async () => {
const client = new Client({
name: 'test-client',
version: '1.0.0'
});
const transport = new StdioClientTransport({
command: 'node',
args: ['--import', 'tsx', 'serverThatHangs.ts'],
cwd: FIXTURES_DIR
});
await client.connect(transport);
await client.setLoggingLevel('debug');
client.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
console.debug('server log: ' + notification.params.data);
});
const serverPid = transport.pid!;
await client.close();
// A short delay to allow the close event to propagate
await new Promise(resolve => setTimeout(resolve, 50));
try {
process.kill(serverPid, 9);
throw new Error('Expected server to be dead but it is alive');
} catch (err: unknown) {
// 'ESRCH' the process doesn't exist
if (err && typeof err === 'object' && 'code' in err && err.code === 'ESRCH') {
// success
} else throw err;
}
});
});

View file

@ -1,360 +0,0 @@
import { createServer, type Server } from 'node:http';
import { AddressInfo } from 'node:net';
import { randomUUID } from 'node:crypto';
import { Client } from '../client/index.js';
import { StreamableHTTPClientTransport } from '../client/streamableHttp.js';
import { McpServer } from '../server/mcp.js';
import { StreamableHTTPServerTransport } from '../server/streamableHttp.js';
import {
CallToolResultSchema,
ListToolsResultSchema,
ListResourcesResultSchema,
ListPromptsResultSchema,
LATEST_PROTOCOL_VERSION
} from '../types.js';
import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js';
describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
const { z } = entry;
describe('Streamable HTTP Transport Session Management', () => {
// Function to set up the server with optional session management
async function setupServer(withSessionManagement: boolean) {
const server: Server = createServer();
const mcpServer = new McpServer(
{ name: 'test-server', version: '1.0.0' },
{
capabilities: {
logging: {},
tools: {},
resources: {},
prompts: {}
}
}
);
// Add a simple resource
mcpServer.resource('test-resource', '/test', { description: 'A test resource' }, async () => ({
contents: [
{
uri: '/test',
text: 'This is a test resource content'
}
]
}));
mcpServer.prompt('test-prompt', 'A test prompt', async () => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'This is a test prompt'
}
}
]
}));
mcpServer.tool(
'greet',
'A simple greeting tool',
{
name: z.string().describe('Name to greet').default('World')
},
async ({ name }) => {
return {
content: [{ type: 'text', text: `Hello, ${name}!` }]
};
}
);
// Create transport with or without session management
const serverTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: withSessionManagement
? () => randomUUID() // With session management, generate UUID
: undefined // Without session management, return undefined
});
await mcpServer.connect(serverTransport);
server.on('request', async (req, res) => {
await serverTransport.handleRequest(req, res);
});
// Start the server on a random port
const baseUrl = await new Promise<URL>(resolve => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as AddressInfo;
resolve(new URL(`http://127.0.0.1:${addr.port}`));
});
});
return { server, mcpServer, serverTransport, baseUrl };
}
describe('Stateless Mode', () => {
let server: Server;
let mcpServer: McpServer;
let serverTransport: StreamableHTTPServerTransport;
let baseUrl: URL;
beforeEach(async () => {
const setup = await setupServer(false);
server = setup.server;
mcpServer = setup.mcpServer;
serverTransport = setup.serverTransport;
baseUrl = setup.baseUrl;
});
afterEach(async () => {
// Clean up resources
await mcpServer.close().catch(() => {});
await serverTransport.close().catch(() => {});
server.close();
});
it('should support multiple client connections', async () => {
// Create and connect a client
const client1 = new Client({
name: 'test-client',
version: '1.0.0'
});
const transport1 = new StreamableHTTPClientTransport(baseUrl);
await client1.connect(transport1);
// Verify that no session ID was set
expect(transport1.sessionId).toBeUndefined();
// List available tools
await client1.request(
{
method: 'tools/list',
params: {}
},
ListToolsResultSchema
);
const client2 = new Client({
name: 'test-client',
version: '1.0.0'
});
const transport2 = new StreamableHTTPClientTransport(baseUrl);
await client2.connect(transport2);
// Verify that no session ID was set
expect(transport2.sessionId).toBeUndefined();
// List available tools
await client2.request(
{
method: 'tools/list',
params: {}
},
ListToolsResultSchema
);
});
it('should operate without session management', async () => {
// Create and connect a client
const client = new Client({
name: 'test-client',
version: '1.0.0'
});
const transport = new StreamableHTTPClientTransport(baseUrl);
await client.connect(transport);
// Verify that no session ID was set
expect(transport.sessionId).toBeUndefined();
// List available tools
const toolsResult = await client.request(
{
method: 'tools/list',
params: {}
},
ListToolsResultSchema
);
// Verify tools are accessible
expect(toolsResult.tools).toContainEqual(
expect.objectContaining({
name: 'greet'
})
);
// List available resources
const resourcesResult = await client.request(
{
method: 'resources/list',
params: {}
},
ListResourcesResultSchema
);
// Verify resources result structure
expect(resourcesResult).toHaveProperty('resources');
// List available prompts
const promptsResult = await client.request(
{
method: 'prompts/list',
params: {}
},
ListPromptsResultSchema
);
// Verify prompts result structure
expect(promptsResult).toHaveProperty('prompts');
expect(promptsResult.prompts).toContainEqual(
expect.objectContaining({
name: 'test-prompt'
})
);
// Call the greeting tool
const greetingResult = await client.request(
{
method: 'tools/call',
params: {
name: 'greet',
arguments: {
name: 'Stateless Transport'
}
}
},
CallToolResultSchema
);
// Verify tool result
expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateless Transport!' }]);
// Clean up
await transport.close();
});
it('should set protocol version after connecting', async () => {
// Create and connect a client
const client = new Client({
name: 'test-client',
version: '1.0.0'
});
const transport = new StreamableHTTPClientTransport(baseUrl);
// Verify protocol version is not set before connecting
expect(transport.protocolVersion).toBeUndefined();
await client.connect(transport);
// Verify protocol version is set after connecting
expect(transport.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
// Clean up
await transport.close();
});
});
describe('Stateful Mode', () => {
let server: Server;
let mcpServer: McpServer;
let serverTransport: StreamableHTTPServerTransport;
let baseUrl: URL;
beforeEach(async () => {
const setup = await setupServer(true);
server = setup.server;
mcpServer = setup.mcpServer;
serverTransport = setup.serverTransport;
baseUrl = setup.baseUrl;
});
afterEach(async () => {
// Clean up resources
await mcpServer.close().catch(() => {});
await serverTransport.close().catch(() => {});
server.close();
});
it('should operate with session management', async () => {
// Create and connect a client
const client = new Client({
name: 'test-client',
version: '1.0.0'
});
const transport = new StreamableHTTPClientTransport(baseUrl);
await client.connect(transport);
// Verify that a session ID was set
expect(transport.sessionId).toBeDefined();
expect(typeof transport.sessionId).toBe('string');
// List available tools
const toolsResult = await client.request(
{
method: 'tools/list',
params: {}
},
ListToolsResultSchema
);
// Verify tools are accessible
expect(toolsResult.tools).toContainEqual(
expect.objectContaining({
name: 'greet'
})
);
// List available resources
const resourcesResult = await client.request(
{
method: 'resources/list',
params: {}
},
ListResourcesResultSchema
);
// Verify resources result structure
expect(resourcesResult).toHaveProperty('resources');
// List available prompts
const promptsResult = await client.request(
{
method: 'prompts/list',
params: {}
},
ListPromptsResultSchema
);
// Verify prompts result structure
expect(promptsResult).toHaveProperty('prompts');
expect(promptsResult.prompts).toContainEqual(
expect.objectContaining({
name: 'test-prompt'
})
);
// Call the greeting tool
const greetingResult = await client.request(
{
method: 'tools/call',
params: {
name: 'greet',
arguments: {
name: 'Stateful Transport'
}
}
},
CallToolResultSchema
);
// Verify tool result
expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateful Transport!' }]);
// Clean up
await transport.close();
});
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,269 +0,0 @@
import { createServer, type Server } from 'node:http';
import { AddressInfo } from 'node:net';
import { randomUUID } from 'node:crypto';
import { Client } from '../client/index.js';
import { StreamableHTTPClientTransport } from '../client/streamableHttp.js';
import { McpServer } from '../server/mcp.js';
import { StreamableHTTPServerTransport } from '../server/streamableHttp.js';
import { CallToolResultSchema, LoggingMessageNotificationSchema } from '../types.js';
import { InMemoryEventStore } from '../examples/shared/inMemoryEventStore.js';
import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js';
describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
const { z } = entry;
describe('Transport resumability', () => {
let server: Server;
let mcpServer: McpServer;
let serverTransport: StreamableHTTPServerTransport;
let baseUrl: URL;
let eventStore: InMemoryEventStore;
beforeEach(async () => {
// Create event store for resumability
eventStore = new InMemoryEventStore();
// Create a simple MCP server
mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } });
// Add a simple notification tool that completes quickly
mcpServer.tool(
'send-notification',
'Sends a single notification',
{
message: z.string().describe('Message to send').default('Test notification')
},
async ({ message }, { sendNotification }) => {
// Send notification immediately
await sendNotification({
method: 'notifications/message',
params: {
level: 'info',
data: message
}
});
return {
content: [{ type: 'text', text: 'Notification sent' }]
};
}
);
// Add a long-running tool that sends multiple notifications
mcpServer.tool(
'run-notifications',
'Sends multiple notifications over time',
{
count: z.number().describe('Number of notifications to send').default(10),
interval: z.number().describe('Interval between notifications in ms').default(50)
},
async ({ count, interval }, { sendNotification }) => {
// Send notifications at specified intervals
for (let i = 0; i < count; i++) {
await sendNotification({
method: 'notifications/message',
params: {
level: 'info',
data: `Notification ${i + 1} of ${count}`
}
});
// Wait for the specified interval before sending next notification
if (i < count - 1) {
await new Promise(resolve => setTimeout(resolve, interval));
}
}
return {
content: [{ type: 'text', text: `Sent ${count} notifications` }]
};
}
);
// Create a transport with the event store
serverTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore
});
// Connect the transport to the MCP server
await mcpServer.connect(serverTransport);
// Create and start an HTTP server
server = createServer(async (req, res) => {
await serverTransport.handleRequest(req, res);
});
// Start the server on a random port
baseUrl = await new Promise<URL>(resolve => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as AddressInfo;
resolve(new URL(`http://127.0.0.1:${addr.port}`));
});
});
});
afterEach(async () => {
// Clean up resources
await mcpServer.close().catch(() => {});
await serverTransport.close().catch(() => {});
server.close();
});
it('should store session ID when client connects', async () => {
// Create and connect a client
const client = new Client({
name: 'test-client',
version: '1.0.0'
});
const transport = new StreamableHTTPClientTransport(baseUrl);
await client.connect(transport);
// Verify session ID was generated
expect(transport.sessionId).toBeDefined();
// Clean up
await transport.close();
});
it('should have session ID functionality', async () => {
// The ability to store a session ID when connecting
const client = new Client({
name: 'test-client-reconnection',
version: '1.0.0'
});
const transport = new StreamableHTTPClientTransport(baseUrl);
// Make sure the client can connect and get a session ID
await client.connect(transport);
expect(transport.sessionId).toBeDefined();
// Clean up
await transport.close();
});
// This test demonstrates the capability to resume long-running tools
// across client disconnection/reconnection
it('should resume long-running notifications with lastEventId', async () => {
// Create unique client ID for this test
const clientTitle = 'test-client-long-running';
const notifications = [];
let lastEventId: string | undefined;
// Create first client
const client1 = new Client({
title: clientTitle,
name: 'test-client',
version: '1.0.0'
});
// Set up notification handler for first client
client1.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
if (notification.method === 'notifications/message') {
notifications.push(notification.params);
}
});
// Connect first client
const transport1 = new StreamableHTTPClientTransport(baseUrl);
await client1.connect(transport1);
const sessionId = transport1.sessionId;
expect(sessionId).toBeDefined();
// Start a long-running notification stream with tracking of lastEventId
const onLastEventIdUpdate = vi.fn((eventId: string) => {
lastEventId = eventId;
});
expect(lastEventId).toBeUndefined();
// Start the notification tool with event tracking using request
const toolPromise = client1.request(
{
method: 'tools/call',
params: {
name: 'run-notifications',
arguments: {
count: 3,
interval: 10
}
}
},
CallToolResultSchema,
{
resumptionToken: lastEventId,
onresumptiontoken: onLastEventIdUpdate
}
);
// Fix for node 18 test failures, allow some time for notifications to arrive
const maxWaitTime = 2000; // 2 seconds max wait
const pollInterval = 10; // Check every 10ms
const startTime = Date.now();
while (notifications.length === 0 && Date.now() - startTime < maxWaitTime) {
// Wait for some notifications to arrive (not all) - shorter wait time
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
// Verify we received some notifications and lastEventId was updated
expect(notifications.length).toBeGreaterThan(0);
expect(notifications.length).toBeLessThan(4);
expect(onLastEventIdUpdate).toHaveBeenCalled();
expect(lastEventId).toBeDefined();
// Disconnect first client without waiting for completion
// When we close the connection, it will cause a ConnectionClosed error for
// any in-progress requests, which is expected behavior
await transport1.close();
// Save the promise so we can catch it after closing
const catchPromise = toolPromise.catch(err => {
// This error is expected - the connection was intentionally closed
if (err?.code !== -32000) {
// ConnectionClosed error code
console.error('Unexpected error type during transport close:', err);
}
});
// Add a short delay to ensure clean disconnect before reconnecting
await new Promise(resolve => setTimeout(resolve, 10));
// Wait for the rejection to be handled
await catchPromise;
// Create second client with same client ID
const client2 = new Client({
title: clientTitle,
name: 'test-client',
version: '1.0.0'
});
// Track replayed notifications separately
const replayedNotifications: unknown[] = [];
client2.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
if (notification.method === 'notifications/message') {
replayedNotifications.push(notification.params);
}
});
// Connect second client with same session ID
const transport2 = new StreamableHTTPClientTransport(baseUrl, {
sessionId
});
await client2.connect(transport2);
// Resume GET SSE stream with Last-Event-ID to replay missed events
// Per spec, resumption uses GET with Last-Event-ID header
await transport2.resumeStream(lastEventId!, { onresumptiontoken: onLastEventIdUpdate });
// Wait for replayed events to arrive via SSE
await new Promise(resolve => setTimeout(resolve, 100));
// Verify the test infrastructure worked - we received notifications in first session
// and captured the lastEventId for potential replay
expect(notifications.length).toBeGreaterThan(0);
expect(lastEventId).toBeDefined();
// Clean up
await transport2.close();
});
});
});

View file

@ -1,294 +0,0 @@
import { authorizationHandler, AuthorizationHandlerOptions } from './authorize.js';
import { OAuthServerProvider, AuthorizationParams } from '../provider.js';
import { OAuthRegisteredClientsStore } from '../clients.js';
import { OAuthClientInformationFull, OAuthTokens } from '../../../shared/auth.js';
import express, { Response } from 'express';
import supertest from 'supertest';
import { AuthInfo } from '../types.js';
import { InvalidTokenError } from '../errors.js';
describe('Authorization Handler', () => {
// Mock client data
const validClient: OAuthClientInformationFull = {
client_id: 'valid-client',
client_secret: 'valid-secret',
redirect_uris: ['https://example.com/callback'],
scope: 'profile email'
};
const multiRedirectClient: OAuthClientInformationFull = {
client_id: 'multi-redirect-client',
client_secret: 'valid-secret',
redirect_uris: ['https://example.com/callback1', 'https://example.com/callback2'],
scope: 'profile email'
};
// Mock client store
const mockClientStore: OAuthRegisteredClientsStore = {
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
if (clientId === 'valid-client') {
return validClient;
} else if (clientId === 'multi-redirect-client') {
return multiRedirectClient;
}
return undefined;
}
};
// Mock provider
const mockProvider: OAuthServerProvider = {
clientsStore: mockClientStore,
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
// Mock implementation - redirects to redirectUri with code and state
const redirectUrl = new URL(params.redirectUri);
redirectUrl.searchParams.set('code', 'mock_auth_code');
if (params.state) {
redirectUrl.searchParams.set('state', params.state);
}
res.redirect(302, redirectUrl.toString());
},
async challengeForAuthorizationCode(): Promise<string> {
return 'mock_challenge';
},
async exchangeAuthorizationCode(): Promise<OAuthTokens> {
return {
access_token: 'mock_access_token',
token_type: 'bearer',
expires_in: 3600,
refresh_token: 'mock_refresh_token'
};
},
async exchangeRefreshToken(): Promise<OAuthTokens> {
return {
access_token: 'new_mock_access_token',
token_type: 'bearer',
expires_in: 3600,
refresh_token: 'new_mock_refresh_token'
};
},
async verifyAccessToken(token: string): Promise<AuthInfo> {
if (token === 'valid_token') {
return {
token,
clientId: 'valid-client',
scopes: ['read', 'write'],
expiresAt: Date.now() / 1000 + 3600
};
}
throw new InvalidTokenError('Token is invalid or expired');
},
async revokeToken(): Promise<void> {
// Do nothing in mock
}
};
// Setup express app with handler
let app: express.Express;
let options: AuthorizationHandlerOptions;
beforeEach(() => {
app = express();
options = { provider: mockProvider };
const handler = authorizationHandler(options);
app.use('/authorize', handler);
});
describe('HTTP method validation', () => {
it('rejects non-GET/POST methods', async () => {
const response = await supertest(app).put('/authorize').query({ client_id: 'valid-client' });
expect(response.status).toBe(405); // Method not allowed response from handler
});
});
describe('Client validation', () => {
it('requires client_id parameter', async () => {
const response = await supertest(app).get('/authorize');
expect(response.status).toBe(400);
expect(response.text).toContain('client_id');
});
it('validates that client exists', async () => {
const response = await supertest(app).get('/authorize').query({ client_id: 'nonexistent-client' });
expect(response.status).toBe(400);
});
});
describe('Redirect URI validation', () => {
it('uses the only redirect_uri if client has just one and none provided', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'valid-client',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256'
});
expect(response.status).toBe(302);
const location = new URL(response.header.location);
expect(location.origin + location.pathname).toBe('https://example.com/callback');
});
it('requires redirect_uri if client has multiple', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'multi-redirect-client',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256'
});
expect(response.status).toBe(400);
});
it('validates redirect_uri against client registered URIs', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://malicious.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256'
});
expect(response.status).toBe(400);
});
it('accepts valid redirect_uri that client registered with', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256'
});
expect(response.status).toBe(302);
const location = new URL(response.header.location);
expect(location.origin + location.pathname).toBe('https://example.com/callback');
});
});
describe('Authorization request validation', () => {
it('requires response_type=code', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'token', // invalid - we only support code flow
code_challenge: 'challenge123',
code_challenge_method: 'S256'
});
expect(response.status).toBe(302);
const location = new URL(response.header.location);
expect(location.searchParams.get('error')).toBe('invalid_request');
});
it('requires code_challenge parameter', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge_method: 'S256'
// Missing code_challenge
});
expect(response.status).toBe(302);
const location = new URL(response.header.location);
expect(location.searchParams.get('error')).toBe('invalid_request');
});
it('requires code_challenge_method=S256', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'plain' // Only S256 is supported
});
expect(response.status).toBe(302);
const location = new URL(response.header.location);
expect(location.searchParams.get('error')).toBe('invalid_request');
});
});
describe('Resource parameter validation', () => {
it('propagates resource parameter', async () => {
const mockProviderWithResource = vi.spyOn(mockProvider, 'authorize');
const response = await supertest(app).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256',
resource: 'https://api.example.com/resource'
});
expect(response.status).toBe(302);
expect(mockProviderWithResource).toHaveBeenCalledWith(
validClient,
expect.objectContaining({
resource: new URL('https://api.example.com/resource'),
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge123'
}),
expect.any(Object)
);
});
});
describe('Successful authorization', () => {
it('handles successful authorization with all parameters', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256',
scope: 'profile email',
state: 'xyz789'
});
expect(response.status).toBe(302);
const location = new URL(response.header.location);
expect(location.origin + location.pathname).toBe('https://example.com/callback');
expect(location.searchParams.get('code')).toBe('mock_auth_code');
expect(location.searchParams.get('state')).toBe('xyz789');
});
it('preserves state parameter in response', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256',
state: 'state-value-123'
});
expect(response.status).toBe(302);
const location = new URL(response.header.location);
expect(location.searchParams.get('state')).toBe('state-value-123');
});
it('handles POST requests the same as GET', async () => {
const response = await supertest(app).post('/authorize').type('form').send({
client_id: 'valid-client',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256'
});
expect(response.status).toBe(302);
const location = new URL(response.header.location);
expect(location.searchParams.has('code')).toBe(true);
});
});
});

View file

@ -1,78 +0,0 @@
import { metadataHandler } from './metadata.js';
import { OAuthMetadata } from '../../../shared/auth.js';
import express from 'express';
import supertest from 'supertest';
describe('Metadata Handler', () => {
const exampleMetadata: OAuthMetadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
registration_endpoint: 'https://auth.example.com/register',
revocation_endpoint: 'https://auth.example.com/revoke',
scopes_supported: ['profile', 'email'],
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['client_secret_basic'],
code_challenge_methods_supported: ['S256']
};
let app: express.Express;
beforeEach(() => {
// Setup express app with metadata handler
app = express();
app.use('/.well-known/oauth-authorization-server', metadataHandler(exampleMetadata));
});
it('requires GET method', async () => {
const response = await supertest(app).post('/.well-known/oauth-authorization-server').send({});
expect(response.status).toBe(405);
expect(response.headers.allow).toBe('GET, OPTIONS');
expect(response.body).toEqual({
error: 'method_not_allowed',
error_description: 'The method POST is not allowed for this endpoint'
});
});
it('returns the metadata object', async () => {
const response = await supertest(app).get('/.well-known/oauth-authorization-server');
expect(response.status).toBe(200);
expect(response.body).toEqual(exampleMetadata);
});
it('includes CORS headers in response', async () => {
const response = await supertest(app).get('/.well-known/oauth-authorization-server').set('Origin', 'https://example.com');
expect(response.header['access-control-allow-origin']).toBe('*');
});
it('supports OPTIONS preflight requests', async () => {
const response = await supertest(app)
.options('/.well-known/oauth-authorization-server')
.set('Origin', 'https://example.com')
.set('Access-Control-Request-Method', 'GET');
expect(response.status).toBe(204);
expect(response.header['access-control-allow-origin']).toBe('*');
});
it('works with minimal metadata', async () => {
// Setup a new express app with minimal metadata
const minimalApp = express();
const minimalMetadata: OAuthMetadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
response_types_supported: ['code']
};
minimalApp.use('/.well-known/oauth-authorization-server', metadataHandler(minimalMetadata));
const response = await supertest(minimalApp).get('/.well-known/oauth-authorization-server');
expect(response.status).toBe(200);
expect(response.body).toEqual(minimalMetadata);
});
});

View file

@ -1,272 +0,0 @@
import { clientRegistrationHandler, ClientRegistrationHandlerOptions } from './register.js';
import { OAuthRegisteredClientsStore } from '../clients.js';
import { OAuthClientInformationFull, OAuthClientMetadata } from '../../../shared/auth.js';
import express from 'express';
import supertest from 'supertest';
import { MockInstance } from 'vitest';
describe('Client Registration Handler', () => {
// Mock client store with registration support
const mockClientStoreWithRegistration: OAuthRegisteredClientsStore = {
async getClient(_clientId: string): Promise<OAuthClientInformationFull | undefined> {
return undefined;
},
async registerClient(client: OAuthClientInformationFull): Promise<OAuthClientInformationFull> {
// Return the client info as-is in the mock
return client;
}
};
// Mock client store without registration support
const mockClientStoreWithoutRegistration: OAuthRegisteredClientsStore = {
async getClient(_clientId: string): Promise<OAuthClientInformationFull | undefined> {
return undefined;
}
// No registerClient method
};
describe('Handler creation', () => {
it('throws error if client store does not support registration', () => {
const options: ClientRegistrationHandlerOptions = {
clientsStore: mockClientStoreWithoutRegistration
};
expect(() => clientRegistrationHandler(options)).toThrow('does not support registering clients');
});
it('creates handler if client store supports registration', () => {
const options: ClientRegistrationHandlerOptions = {
clientsStore: mockClientStoreWithRegistration
};
expect(() => clientRegistrationHandler(options)).not.toThrow();
});
});
describe('Request handling', () => {
let app: express.Express;
let spyRegisterClient: MockInstance;
beforeEach(() => {
// Setup express app with registration handler
app = express();
const options: ClientRegistrationHandlerOptions = {
clientsStore: mockClientStoreWithRegistration,
clientSecretExpirySeconds: 86400 // 1 day for testing
};
app.use('/register', clientRegistrationHandler(options));
// Spy on the registerClient method
spyRegisterClient = vi.spyOn(mockClientStoreWithRegistration, 'registerClient');
});
afterEach(() => {
spyRegisterClient.mockRestore();
});
it('requires POST method', async () => {
const response = await supertest(app)
.get('/register')
.send({
redirect_uris: ['https://example.com/callback']
});
expect(response.status).toBe(405);
expect(response.headers.allow).toBe('POST');
expect(response.body).toEqual({
error: 'method_not_allowed',
error_description: 'The method GET is not allowed for this endpoint'
});
expect(spyRegisterClient).not.toHaveBeenCalled();
});
it('validates required client metadata', async () => {
const response = await supertest(app).post('/register').send({
// Missing redirect_uris (required)
client_name: 'Test Client'
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('invalid_client_metadata');
expect(spyRegisterClient).not.toHaveBeenCalled();
});
it('validates redirect URIs format', async () => {
const response = await supertest(app)
.post('/register')
.send({
redirect_uris: ['invalid-url'] // Invalid URL format
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('invalid_client_metadata');
expect(response.body.error_description).toContain('redirect_uris');
expect(spyRegisterClient).not.toHaveBeenCalled();
});
it('successfully registers client with minimal metadata', async () => {
const clientMetadata: OAuthClientMetadata = {
redirect_uris: ['https://example.com/callback']
};
const response = await supertest(app).post('/register').send(clientMetadata);
expect(response.status).toBe(201);
// Verify the generated client information
expect(response.body.client_id).toBeDefined();
expect(response.body.client_secret).toBeDefined();
expect(response.body.client_id_issued_at).toBeDefined();
expect(response.body.client_secret_expires_at).toBeDefined();
expect(response.body.redirect_uris).toEqual(['https://example.com/callback']);
// Verify client was registered
expect(spyRegisterClient).toHaveBeenCalledTimes(1);
});
it('sets client_secret to undefined for token_endpoint_auth_method=none', async () => {
const clientMetadata: OAuthClientMetadata = {
redirect_uris: ['https://example.com/callback'],
token_endpoint_auth_method: 'none'
};
const response = await supertest(app).post('/register').send(clientMetadata);
expect(response.status).toBe(201);
expect(response.body.client_secret).toBeUndefined();
expect(response.body.client_secret_expires_at).toBeUndefined();
});
it('sets client_secret_expires_at for public clients only', async () => {
// Test for public client (token_endpoint_auth_method not 'none')
const publicClientMetadata: OAuthClientMetadata = {
redirect_uris: ['https://example.com/callback'],
token_endpoint_auth_method: 'client_secret_basic'
};
const publicResponse = await supertest(app).post('/register').send(publicClientMetadata);
expect(publicResponse.status).toBe(201);
expect(publicResponse.body.client_secret).toBeDefined();
expect(publicResponse.body.client_secret_expires_at).toBeDefined();
// Test for non-public client (token_endpoint_auth_method is 'none')
const nonPublicClientMetadata: OAuthClientMetadata = {
redirect_uris: ['https://example.com/callback'],
token_endpoint_auth_method: 'none'
};
const nonPublicResponse = await supertest(app).post('/register').send(nonPublicClientMetadata);
expect(nonPublicResponse.status).toBe(201);
expect(nonPublicResponse.body.client_secret).toBeUndefined();
expect(nonPublicResponse.body.client_secret_expires_at).toBeUndefined();
});
it('sets expiry based on clientSecretExpirySeconds', async () => {
// Create handler with custom expiry time
const customApp = express();
const options: ClientRegistrationHandlerOptions = {
clientsStore: mockClientStoreWithRegistration,
clientSecretExpirySeconds: 3600 // 1 hour
};
customApp.use('/register', clientRegistrationHandler(options));
const response = await supertest(customApp)
.post('/register')
.send({
redirect_uris: ['https://example.com/callback']
});
expect(response.status).toBe(201);
// Verify the expiration time (~1 hour from now)
const issuedAt = response.body.client_id_issued_at;
const expiresAt = response.body.client_secret_expires_at;
expect(expiresAt - issuedAt).toBe(3600);
});
it('sets no expiry when clientSecretExpirySeconds=0', async () => {
// Create handler with no expiry
const customApp = express();
const options: ClientRegistrationHandlerOptions = {
clientsStore: mockClientStoreWithRegistration,
clientSecretExpirySeconds: 0 // No expiry
};
customApp.use('/register', clientRegistrationHandler(options));
const response = await supertest(customApp)
.post('/register')
.send({
redirect_uris: ['https://example.com/callback']
});
expect(response.status).toBe(201);
expect(response.body.client_secret_expires_at).toBe(0);
});
it('sets no client_id when clientIdGeneration=false', async () => {
// Create handler with no expiry
const customApp = express();
const options: ClientRegistrationHandlerOptions = {
clientsStore: mockClientStoreWithRegistration,
clientIdGeneration: false
};
customApp.use('/register', clientRegistrationHandler(options));
const response = await supertest(customApp)
.post('/register')
.send({
redirect_uris: ['https://example.com/callback']
});
expect(response.status).toBe(201);
expect(response.body.client_id).toBeUndefined();
expect(response.body.client_id_issued_at).toBeUndefined();
});
it('handles client with all metadata fields', async () => {
const fullClientMetadata: OAuthClientMetadata = {
redirect_uris: ['https://example.com/callback'],
token_endpoint_auth_method: 'client_secret_basic',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
client_name: 'Test Client',
client_uri: 'https://example.com',
logo_uri: 'https://example.com/logo.png',
scope: 'profile email',
contacts: ['dev@example.com'],
tos_uri: 'https://example.com/tos',
policy_uri: 'https://example.com/privacy',
jwks_uri: 'https://example.com/jwks',
software_id: 'test-software',
software_version: '1.0.0'
};
const response = await supertest(app).post('/register').send(fullClientMetadata);
expect(response.status).toBe(201);
// Verify all metadata was preserved
Object.entries(fullClientMetadata).forEach(([key, value]) => {
expect(response.body[key]).toEqual(value);
});
});
it('includes CORS headers in response', async () => {
const response = await supertest(app)
.post('/register')
.set('Origin', 'https://example.com')
.send({
redirect_uris: ['https://example.com/callback']
});
expect(response.header['access-control-allow-origin']).toBe('*');
});
});
});

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