Relocate tests under /test (#1220)
This commit is contained in:
parent
7655eeb51c
commit
5a9de1c638
50 changed files with 630 additions and 524 deletions
331
test/client/auth-extensions.test.ts
Normal file
331
test/client/auth-extensions.test.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { auth } from '../../src/client/auth.js';
|
||||
import {
|
||||
ClientCredentialsProvider,
|
||||
PrivateKeyJwtProvider,
|
||||
StaticPrivateKeyJwtProvider,
|
||||
createPrivateKeyJwtAuth
|
||||
} from '../../src/client/auth-extensions.js';
|
||||
import { createMockOAuthFetch } from '../helpers/oauth.js';
|
||||
|
||||
const RESOURCE_SERVER_URL = 'https://resource.example.com/';
|
||||
const AUTH_SERVER_URL = 'https://auth.example.com';
|
||||
|
||||
describe('auth-extensions providers (end-to-end with auth())', () => {
|
||||
it('authenticates using ClientCredentialsProvider with client_secret_basic', async () => {
|
||||
const provider = new ClientCredentialsProvider({
|
||||
clientId: 'my-client',
|
||||
clientSecret: 'my-secret',
|
||||
clientName: 'test-client'
|
||||
});
|
||||
|
||||
const fetchMock = createMockOAuthFetch({
|
||||
resourceServerUrl: RESOURCE_SERVER_URL,
|
||||
authServerUrl: AUTH_SERVER_URL,
|
||||
onTokenRequest: async (_url, init) => {
|
||||
const params = init?.body as URLSearchParams;
|
||||
expect(params).toBeInstanceOf(URLSearchParams);
|
||||
expect(params.get('grant_type')).toBe('client_credentials');
|
||||
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
|
||||
expect(params.get('client_assertion')).toBeNull();
|
||||
|
||||
const headers = new Headers(init?.headers);
|
||||
const authHeader = headers.get('Authorization');
|
||||
expect(authHeader).toBeTruthy();
|
||||
|
||||
const expectedCredentials = Buffer.from('my-client:my-secret').toString('base64');
|
||||
expect(authHeader).toBe(`Basic ${expectedCredentials}`);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await auth(provider, {
|
||||
serverUrl: RESOURCE_SERVER_URL,
|
||||
fetchFn: fetchMock
|
||||
});
|
||||
|
||||
expect(result).toBe('AUTHORIZED');
|
||||
const tokens = provider.tokens();
|
||||
expect(tokens).toBeTruthy();
|
||||
expect(tokens?.access_token).toBe('test-access-token');
|
||||
});
|
||||
|
||||
it('authenticates using PrivateKeyJwtProvider with private_key_jwt', async () => {
|
||||
const provider = new PrivateKeyJwtProvider({
|
||||
clientId: 'client-id',
|
||||
privateKey: 'a-string-secret-at-least-256-bits-long',
|
||||
algorithm: 'HS256',
|
||||
clientName: 'private-key-jwt-client'
|
||||
});
|
||||
|
||||
let assertionFromRequest: string | null = null;
|
||||
|
||||
const fetchMock = createMockOAuthFetch({
|
||||
resourceServerUrl: RESOURCE_SERVER_URL,
|
||||
authServerUrl: AUTH_SERVER_URL,
|
||||
onTokenRequest: async (_url, init) => {
|
||||
const params = init?.body as URLSearchParams;
|
||||
expect(params).toBeInstanceOf(URLSearchParams);
|
||||
expect(params.get('grant_type')).toBe('client_credentials');
|
||||
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
|
||||
|
||||
assertionFromRequest = params.get('client_assertion');
|
||||
expect(assertionFromRequest).toBeTruthy();
|
||||
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
|
||||
|
||||
const parts = assertionFromRequest!.split('.');
|
||||
expect(parts).toHaveLength(3);
|
||||
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get('Authorization')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
const result = await auth(provider, {
|
||||
serverUrl: RESOURCE_SERVER_URL,
|
||||
fetchFn: fetchMock
|
||||
});
|
||||
|
||||
expect(result).toBe('AUTHORIZED');
|
||||
const tokens = provider.tokens();
|
||||
expect(tokens).toBeTruthy();
|
||||
expect(tokens?.access_token).toBe('test-access-token');
|
||||
expect(assertionFromRequest).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fails when PrivateKeyJwtProvider is configured with an unsupported algorithm', async () => {
|
||||
const provider = new PrivateKeyJwtProvider({
|
||||
clientId: 'client-id',
|
||||
privateKey: 'a-string-secret-at-least-256-bits-long',
|
||||
algorithm: 'none',
|
||||
clientName: 'private-key-jwt-client'
|
||||
});
|
||||
|
||||
const fetchMock = createMockOAuthFetch({
|
||||
resourceServerUrl: RESOURCE_SERVER_URL,
|
||||
authServerUrl: AUTH_SERVER_URL
|
||||
});
|
||||
|
||||
await expect(
|
||||
auth(provider, {
|
||||
serverUrl: RESOURCE_SERVER_URL,
|
||||
fetchFn: fetchMock
|
||||
})
|
||||
).rejects.toThrow('Unsupported algorithm none');
|
||||
});
|
||||
|
||||
it('authenticates using StaticPrivateKeyJwtProvider with static client assertion', async () => {
|
||||
const staticAssertion = 'header.payload.signature';
|
||||
|
||||
const provider = new StaticPrivateKeyJwtProvider({
|
||||
clientId: 'static-client',
|
||||
jwtBearerAssertion: staticAssertion,
|
||||
clientName: 'static-private-key-jwt-client'
|
||||
});
|
||||
|
||||
const fetchMock = createMockOAuthFetch({
|
||||
resourceServerUrl: RESOURCE_SERVER_URL,
|
||||
authServerUrl: AUTH_SERVER_URL,
|
||||
onTokenRequest: async (_url, init) => {
|
||||
const params = init?.body as URLSearchParams;
|
||||
expect(params).toBeInstanceOf(URLSearchParams);
|
||||
expect(params.get('grant_type')).toBe('client_credentials');
|
||||
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
|
||||
|
||||
expect(params.get('client_assertion')).toBe(staticAssertion);
|
||||
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
|
||||
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get('Authorization')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
const result = await auth(provider, {
|
||||
serverUrl: RESOURCE_SERVER_URL,
|
||||
fetchFn: fetchMock
|
||||
});
|
||||
|
||||
expect(result).toBe('AUTHORIZED');
|
||||
const tokens = provider.tokens();
|
||||
expect(tokens).toBeTruthy();
|
||||
expect(tokens?.access_token).toBe('test-access-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPrivateKeyJwtAuth', () => {
|
||||
const baseOptions = {
|
||||
issuer: 'client-id',
|
||||
subject: 'client-id',
|
||||
privateKey: 'a-string-secret-at-least-256-bits-long',
|
||||
alg: 'HS256'
|
||||
};
|
||||
|
||||
it('creates an addClientAuthentication function that sets JWT assertion params', async () => {
|
||||
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
|
||||
|
||||
const headers = new Headers();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
await addClientAuth(headers, params, 'https://auth.example.com/token', undefined);
|
||||
|
||||
expect(params.get('client_assertion')).toBeTruthy();
|
||||
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
|
||||
|
||||
// Verify JWT structure (three dot-separated segments)
|
||||
const assertion = params.get('client_assertion')!;
|
||||
const parts = assertion.split('.');
|
||||
expect(parts).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('throws when globalThis.crypto is not available', async () => {
|
||||
// Temporarily remove globalThis.crypto to simulate older Node.js runtimes
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const globalAny = globalThis as any;
|
||||
const originalCrypto = globalAny.crypto;
|
||||
// Use delete so that typeof globalThis.crypto === 'undefined'
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete globalAny.crypto;
|
||||
|
||||
try {
|
||||
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
|
||||
'crypto is not available, please ensure you add have Web Crypto API support for older Node.js versions'
|
||||
);
|
||||
} finally {
|
||||
// Restore original crypto to avoid affecting other tests
|
||||
globalAny.crypto = originalCrypto;
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a signed JWT when using a Uint8Array HMAC key', async () => {
|
||||
const secret = new TextEncoder().encode('a-string-secret-at-least-256-bits-long');
|
||||
|
||||
const addClientAuth = createPrivateKeyJwtAuth({
|
||||
issuer: 'client-id',
|
||||
subject: 'client-id',
|
||||
privateKey: secret,
|
||||
alg: 'HS256'
|
||||
});
|
||||
|
||||
const params = new URLSearchParams();
|
||||
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
|
||||
|
||||
const assertion = params.get('client_assertion')!;
|
||||
const parts = assertion.split('.');
|
||||
expect(parts).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('creates a signed JWT when using a symmetric JWK key', async () => {
|
||||
const jwk: Record<string, unknown> = {
|
||||
kty: 'oct',
|
||||
// "a-string-secret-at-least-256-bits-long" base64url-encoded
|
||||
k: 'YS1zdHJpbmctc2VjcmV0LWF0LWxlYXN0LTI1Ni1iaXRzLWxvbmc',
|
||||
alg: 'HS256'
|
||||
};
|
||||
|
||||
const addClientAuth = createPrivateKeyJwtAuth({
|
||||
issuer: 'client-id',
|
||||
subject: 'client-id',
|
||||
privateKey: jwk,
|
||||
alg: 'HS256'
|
||||
});
|
||||
|
||||
const params = new URLSearchParams();
|
||||
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
|
||||
|
||||
const assertion = params.get('client_assertion')!;
|
||||
const parts = assertion.split('.');
|
||||
expect(parts).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('creates a signed JWT when using an RSA PEM private key', async () => {
|
||||
// Generate an RSA key pair on the fly
|
||||
const jose = await import('jose');
|
||||
const { privateKey } = await jose.generateKeyPair('RS256', { extractable: true });
|
||||
const pem = await jose.exportPKCS8(privateKey);
|
||||
|
||||
const addClientAuth = createPrivateKeyJwtAuth({
|
||||
issuer: 'client-id',
|
||||
subject: 'client-id',
|
||||
privateKey: pem,
|
||||
alg: 'RS256'
|
||||
});
|
||||
|
||||
const params = new URLSearchParams();
|
||||
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
|
||||
|
||||
const assertion = params.get('client_assertion')!;
|
||||
const parts = assertion.split('.');
|
||||
expect(parts).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('uses metadata.issuer as audience when available', async () => {
|
||||
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', {
|
||||
issuer: 'https://issuer.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
response_types_supported: ['code']
|
||||
});
|
||||
|
||||
const assertion = params.get('client_assertion')!;
|
||||
// Decode the payload to verify audience
|
||||
const [, payloadB64] = assertion.split('.');
|
||||
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
||||
expect(payload.aud).toBe('https://issuer.example.com');
|
||||
});
|
||||
|
||||
it('throws when using an unsupported algorithm', async () => {
|
||||
const addClientAuth = createPrivateKeyJwtAuth({
|
||||
issuer: 'client-id',
|
||||
subject: 'client-id',
|
||||
privateKey: 'a-string-secret-at-least-256-bits-long',
|
||||
alg: 'none'
|
||||
});
|
||||
|
||||
const params = new URLSearchParams();
|
||||
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
|
||||
'Unsupported algorithm none'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when jose cannot import an invalid RSA PEM key', async () => {
|
||||
const badPem = '-----BEGIN PRIVATE KEY-----\nnot-a-valid-key\n-----END PRIVATE KEY-----';
|
||||
|
||||
const addClientAuth = createPrivateKeyJwtAuth({
|
||||
issuer: 'client-id',
|
||||
subject: 'client-id',
|
||||
privateKey: badPem,
|
||||
alg: 'RS256'
|
||||
});
|
||||
|
||||
const params = new URLSearchParams();
|
||||
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
|
||||
/Invalid character/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when jose cannot import a mismatched JWK key', async () => {
|
||||
const jwk: Record<string, unknown> = {
|
||||
kty: 'oct',
|
||||
k: 'c2VjcmV0LWtleQ', // "secret-key" base64url
|
||||
alg: 'HS256'
|
||||
};
|
||||
|
||||
const addClientAuth = createPrivateKeyJwtAuth({
|
||||
issuer: 'client-id',
|
||||
subject: 'client-id',
|
||||
privateKey: jwk,
|
||||
// Ask for an RSA algorithm with an octet key, which should cause jose.importJWK to fail
|
||||
alg: 'RS256'
|
||||
});
|
||||
|
||||
const params = new URLSearchParams();
|
||||
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
|
||||
/Key for the RS256 algorithm must be one of type CryptoKey, KeyObject, or JSON Web Key/
|
||||
);
|
||||
});
|
||||
});
|
||||
3247
test/client/auth.test.ts
Normal file
3247
test/client/auth.test.ts
Normal file
File diff suppressed because it is too large
Load diff
153
test/client/cross-spawn.test.ts
Normal file
153
test/client/cross-spawn.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { StdioClientTransport, getDefaultEnvironment } from '../../src/client/stdio.js';
|
||||
import spawn from 'cross-spawn';
|
||||
import { JSONRPCMessage } from '../../src/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();
|
||||
});
|
||||
});
|
||||
4139
test/client/index.test.ts
Normal file
4139
test/client/index.test.ts
Normal file
File diff suppressed because it is too large
Load diff
1118
test/client/middleware.test.ts
Normal file
1118
test/client/middleware.test.ts
Normal file
File diff suppressed because it is too large
Load diff
1506
test/client/sse.test.ts
Normal file
1506
test/client/sse.test.ts
Normal file
File diff suppressed because it is too large
Load diff
77
test/client/stdio.test.ts
Normal file
77
test/client/stdio.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { JSONRPCMessage } from '../../src/types.js';
|
||||
import { StdioClientTransport, StdioServerParameters } from '../../src/client/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();
|
||||
});
|
||||
1626
test/client/streamableHttp.test.ts
Normal file
1626
test/client/streamableHttp.test.ts
Normal file
File diff suppressed because it is too large
Load diff
263
test/examples/server/demoInMemoryOAuthProvider.test.ts
Normal file
263
test/examples/server/demoInMemoryOAuthProvider.test.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import { Response } from 'express';
|
||||
import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from '../../../src/examples/server/demoInMemoryOAuthProvider.js';
|
||||
import { AuthorizationParams } from '../../../src/server/auth/provider.js';
|
||||
import { OAuthClientInformationFull } from '../../../src/shared/auth.js';
|
||||
import { InvalidRequestError } from '../../../src/server/auth/errors.js';
|
||||
|
||||
import { createExpressResponseMock } from '../../helpers/http.js';
|
||||
|
||||
describe('DemoInMemoryAuthProvider', () => {
|
||||
let provider: DemoInMemoryAuthProvider;
|
||||
let mockResponse: Response & { getRedirectUrl: () => string };
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new DemoInMemoryAuthProvider();
|
||||
mockResponse = createExpressResponseMock({ trackRedirectUrl: true }) as Response & {
|
||||
getRedirectUrl: () => string;
|
||||
};
|
||||
});
|
||||
|
||||
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 = createExpressResponseMock({ trackRedirectUrl: true }) as Response & {
|
||||
getRedirectUrl: () => string;
|
||||
};
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
936
test/experimental/tasks/stores/in-memory.test.ts
Normal file
936
test/experimental/tasks/stores/in-memory.test.ts
Normal file
|
|
@ -0,0 +1,936 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '../../../../src/experimental/tasks/stores/in-memory.js';
|
||||
import { TaskCreationParams, Request } from '../../../../src/types.js';
|
||||
import { QueuedMessage } from '../../../../src/experimental/tasks/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');
|
||||
});
|
||||
});
|
||||
});
|
||||
128
test/experimental/tasks/task-listing.test.ts
Normal file
128
test/experimental/tasks/task-listing.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { ErrorCode, McpError } from '../../../src/types.js';
|
||||
import { createInMemoryTaskEnvironment } from '../../helpers/mcp.js';
|
||||
|
||||
describe('Task Listing with Pagination', () => {
|
||||
let client: Awaited<ReturnType<typeof createInMemoryTaskEnvironment>>['client'];
|
||||
let server: Awaited<ReturnType<typeof createInMemoryTaskEnvironment>>['server'];
|
||||
let taskStore: Awaited<ReturnType<typeof createInMemoryTaskEnvironment>>['taskStore'];
|
||||
|
||||
beforeEach(async () => {
|
||||
const env = await createInMemoryTaskEnvironment();
|
||||
client = env.client;
|
||||
server = env.server;
|
||||
taskStore = env.taskStore;
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
117
test/experimental/tasks/task.test.ts
Normal file
117
test/experimental/tasks/task.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { isTerminal } from '../../../src/experimental/tasks/interfaces.js';
|
||||
import type { Task } from '../../../src/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);
|
||||
});
|
||||
});
|
||||
});
|
||||
96
test/helpers/http.ts
Normal file
96
test/helpers/http.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type http from 'node:http';
|
||||
import { type Server } from 'node:http';
|
||||
import type { Response } from 'express';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Attach a listener to an existing server on a random localhost port and return its base URL.
|
||||
*/
|
||||
export async function listenOnRandomPort(server: Server, host: string = '127.0.0.1'): Promise<URL> {
|
||||
return new Promise<URL>(resolve => {
|
||||
server.listen(0, host, () => {
|
||||
const addr = server.address() as AddressInfo;
|
||||
resolve(new URL(`http://${host}:${addr.port}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// =========================
|
||||
// HTTP/Express mock helpers
|
||||
// =========================
|
||||
|
||||
/**
|
||||
* Create a minimal Express-like Response mock for tests.
|
||||
*
|
||||
* The mock supports:
|
||||
* - redirect()
|
||||
* - status().json().send() chaining
|
||||
* - set()/header()
|
||||
* - optional getRedirectUrl() helper used in some tests
|
||||
*/
|
||||
export function createExpressResponseMock(options: { trackRedirectUrl?: boolean } = {}): Response & {
|
||||
getRedirectUrl?: () => string;
|
||||
} {
|
||||
let capturedRedirectUrl: string | undefined;
|
||||
|
||||
const res: Partial<Response> & { getRedirectUrl?: () => string } = {
|
||||
redirect: vi.fn((urlOrStatus: string | number, maybeUrl?: string | number) => {
|
||||
if (options.trackRedirectUrl) {
|
||||
if (typeof urlOrStatus === 'string') {
|
||||
capturedRedirectUrl = urlOrStatus;
|
||||
} else if (typeof maybeUrl === 'string') {
|
||||
capturedRedirectUrl = maybeUrl;
|
||||
}
|
||||
}
|
||||
return res as Response;
|
||||
}) as unknown as Response['redirect'],
|
||||
status: vi.fn<Response['status']>().mockImplementation((_code: number) => {
|
||||
// status code is ignored for now; tests assert it via jest/vitest spies
|
||||
return res as Response;
|
||||
}),
|
||||
json: vi.fn<Response['json']>().mockImplementation((_body: unknown) => {
|
||||
// body is ignored; tests usually assert via spy
|
||||
return res as Response;
|
||||
}),
|
||||
send: vi.fn<Response['send']>().mockImplementation((_body?: unknown) => {
|
||||
// body is ignored; tests usually assert via spy
|
||||
return res as Response;
|
||||
}),
|
||||
set: vi.fn<Response['set']>().mockImplementation((_field: string, _value?: string | string[]) => {
|
||||
// header value is ignored in the generic mock; tests spy on set()
|
||||
return res as Response;
|
||||
}),
|
||||
header: vi.fn<Response['header']>().mockImplementation((_field: string, _value?: string | string[]) => {
|
||||
return res as Response;
|
||||
})
|
||||
};
|
||||
|
||||
if (options.trackRedirectUrl) {
|
||||
res.getRedirectUrl = () => {
|
||||
if (capturedRedirectUrl === undefined) {
|
||||
throw new Error('No redirect URL was captured. Ensure redirect() was called first.');
|
||||
}
|
||||
return capturedRedirectUrl;
|
||||
};
|
||||
}
|
||||
|
||||
return res as Response & { getRedirectUrl?: () => string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Node http.ServerResponse mock used for low-level transport tests.
|
||||
*
|
||||
* All core methods are jest/vitest fns returning `this` so that
|
||||
* tests can assert on writeHead/write/on/end calls.
|
||||
*/
|
||||
export function createNodeServerResponseMock(): http.ServerResponse {
|
||||
const res = {
|
||||
writeHead: vi.fn<http.ServerResponse['writeHead']>().mockReturnThis(),
|
||||
write: vi.fn<http.ServerResponse['write']>().mockReturnThis(),
|
||||
on: vi.fn<http.ServerResponse['on']>().mockReturnThis(),
|
||||
end: vi.fn<http.ServerResponse['end']>().mockReturnThis()
|
||||
};
|
||||
|
||||
return res as unknown as http.ServerResponse;
|
||||
}
|
||||
71
test/helpers/mcp.ts
Normal file
71
test/helpers/mcp.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { InMemoryTransport } from '../../src/inMemory.js';
|
||||
import { Client } from '../../src/client/index.js';
|
||||
import { Server } from '../../src/server/index.js';
|
||||
import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '../../src/experimental/tasks/stores/in-memory.js';
|
||||
import type { ClientCapabilities, ServerCapabilities } from '../../src/types.js';
|
||||
|
||||
export interface InMemoryTaskEnvironment {
|
||||
client: Client;
|
||||
server: Server;
|
||||
taskStore: InMemoryTaskStore;
|
||||
clientTransport: InMemoryTransport;
|
||||
serverTransport: InMemoryTransport;
|
||||
}
|
||||
|
||||
export async function createInMemoryTaskEnvironment(options?: {
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
serverCapabilities?: ServerCapabilities;
|
||||
}): Promise<InMemoryTaskEnvironment> {
|
||||
const taskStore = new InMemoryTaskStore();
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: options?.clientCapabilities ?? {
|
||||
tasks: {
|
||||
list: {},
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: options?.serverCapabilities ?? {
|
||||
tasks: {
|
||||
list: {},
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
taskStore,
|
||||
taskMessageQueue: new InMemoryTaskMessageQueue()
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
return {
|
||||
client,
|
||||
server,
|
||||
taskStore,
|
||||
clientTransport,
|
||||
serverTransport
|
||||
};
|
||||
}
|
||||
87
test/helpers/oauth.ts
Normal file
87
test/helpers/oauth.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import type { FetchLike } from '../../src/shared/transport.js';
|
||||
|
||||
export interface MockOAuthFetchOptions {
|
||||
resourceServerUrl: string;
|
||||
authServerUrl: string;
|
||||
/**
|
||||
* Optional hook to inspect or override the token request.
|
||||
*/
|
||||
onTokenRequest?: (url: URL, init: RequestInit | undefined) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared mock fetch implementation for OAuth flows used in client tests.
|
||||
*
|
||||
* It handles:
|
||||
* - OAuth Protected Resource Metadata discovery
|
||||
* - Authorization Server Metadata discovery
|
||||
* - Token endpoint responses
|
||||
*/
|
||||
export function createMockOAuthFetch(options: MockOAuthFetchOptions): FetchLike {
|
||||
const { resourceServerUrl, authServerUrl, onTokenRequest } = options;
|
||||
|
||||
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 === resourceServerUrl.slice(0, -1) && url.pathname === '/.well-known/oauth-protected-resource') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
resource: resourceServerUrl,
|
||||
authorization_servers: [authServerUrl]
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Authorization server metadata discovery
|
||||
if (url.origin === authServerUrl && url.pathname === '/.well-known/oauth-authorization-server') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
issuer: authServerUrl,
|
||||
authorization_endpoint: `${authServerUrl}/authorize`,
|
||||
token_endpoint: `${authServerUrl}/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 === authServerUrl && 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 OAuth fetch: ${url.toString()}`);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to install a vi.fn-based global.fetch mock for tests that rely on global fetch.
|
||||
*/
|
||||
export function mockGlobalFetch() {
|
||||
const mockFetch = vi.fn();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = mockFetch;
|
||||
return mockFetch;
|
||||
}
|
||||
33
test/helpers/tasks.ts
Normal file
33
test/helpers/tasks.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { Task } from '../../src/types.js';
|
||||
|
||||
/**
|
||||
* Polls the provided getTask function until the task reaches the desired status or times out.
|
||||
*/
|
||||
export async function waitForTaskStatus(
|
||||
getTask: (taskId: string) => Promise<Task | null | undefined>,
|
||||
taskId: string,
|
||||
desiredStatus: Task['status'],
|
||||
{
|
||||
intervalMs = 100,
|
||||
timeoutMs = 10_000
|
||||
}: {
|
||||
intervalMs?: number;
|
||||
timeoutMs?: number;
|
||||
} = {}
|
||||
): Promise<Task> {
|
||||
const start = Date.now();
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const task = await getTask(taskId);
|
||||
if (task && task.status === desiredStatus) {
|
||||
return task;
|
||||
}
|
||||
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`Timed out waiting for task ${taskId} to reach status ${desiredStatus}`);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
}
|
||||
119
test/inMemory.test.ts
Normal file
119
test/inMemory.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { InMemoryTransport } from '../src/inMemory.js';
|
||||
import { JSONRPCMessage } from '../src/types.js';
|
||||
import { AuthInfo } from '../src/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);
|
||||
});
|
||||
});
|
||||
113
test/integration-tests/processCleanup.test.ts
Normal file
113
test/integration-tests/processCleanup.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import path from 'node:path';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { Client } from '../../src/client/index.js';
|
||||
import { StdioClientTransport } from '../../src/client/stdio.js';
|
||||
import { Server } from '../../src/server/index.js';
|
||||
import { StdioServerTransport } from '../../src/server/stdio.js';
|
||||
import { LoggingMessageNotificationSchema } from '../../src/types.js';
|
||||
|
||||
const FIXTURES_DIR = path.resolve(__dirname, '../../src/__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;
|
||||
}
|
||||
});
|
||||
});
|
||||
355
test/integration-tests/stateManagementStreamableHttp.test.ts
Normal file
355
test/integration-tests/stateManagementStreamableHttp.test.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import { createServer, type Server } from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Client } from '../../src/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js';
|
||||
import { McpServer } from '../../src/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../../src/server/streamableHttp.js';
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
ListToolsResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ListPromptsResultSchema,
|
||||
LATEST_PROTOCOL_VERSION
|
||||
} from '../../src/types.js';
|
||||
import { zodTestMatrix, type ZodMatrixEntry } from '../../src/__fixtures__/zodTestMatrix.js';
|
||||
import { listenOnRandomPort } from '../helpers/http.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 listenOnRandomPort(server);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
1664
test/integration-tests/taskLifecycle.test.ts
Normal file
1664
test/integration-tests/taskLifecycle.test.ts
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue