v2 ESlint fixes (#1403)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
This commit is contained in:
parent
9296459ac0
commit
9fc7e28f48
36 changed files with 1672 additions and 1346 deletions
|
|
@ -277,17 +277,21 @@ function applyClientAuthentication(
|
|||
const { client_id, client_secret } = clientInformation;
|
||||
|
||||
switch (method) {
|
||||
case 'client_secret_basic':
|
||||
case 'client_secret_basic': {
|
||||
applyBasicAuth(client_id, client_secret, headers);
|
||||
return;
|
||||
case 'client_secret_post':
|
||||
}
|
||||
case 'client_secret_post': {
|
||||
applyPostAuth(client_id, client_secret, params);
|
||||
return;
|
||||
case 'none':
|
||||
}
|
||||
case 'none': {
|
||||
applyPublicAuth(client_id, params);
|
||||
return;
|
||||
default:
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unsupported client authentication method: ${method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -611,7 +615,7 @@ function extractFieldFromWwwAuth(response: Response, fieldName: string): string
|
|||
return null;
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`${fieldName}=(?:"([^"]+)"|([^\\s,]+))`);
|
||||
const pattern = new RegExp(String.raw`${fieldName}=(?:"([^"]+)"|([^\s,]+))`);
|
||||
const match = wwwAuthHeader.match(pattern);
|
||||
|
||||
if (match) {
|
||||
|
|
@ -689,13 +693,9 @@ async function fetchWithCorsRetry(url: URL, headers?: Record<string, string>, fe
|
|||
return await fetchFn(url, { headers });
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError) {
|
||||
if (headers) {
|
||||
// CORS errors come back as TypeError, retry without headers
|
||||
return fetchWithCorsRetry(url, undefined, fetchFn);
|
||||
} else {
|
||||
// We're getting CORS errors on retry too, return undefined
|
||||
return undefined;
|
||||
}
|
||||
// CORS errors come back as TypeError, retry without headers
|
||||
// We're getting CORS errors on retry too, return undefined
|
||||
return headers ? fetchWithCorsRetry(url, undefined, fetchFn) : undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -827,17 +827,20 @@ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url:
|
|||
const urlsToTry: { url: URL; type: 'oauth' | 'oidc' }[] = [];
|
||||
|
||||
if (!hasPath) {
|
||||
// Root path: https://example.com/.well-known/oauth-authorization-server
|
||||
urlsToTry.push({
|
||||
url: new URL('/.well-known/oauth-authorization-server', url.origin),
|
||||
type: 'oauth'
|
||||
});
|
||||
urlsToTry.push(
|
||||
// Root path: https://example.com/.well-known/oauth-authorization-server
|
||||
|
||||
// OIDC: https://example.com/.well-known/openid-configuration
|
||||
urlsToTry.push({
|
||||
url: new URL(`/.well-known/openid-configuration`, url.origin),
|
||||
type: 'oidc'
|
||||
});
|
||||
{
|
||||
url: new URL('/.well-known/oauth-authorization-server', url.origin),
|
||||
type: 'oauth'
|
||||
},
|
||||
// OIDC: https://example.com/.well-known/openid-configuration
|
||||
|
||||
{
|
||||
url: new URL(`/.well-known/openid-configuration`, url.origin),
|
||||
type: 'oidc'
|
||||
}
|
||||
);
|
||||
|
||||
return urlsToTry;
|
||||
}
|
||||
|
|
@ -848,25 +851,26 @@ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url:
|
|||
pathname = pathname.slice(0, -1);
|
||||
}
|
||||
|
||||
// 1. OAuth metadata at the given URL
|
||||
// Insert well-known before the path: https://example.com/.well-known/oauth-authorization-server/tenant1
|
||||
urlsToTry.push({
|
||||
url: new URL(`/.well-known/oauth-authorization-server${pathname}`, url.origin),
|
||||
type: 'oauth'
|
||||
});
|
||||
urlsToTry.push(
|
||||
// 1. OAuth metadata at the given URL
|
||||
// Insert well-known before the path: https://example.com/.well-known/oauth-authorization-server/tenant1
|
||||
{
|
||||
url: new URL(`/.well-known/oauth-authorization-server${pathname}`, url.origin),
|
||||
type: 'oauth'
|
||||
},
|
||||
// 2. OIDC metadata endpoints
|
||||
// RFC 8414 style: Insert /.well-known/openid-configuration before the path
|
||||
{
|
||||
url: new URL(`/.well-known/openid-configuration${pathname}`, url.origin),
|
||||
type: 'oidc'
|
||||
},
|
||||
// OIDC Discovery 1.0 style: Append /.well-known/openid-configuration after the path
|
||||
|
||||
// 2. OIDC metadata endpoints
|
||||
// RFC 8414 style: Insert /.well-known/openid-configuration before the path
|
||||
urlsToTry.push({
|
||||
url: new URL(`/.well-known/openid-configuration${pathname}`, url.origin),
|
||||
type: 'oidc'
|
||||
});
|
||||
|
||||
// OIDC Discovery 1.0 style: Append /.well-known/openid-configuration after the path
|
||||
urlsToTry.push({
|
||||
url: new URL(`${pathname}/.well-known/openid-configuration`, url.origin),
|
||||
type: 'oidc'
|
||||
});
|
||||
{
|
||||
url: new URL(`${pathname}/.well-known/openid-configuration`, url.origin),
|
||||
type: 'oidc'
|
||||
}
|
||||
);
|
||||
|
||||
return urlsToTry;
|
||||
}
|
||||
|
|
@ -929,11 +933,9 @@ export async function discoverAuthorizationServerMetadata(
|
|||
}
|
||||
|
||||
// Parse and validate based on type
|
||||
if (type === 'oauth') {
|
||||
return OAuthMetadataSchema.parse(await response.json());
|
||||
} else {
|
||||
return OpenIdProviderDiscoveryMetadataSchema.parse(await response.json());
|
||||
}
|
||||
return type === 'oauth'
|
||||
? OAuthMetadataSchema.parse(await response.json())
|
||||
: OpenIdProviderDiscoveryMetadataSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
|
|
|||
398
packages/client/src/client/authExtensions.ts
Normal file
398
packages/client/src/client/authExtensions.ts
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
/**
|
||||
* OAuth provider extensions for specialized authentication flows.
|
||||
*
|
||||
* This module provides ready-to-use OAuthClientProvider implementations
|
||||
* for common machine-to-machine authentication scenarios.
|
||||
*/
|
||||
|
||||
import type { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core';
|
||||
import type { CryptoKey, JWK } from 'jose';
|
||||
|
||||
import type { AddClientAuthentication, OAuthClientProvider } from './auth.js';
|
||||
|
||||
/**
|
||||
* Helper to produce a private_key_jwt client authentication function.
|
||||
*
|
||||
* Usage:
|
||||
* const addClientAuth = createPrivateKeyJwtAuth({ issuer, subject, privateKey, alg, audience? });
|
||||
* // pass addClientAuth as provider.addClientAuthentication implementation
|
||||
*/
|
||||
export function createPrivateKeyJwtAuth(options: {
|
||||
issuer: string;
|
||||
subject: string;
|
||||
privateKey: string | Uint8Array | Record<string, unknown>;
|
||||
alg: string;
|
||||
audience?: string | URL;
|
||||
lifetimeSeconds?: number;
|
||||
claims?: Record<string, unknown>;
|
||||
}): AddClientAuthentication {
|
||||
return async (_headers, params, url, metadata) => {
|
||||
// Lazy import to avoid heavy dependency unless used
|
||||
if (globalThis.crypto === undefined) {
|
||||
throw new TypeError(
|
||||
'crypto is not available, please ensure you add have Web Crypto API support for older Node.js versions (see https://github.com/modelcontextprotocol/typescript-sdk#nodejs-web-crypto-globalthiscrypto-compatibility)'
|
||||
);
|
||||
}
|
||||
|
||||
const jose = await import('jose');
|
||||
|
||||
const audience = String(options.audience ?? metadata?.issuer ?? url);
|
||||
const lifetimeSeconds = options.lifetimeSeconds ?? 300;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const jti = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
const baseClaims = {
|
||||
iss: options.issuer,
|
||||
sub: options.subject,
|
||||
aud: audience,
|
||||
exp: now + lifetimeSeconds,
|
||||
iat: now,
|
||||
jti
|
||||
};
|
||||
const claims = options.claims ? { ...baseClaims, ...options.claims } : baseClaims;
|
||||
|
||||
// Import key for the requested algorithm
|
||||
const alg = options.alg;
|
||||
let key: unknown;
|
||||
if (typeof options.privateKey === 'string') {
|
||||
if (alg.startsWith('RS') || alg.startsWith('ES') || alg.startsWith('PS')) {
|
||||
key = await jose.importPKCS8(options.privateKey, alg);
|
||||
} else if (alg.startsWith('HS')) {
|
||||
key = new TextEncoder().encode(options.privateKey);
|
||||
} else {
|
||||
throw new Error(`Unsupported algorithm ${alg}`);
|
||||
}
|
||||
} else if (options.privateKey instanceof Uint8Array) {
|
||||
// Assume PKCS#8 DER in Uint8Array for asymmetric algorithms
|
||||
key = alg.startsWith('HS') ? options.privateKey : await jose.importPKCS8(new TextDecoder().decode(options.privateKey), alg);
|
||||
} else {
|
||||
// Treat as JWK
|
||||
key = await jose.importJWK(options.privateKey as JWK, alg);
|
||||
}
|
||||
|
||||
// Sign JWT
|
||||
const assertion = await new jose.SignJWT(claims)
|
||||
.setProtectedHeader({ alg, typ: 'JWT' })
|
||||
.setIssuer(options.issuer)
|
||||
.setSubject(options.subject)
|
||||
.setAudience(audience)
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + lifetimeSeconds)
|
||||
.setJti(jti)
|
||||
.sign(key as unknown as Uint8Array | CryptoKey);
|
||||
|
||||
params.set('client_assertion', assertion);
|
||||
params.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a ClientCredentialsProvider.
|
||||
*/
|
||||
export interface ClientCredentialsProviderOptions {
|
||||
/**
|
||||
* The client_id for this OAuth client.
|
||||
*/
|
||||
clientId: string;
|
||||
|
||||
/**
|
||||
* The client_secret for client_secret_basic authentication.
|
||||
*/
|
||||
clientSecret: string;
|
||||
|
||||
/**
|
||||
* Optional client name for metadata.
|
||||
*/
|
||||
clientName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth provider for client_credentials grant with client_secret_basic authentication.
|
||||
*
|
||||
* This provider is designed for machine-to-machine authentication where
|
||||
* the client authenticates using a client_id and client_secret.
|
||||
*
|
||||
* @example
|
||||
* const provider = new ClientCredentialsProvider({
|
||||
* clientId: 'my-client',
|
||||
* clientSecret: 'my-secret'
|
||||
* });
|
||||
*
|
||||
* const transport = new StreamableHTTPClientTransport(serverUrl, {
|
||||
* authProvider: provider
|
||||
* });
|
||||
*/
|
||||
export class ClientCredentialsProvider implements OAuthClientProvider {
|
||||
private _tokens?: OAuthTokens;
|
||||
private _clientInfo: OAuthClientInformation;
|
||||
private _clientMetadata: OAuthClientMetadata;
|
||||
|
||||
constructor(options: ClientCredentialsProviderOptions) {
|
||||
this._clientInfo = {
|
||||
client_id: options.clientId,
|
||||
client_secret: options.clientSecret
|
||||
};
|
||||
this._clientMetadata = {
|
||||
client_name: options.clientName ?? 'client-credentials-client',
|
||||
redirect_uris: [],
|
||||
grant_types: ['client_credentials'],
|
||||
token_endpoint_auth_method: 'client_secret_basic'
|
||||
};
|
||||
}
|
||||
|
||||
get redirectUrl(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return this._clientMetadata;
|
||||
}
|
||||
|
||||
clientInformation(): OAuthClientInformation {
|
||||
return this._clientInfo;
|
||||
}
|
||||
|
||||
saveClientInformation(info: OAuthClientInformation): void {
|
||||
this._clientInfo = info;
|
||||
}
|
||||
|
||||
tokens(): OAuthTokens | undefined {
|
||||
return this._tokens;
|
||||
}
|
||||
|
||||
saveTokens(tokens: OAuthTokens): void {
|
||||
this._tokens = tokens;
|
||||
}
|
||||
|
||||
redirectToAuthorization(): void {
|
||||
throw new Error('redirectToAuthorization is not used for client_credentials flow');
|
||||
}
|
||||
|
||||
saveCodeVerifier(): void {
|
||||
// Not used for client_credentials
|
||||
}
|
||||
|
||||
codeVerifier(): string {
|
||||
throw new Error('codeVerifier is not used for client_credentials flow');
|
||||
}
|
||||
|
||||
prepareTokenRequest(scope?: string): URLSearchParams {
|
||||
const params = new URLSearchParams({ grant_type: 'client_credentials' });
|
||||
if (scope) params.set('scope', scope);
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a PrivateKeyJwtProvider.
|
||||
*/
|
||||
export interface PrivateKeyJwtProviderOptions {
|
||||
/**
|
||||
* The client_id for this OAuth client.
|
||||
*/
|
||||
clientId: string;
|
||||
|
||||
/**
|
||||
* The private key for signing JWT assertions.
|
||||
* Can be a PEM string, Uint8Array, or JWK object.
|
||||
*/
|
||||
privateKey: string | Uint8Array | Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* The algorithm to use for signing (e.g., 'RS256', 'ES256').
|
||||
*/
|
||||
algorithm: string;
|
||||
|
||||
/**
|
||||
* Optional client name for metadata.
|
||||
*/
|
||||
clientName?: string;
|
||||
|
||||
/**
|
||||
* Optional JWT lifetime in seconds (default: 300).
|
||||
*/
|
||||
jwtLifetimeSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth provider for client_credentials grant with private_key_jwt authentication.
|
||||
*
|
||||
* This provider is designed for machine-to-machine authentication where
|
||||
* the client authenticates using a signed JWT assertion (RFC 7523 Section 2.2).
|
||||
*
|
||||
* @example
|
||||
* const provider = new PrivateKeyJwtProvider({
|
||||
* clientId: 'my-client',
|
||||
* privateKey: pemEncodedPrivateKey,
|
||||
* algorithm: 'RS256'
|
||||
* });
|
||||
*
|
||||
* const transport = new StreamableHTTPClientTransport(serverUrl, {
|
||||
* authProvider: provider
|
||||
* });
|
||||
*/
|
||||
export class PrivateKeyJwtProvider implements OAuthClientProvider {
|
||||
private _tokens?: OAuthTokens;
|
||||
private _clientInfo: OAuthClientInformation;
|
||||
private _clientMetadata: OAuthClientMetadata;
|
||||
addClientAuthentication: AddClientAuthentication;
|
||||
|
||||
constructor(options: PrivateKeyJwtProviderOptions) {
|
||||
this._clientInfo = {
|
||||
client_id: options.clientId
|
||||
};
|
||||
this._clientMetadata = {
|
||||
client_name: options.clientName ?? 'private-key-jwt-client',
|
||||
redirect_uris: [],
|
||||
grant_types: ['client_credentials'],
|
||||
token_endpoint_auth_method: 'private_key_jwt'
|
||||
};
|
||||
this.addClientAuthentication = createPrivateKeyJwtAuth({
|
||||
issuer: options.clientId,
|
||||
subject: options.clientId,
|
||||
privateKey: options.privateKey,
|
||||
alg: options.algorithm,
|
||||
lifetimeSeconds: options.jwtLifetimeSeconds
|
||||
});
|
||||
}
|
||||
|
||||
get redirectUrl(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return this._clientMetadata;
|
||||
}
|
||||
|
||||
clientInformation(): OAuthClientInformation {
|
||||
return this._clientInfo;
|
||||
}
|
||||
|
||||
saveClientInformation(info: OAuthClientInformation): void {
|
||||
this._clientInfo = info;
|
||||
}
|
||||
|
||||
tokens(): OAuthTokens | undefined {
|
||||
return this._tokens;
|
||||
}
|
||||
|
||||
saveTokens(tokens: OAuthTokens): void {
|
||||
this._tokens = tokens;
|
||||
}
|
||||
|
||||
redirectToAuthorization(): void {
|
||||
throw new Error('redirectToAuthorization is not used for client_credentials flow');
|
||||
}
|
||||
|
||||
saveCodeVerifier(): void {
|
||||
// Not used for client_credentials
|
||||
}
|
||||
|
||||
codeVerifier(): string {
|
||||
throw new Error('codeVerifier is not used for client_credentials flow');
|
||||
}
|
||||
|
||||
prepareTokenRequest(scope?: string): URLSearchParams {
|
||||
const params = new URLSearchParams({ grant_type: 'client_credentials' });
|
||||
if (scope) params.set('scope', scope);
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a StaticPrivateKeyJwtProvider.
|
||||
*/
|
||||
export interface StaticPrivateKeyJwtProviderOptions {
|
||||
/**
|
||||
* The client_id for this OAuth client.
|
||||
*/
|
||||
clientId: string;
|
||||
|
||||
/**
|
||||
* A pre-built JWT client assertion to use for authentication.
|
||||
*
|
||||
* This token should already contain the appropriate claims
|
||||
* (iss, sub, aud, exp, etc.) and be signed by the client's key.
|
||||
*/
|
||||
jwtBearerAssertion: string;
|
||||
|
||||
/**
|
||||
* Optional client name for metadata.
|
||||
*/
|
||||
clientName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth provider for client_credentials grant with a static private_key_jwt assertion.
|
||||
*
|
||||
* This provider mirrors {@link PrivateKeyJwtProvider} but instead of constructing and
|
||||
* signing a JWT on each request, it accepts a pre-built JWT assertion string and
|
||||
* uses it directly for authentication.
|
||||
*/
|
||||
export class StaticPrivateKeyJwtProvider implements OAuthClientProvider {
|
||||
private _tokens?: OAuthTokens;
|
||||
private _clientInfo: OAuthClientInformation;
|
||||
private _clientMetadata: OAuthClientMetadata;
|
||||
addClientAuthentication: AddClientAuthentication;
|
||||
|
||||
constructor(options: StaticPrivateKeyJwtProviderOptions) {
|
||||
this._clientInfo = {
|
||||
client_id: options.clientId
|
||||
};
|
||||
this._clientMetadata = {
|
||||
client_name: options.clientName ?? 'static-private-key-jwt-client',
|
||||
redirect_uris: [],
|
||||
grant_types: ['client_credentials'],
|
||||
token_endpoint_auth_method: 'private_key_jwt'
|
||||
};
|
||||
|
||||
const assertion = options.jwtBearerAssertion;
|
||||
this.addClientAuthentication = async (_headers, params) => {
|
||||
params.set('client_assertion', assertion);
|
||||
params.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
|
||||
};
|
||||
}
|
||||
|
||||
get redirectUrl(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return this._clientMetadata;
|
||||
}
|
||||
|
||||
clientInformation(): OAuthClientInformation {
|
||||
return this._clientInfo;
|
||||
}
|
||||
|
||||
saveClientInformation(info: OAuthClientInformation): void {
|
||||
this._clientInfo = info;
|
||||
}
|
||||
|
||||
tokens(): OAuthTokens | undefined {
|
||||
return this._tokens;
|
||||
}
|
||||
|
||||
saveTokens(tokens: OAuthTokens): void {
|
||||
this._tokens = tokens;
|
||||
}
|
||||
|
||||
redirectToAuthorization(): void {
|
||||
throw new Error('redirectToAuthorization is not used for client_credentials flow');
|
||||
}
|
||||
|
||||
saveCodeVerifier(): void {
|
||||
// Not used for client_credentials
|
||||
}
|
||||
|
||||
codeVerifier(): string {
|
||||
throw new Error('codeVerifier is not used for client_credentials flow');
|
||||
}
|
||||
|
||||
prepareTokenRequest(scope?: string): URLSearchParams {
|
||||
const params = new URLSearchParams({ grant_type: 'client_credentials' });
|
||||
if (scope) params.set('scope', scope);
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
|
@ -358,7 +358,7 @@ export class Client<
|
|||
}
|
||||
|
||||
if (typeof methodValue !== 'string') {
|
||||
throw new Error('Schema method literal must be a string');
|
||||
throw new TypeError('Schema method literal must be a string');
|
||||
}
|
||||
const method = methodValue;
|
||||
if (method === 'elicitation/create') {
|
||||
|
|
@ -413,13 +413,17 @@ export class Client<
|
|||
const validatedResult = validationResult.data;
|
||||
const requestedSchema = params.mode === 'form' ? (params.requestedSchema as JsonSchemaType) : undefined;
|
||||
|
||||
if (params.mode === 'form' && validatedResult.action === 'accept' && validatedResult.content && requestedSchema) {
|
||||
if (this._capabilities.elicitation?.form?.applyDefaults) {
|
||||
try {
|
||||
applyElicitationDefaults(requestedSchema, validatedResult.content);
|
||||
} catch {
|
||||
// gracefully ignore errors in default application
|
||||
}
|
||||
if (
|
||||
params.mode === 'form' &&
|
||||
validatedResult.action === 'accept' &&
|
||||
validatedResult.content &&
|
||||
requestedSchema &&
|
||||
this._capabilities.elicitation?.form?.applyDefaults
|
||||
) {
|
||||
try {
|
||||
applyElicitationDefaults(requestedSchema, validatedResult.content);
|
||||
} catch {
|
||||
// gracefully ignore errors in default application
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -563,24 +567,26 @@ export class Client<
|
|||
|
||||
protected assertCapabilityForMethod(method: RequestT['method']): void {
|
||||
switch (method as ClientRequest['method']) {
|
||||
case 'logging/setLevel':
|
||||
case 'logging/setLevel': {
|
||||
if (!this._serverCapabilities?.logging) {
|
||||
throw new Error(`Server does not support logging (required for ${method})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'prompts/get':
|
||||
case 'prompts/list':
|
||||
case 'prompts/list': {
|
||||
if (!this._serverCapabilities?.prompts) {
|
||||
throw new Error(`Server does not support prompts (required for ${method})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'resources/list':
|
||||
case 'resources/templates/list':
|
||||
case 'resources/read':
|
||||
case 'resources/subscribe':
|
||||
case 'resources/unsubscribe':
|
||||
case 'resources/unsubscribe': {
|
||||
if (!this._serverCapabilities?.resources) {
|
||||
throw new Error(`Server does not support resources (required for ${method})`);
|
||||
}
|
||||
|
|
@ -590,49 +596,58 @@ export class Client<
|
|||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tools/call':
|
||||
case 'tools/list':
|
||||
case 'tools/list': {
|
||||
if (!this._serverCapabilities?.tools) {
|
||||
throw new Error(`Server does not support tools (required for ${method})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'completion/complete':
|
||||
case 'completion/complete': {
|
||||
if (!this._serverCapabilities?.completions) {
|
||||
throw new Error(`Server does not support completions (required for ${method})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'initialize':
|
||||
case 'initialize': {
|
||||
// No specific capability required for initialize
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ping':
|
||||
case 'ping': {
|
||||
// No specific capability required for ping
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected assertNotificationCapability(method: NotificationT['method']): void {
|
||||
switch (method as ClientNotification['method']) {
|
||||
case 'notifications/roots/list_changed':
|
||||
case 'notifications/roots/list_changed': {
|
||||
if (!this._capabilities.roots?.listChanged) {
|
||||
throw new Error(`Client does not support roots list changed notifications (required for ${method})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'notifications/initialized':
|
||||
case 'notifications/initialized': {
|
||||
// No specific capability required for initialized
|
||||
break;
|
||||
}
|
||||
|
||||
case 'notifications/cancelled':
|
||||
case 'notifications/cancelled': {
|
||||
// Cancellation notifications are always allowed
|
||||
break;
|
||||
}
|
||||
|
||||
case 'notifications/progress':
|
||||
case 'notifications/progress': {
|
||||
// Progress notifications are always allowed
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -644,36 +659,41 @@ export class Client<
|
|||
}
|
||||
|
||||
switch (method) {
|
||||
case 'sampling/createMessage':
|
||||
case 'sampling/createMessage': {
|
||||
if (!this._capabilities.sampling) {
|
||||
throw new Error(`Client does not support sampling capability (required for ${method})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'elicitation/create':
|
||||
case 'elicitation/create': {
|
||||
if (!this._capabilities.elicitation) {
|
||||
throw new Error(`Client does not support elicitation capability (required for ${method})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'roots/list':
|
||||
case 'roots/list': {
|
||||
if (!this._capabilities.roots) {
|
||||
throw new Error(`Client does not support roots capability (required for ${method})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tasks/get':
|
||||
case 'tasks/list':
|
||||
case 'tasks/result':
|
||||
case 'tasks/cancel':
|
||||
case 'tasks/cancel': {
|
||||
if (!this._capabilities.tasks) {
|
||||
throw new Error(`Client does not support tasks capability (required for ${method})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ping':
|
||||
case 'ping': {
|
||||
// No specific capability required for ping
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -866,7 +886,7 @@ export class Client<
|
|||
|
||||
// Validate callback
|
||||
if (typeof options.onChanged !== 'function') {
|
||||
throw new Error(`Invalid ${listType} listChanged options: onChanged must be a function`);
|
||||
throw new TypeError(`Invalid ${listType} listChanged options: onChanged must be a function`);
|
||||
}
|
||||
|
||||
const { autoRefresh, debounceMs } = parseResult.data;
|
||||
|
|
@ -881,9 +901,9 @@ export class Client<
|
|||
try {
|
||||
const items = await fetcher();
|
||||
onChanged(null, items);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
onChanged(error, null);
|
||||
} catch (error) {
|
||||
const newError = error instanceof Error ? error : new Error(String(error));
|
||||
onChanged(newError, null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -167,16 +167,12 @@ export const withLogging = (options: LoggingOptions = {}): Middleware => {
|
|||
|
||||
// Add headers to message if requested
|
||||
if (includeRequestHeaders && requestHeaders) {
|
||||
const reqHeaders = Array.from(requestHeaders.entries())
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(', ');
|
||||
const reqHeaders = [...requestHeaders.entries()].map(([key, value]) => `${key}: ${value}`).join(', ');
|
||||
message += `\n Request Headers: {${reqHeaders}}`;
|
||||
}
|
||||
|
||||
if (includeResponseHeaders && responseHeaders) {
|
||||
const resHeaders = Array.from(responseHeaders.entries())
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(', ');
|
||||
const resHeaders = [...responseHeaders.entries()].map(([key, value]) => `${key}: ${value}`).join(', ');
|
||||
message += `\n Response Headers: {${resHeaders}}`;
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +251,11 @@ export const withLogging = (options: LoggingOptions = {}): Middleware => {
|
|||
*/
|
||||
export const applyMiddlewares = (...middleware: Middleware[]): Middleware => {
|
||||
return next => {
|
||||
return middleware.reduce((handler, mw) => mw(handler), next);
|
||||
let handler = next;
|
||||
for (const mw of middleware) {
|
||||
handler = mw(handler);
|
||||
}
|
||||
return handler;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ export class StdioClientTransport implements Transport {
|
|||
// ignore
|
||||
}
|
||||
|
||||
await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2_000).unref())]);
|
||||
await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2000).unref())]);
|
||||
|
||||
if (processToClose.exitCode === null) {
|
||||
try {
|
||||
|
|
@ -228,7 +228,7 @@ export class StdioClientTransport implements Transport {
|
|||
// ignore
|
||||
}
|
||||
|
||||
await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2_000).unref())]);
|
||||
await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2000).unref())]);
|
||||
}
|
||||
|
||||
if (processToClose.exitCode === null) {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js
|
|||
// Default reconnection options for StreamableHTTP connections
|
||||
const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = {
|
||||
initialReconnectionDelay: 1000,
|
||||
maxReconnectionDelay: 30000,
|
||||
maxReconnectionDelay: 30_000,
|
||||
reconnectionDelayGrowFactor: 1.5,
|
||||
maxRetries: 2
|
||||
};
|
||||
|
|
@ -468,8 +468,8 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
|
||||
if (resumptionToken) {
|
||||
// If we have at last event ID, we need to reconnect the SSE stream
|
||||
this._startOrAuthSse({ resumptionToken, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch(err =>
|
||||
this.onerror?.(err)
|
||||
this._startOrAuthSse({ resumptionToken, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch(
|
||||
error => this.onerror?.(error)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -573,7 +573,7 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
// if it's supported by the server
|
||||
if (isInitializedNotification(message)) {
|
||||
// Start without a lastEventId since this is a fresh connection
|
||||
this._startOrAuthSse({ resumptionToken: undefined }).catch(err => this.onerror?.(err));
|
||||
this._startOrAuthSse({ resumptionToken: undefined }).catch(error => this.onerror?.(error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -581,7 +581,7 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
// Get original message(s) for detecting request IDs
|
||||
const messages = Array.isArray(message) ? message : [message];
|
||||
|
||||
const hasRequests = messages.filter(msg => 'method' in msg && 'id' in msg && msg.id !== undefined).length > 0;
|
||||
const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg && msg.id !== undefined);
|
||||
|
||||
// Check the response type
|
||||
const contentType = response.headers.get('content-type');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export * from './client/auth.js';
|
||||
export * from './client/auth-extensions.js';
|
||||
export * from './client/authExtensions.js';
|
||||
export * from './client/client.js';
|
||||
export * from './client/middleware.js';
|
||||
export * from './client/sse.js';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
createPrivateKeyJwtAuth,
|
||||
PrivateKeyJwtProvider,
|
||||
StaticPrivateKeyJwtProvider
|
||||
} from '../../src/client/auth-extensions.js';
|
||||
} from '../../src/client/authExtensions.js';
|
||||
|
||||
const RESOURCE_SERVER_URL = 'https://resource.example.com/';
|
||||
const AUTH_SERVER_URL = 'https://auth.example.com';
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
selectClientAuthMethod,
|
||||
startAuthorization
|
||||
} from '../../src/client/auth.js';
|
||||
import { createPrivateKeyJwtAuth } from '../../src/client/auth-extensions.js';
|
||||
import { createPrivateKeyJwtAuth } from '../../src/client/authExtensions.js';
|
||||
|
||||
// Mock pkce-challenge
|
||||
vi.mock('pkce-challenge', () => ({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export * from './tasks/helpers.js';
|
||||
export * from './tasks/interfaces.js';
|
||||
export * from './tasks/stores/in-memory.js';
|
||||
export * from './tasks/stores/inMemory.js';
|
||||
|
|
|
|||
|
|
@ -36,15 +36,17 @@ export function assertToolsCallTaskCapability(
|
|||
}
|
||||
|
||||
switch (method) {
|
||||
case 'tools/call':
|
||||
case 'tools/call': {
|
||||
if (!requests.tools?.call) {
|
||||
throw new Error(`${entityName} does not support task creation for tools/call (required for ${method})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
default: {
|
||||
// Method doesn't support tasks, which is fine - no error
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,20 +71,23 @@ export function assertClientRequestTaskCapability(
|
|||
}
|
||||
|
||||
switch (method) {
|
||||
case 'sampling/createMessage':
|
||||
case 'sampling/createMessage': {
|
||||
if (!requests.sampling?.createMessage) {
|
||||
throw new Error(`${entityName} does not support task creation for sampling/createMessage (required for ${method})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'elicitation/create':
|
||||
case 'elicitation/create': {
|
||||
if (!requests.elicitation?.create) {
|
||||
throw new Error(`${entityName} does not support task creation for elicitation/create (required for ${method})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
default: {
|
||||
// Method doesn't support tasks, which is fine - no error
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
297
packages/core/src/experimental/tasks/stores/inMemory.ts
Normal file
297
packages/core/src/experimental/tasks/stores/inMemory.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
/**
|
||||
* In-memory implementations of TaskStore and TaskMessageQueue.
|
||||
* WARNING: These APIs are experimental and may change without notice.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import type { Request, RequestId, Result, Task } from '../../../types/types.js';
|
||||
import type { CreateTaskOptions, QueuedMessage, TaskMessageQueue, TaskStore } from '../interfaces.js';
|
||||
import { isTerminal } from '../interfaces.js';
|
||||
|
||||
interface StoredTask {
|
||||
task: Task;
|
||||
request: Request;
|
||||
requestId: RequestId;
|
||||
result?: Result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple in-memory implementation of TaskStore for demonstration purposes.
|
||||
*
|
||||
* This implementation stores all tasks in memory and provides automatic cleanup
|
||||
* based on the ttl duration specified in the task creation parameters.
|
||||
*
|
||||
* Note: This is not suitable for production use as all data is lost on restart.
|
||||
* For production, consider implementing TaskStore with a database or distributed cache.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export class InMemoryTaskStore implements TaskStore {
|
||||
private tasks = new Map<string, StoredTask>();
|
||||
private cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
/**
|
||||
* Generates a unique task ID.
|
||||
* Uses 16 bytes of random data encoded as hex (32 characters).
|
||||
*/
|
||||
private generateTaskId(): string {
|
||||
return randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
async createTask(taskParams: CreateTaskOptions, requestId: RequestId, request: Request, _sessionId?: string): Promise<Task> {
|
||||
// Generate a unique task ID
|
||||
const taskId = this.generateTaskId();
|
||||
|
||||
// Ensure uniqueness
|
||||
if (this.tasks.has(taskId)) {
|
||||
throw new Error(`Task with ID ${taskId} already exists`);
|
||||
}
|
||||
|
||||
const actualTtl = taskParams.ttl ?? null;
|
||||
|
||||
// Create task with generated ID and timestamps
|
||||
const createdAt = new Date().toISOString();
|
||||
const task: Task = {
|
||||
taskId,
|
||||
status: 'working',
|
||||
ttl: actualTtl,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt,
|
||||
pollInterval: taskParams.pollInterval ?? 1000
|
||||
};
|
||||
|
||||
this.tasks.set(taskId, {
|
||||
task,
|
||||
request,
|
||||
requestId
|
||||
});
|
||||
|
||||
// Schedule cleanup if ttl is specified
|
||||
// Cleanup occurs regardless of task status
|
||||
if (actualTtl) {
|
||||
const timer = setTimeout(() => {
|
||||
this.tasks.delete(taskId);
|
||||
this.cleanupTimers.delete(taskId);
|
||||
}, actualTtl);
|
||||
|
||||
this.cleanupTimers.set(taskId, timer);
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
async getTask(taskId: string, _sessionId?: string): Promise<Task | null> {
|
||||
const stored = this.tasks.get(taskId);
|
||||
return stored ? { ...stored.task } : null;
|
||||
}
|
||||
|
||||
async storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, _sessionId?: string): Promise<void> {
|
||||
const stored = this.tasks.get(taskId);
|
||||
if (!stored) {
|
||||
throw new Error(`Task with ID ${taskId} not found`);
|
||||
}
|
||||
|
||||
// Don't allow storing results for tasks already in terminal state
|
||||
if (isTerminal(stored.task.status)) {
|
||||
throw new Error(
|
||||
`Cannot store result for task ${taskId} in terminal status '${stored.task.status}'. Task results can only be stored once.`
|
||||
);
|
||||
}
|
||||
|
||||
stored.result = result;
|
||||
stored.task.status = status;
|
||||
stored.task.lastUpdatedAt = new Date().toISOString();
|
||||
|
||||
// Reset cleanup timer to start from now (if ttl is set)
|
||||
if (stored.task.ttl) {
|
||||
const existingTimer = this.cleanupTimers.get(taskId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.tasks.delete(taskId);
|
||||
this.cleanupTimers.delete(taskId);
|
||||
}, stored.task.ttl);
|
||||
|
||||
this.cleanupTimers.set(taskId, timer);
|
||||
}
|
||||
}
|
||||
|
||||
async getTaskResult(taskId: string, _sessionId?: string): Promise<Result> {
|
||||
const stored = this.tasks.get(taskId);
|
||||
if (!stored) {
|
||||
throw new Error(`Task with ID ${taskId} not found`);
|
||||
}
|
||||
|
||||
if (!stored.result) {
|
||||
throw new Error(`Task ${taskId} has no result stored`);
|
||||
}
|
||||
|
||||
return stored.result;
|
||||
}
|
||||
|
||||
async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, _sessionId?: string): Promise<void> {
|
||||
const stored = this.tasks.get(taskId);
|
||||
if (!stored) {
|
||||
throw new Error(`Task with ID ${taskId} not found`);
|
||||
}
|
||||
|
||||
// Don't allow transitions from terminal states
|
||||
if (isTerminal(stored.task.status)) {
|
||||
throw new Error(
|
||||
`Cannot update task ${taskId} from terminal status '${stored.task.status}' to '${status}'. Terminal states (completed, failed, cancelled) cannot transition to other states.`
|
||||
);
|
||||
}
|
||||
|
||||
stored.task.status = status;
|
||||
if (statusMessage) {
|
||||
stored.task.statusMessage = statusMessage;
|
||||
}
|
||||
|
||||
stored.task.lastUpdatedAt = new Date().toISOString();
|
||||
|
||||
// If task is in a terminal state and has ttl, start cleanup timer
|
||||
if (isTerminal(status) && stored.task.ttl) {
|
||||
const existingTimer = this.cleanupTimers.get(taskId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.tasks.delete(taskId);
|
||||
this.cleanupTimers.delete(taskId);
|
||||
}, stored.task.ttl);
|
||||
|
||||
this.cleanupTimers.set(taskId, timer);
|
||||
}
|
||||
}
|
||||
|
||||
async listTasks(cursor?: string, _sessionId?: string): Promise<{ tasks: Task[]; nextCursor?: string }> {
|
||||
const PAGE_SIZE = 10;
|
||||
const allTaskIds = [...this.tasks.keys()];
|
||||
|
||||
let startIndex = 0;
|
||||
if (cursor) {
|
||||
const cursorIndex = allTaskIds.indexOf(cursor);
|
||||
if (cursorIndex === -1) {
|
||||
// Invalid cursor - throw error
|
||||
throw new Error(`Invalid cursor: ${cursor}`);
|
||||
} else {
|
||||
startIndex = cursorIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const pageTaskIds = allTaskIds.slice(startIndex, startIndex + PAGE_SIZE);
|
||||
const tasks = pageTaskIds.map(taskId => {
|
||||
const stored = this.tasks.get(taskId)!;
|
||||
return { ...stored.task };
|
||||
});
|
||||
|
||||
const nextCursor = startIndex + PAGE_SIZE < allTaskIds.length ? pageTaskIds.at(-1) : undefined;
|
||||
|
||||
return { tasks, nextCursor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all timers (useful for testing or graceful shutdown)
|
||||
*/
|
||||
cleanup(): void {
|
||||
for (const timer of this.cleanupTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.cleanupTimers.clear();
|
||||
this.tasks.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tasks (useful for debugging)
|
||||
*/
|
||||
getAllTasks(): Task[] {
|
||||
return [...this.tasks.values()].map(stored => ({ ...stored.task }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple in-memory implementation of TaskMessageQueue for demonstration purposes.
|
||||
*
|
||||
* This implementation stores messages in memory, organized by task ID and optional session ID.
|
||||
* Messages are stored in FIFO queues per task.
|
||||
*
|
||||
* Note: This is not suitable for production use in distributed systems.
|
||||
* For production, consider implementing TaskMessageQueue with Redis or other distributed queues.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export class InMemoryTaskMessageQueue implements TaskMessageQueue {
|
||||
private queues = new Map<string, QueuedMessage[]>();
|
||||
|
||||
/**
|
||||
* Generates a queue key from taskId.
|
||||
* SessionId is intentionally ignored because taskIds are globally unique
|
||||
* and tasks need to be accessible across HTTP requests/sessions.
|
||||
*/
|
||||
private getQueueKey(taskId: string, _sessionId?: string): string {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates a queue for the given task and session.
|
||||
*/
|
||||
private getQueue(taskId: string, sessionId?: string): QueuedMessage[] {
|
||||
const key = this.getQueueKey(taskId, sessionId);
|
||||
let queue = this.queues.get(key);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
this.queues.set(key, queue);
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a message to the end of the queue for a specific task.
|
||||
* Atomically checks queue size and throws if maxSize would be exceeded.
|
||||
* @param taskId The task identifier
|
||||
* @param message The message to enqueue
|
||||
* @param sessionId Optional session ID for binding the operation to a specific session
|
||||
* @param maxSize Optional maximum queue size - if specified and queue is full, throws an error
|
||||
* @throws Error if maxSize is specified and would be exceeded
|
||||
*/
|
||||
async enqueue(taskId: string, message: QueuedMessage, sessionId?: string, maxSize?: number): Promise<void> {
|
||||
const queue = this.getQueue(taskId, sessionId);
|
||||
|
||||
// Atomically check size and enqueue
|
||||
if (maxSize !== undefined && queue.length >= maxSize) {
|
||||
throw new Error(`Task message queue overflow: queue size (${queue.length}) exceeds maximum (${maxSize})`);
|
||||
}
|
||||
|
||||
queue.push(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes and returns the first message from the queue for a specific task.
|
||||
* @param taskId The task identifier
|
||||
* @param sessionId Optional session ID for binding the query to a specific session
|
||||
* @returns The first message, or undefined if the queue is empty
|
||||
*/
|
||||
async dequeue(taskId: string, sessionId?: string): Promise<QueuedMessage | undefined> {
|
||||
const queue = this.getQueue(taskId, sessionId);
|
||||
return queue.shift();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes and returns all messages from the queue for a specific task.
|
||||
* @param taskId The task identifier
|
||||
* @param sessionId Optional session ID for binding the query to a specific session
|
||||
* @returns Array of all messages that were in the queue
|
||||
*/
|
||||
async dequeueAll(taskId: string, sessionId?: string): Promise<QueuedMessage[]> {
|
||||
const key = this.getQueueKey(taskId, sessionId);
|
||||
const queue = this.queues.get(key) ?? [];
|
||||
this.queues.delete(key);
|
||||
return queue;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
export * from './auth/errors.js';
|
||||
export * from './shared/auth.js';
|
||||
export * from './shared/auth-utils.js';
|
||||
export * from './shared/authUtils.js';
|
||||
export * from './shared/metadataUtils.js';
|
||||
export * from './shared/protocol.js';
|
||||
export * from './shared/responseMessage.js';
|
||||
|
|
@ -10,13 +10,13 @@ export * from './shared/transport.js';
|
|||
export * from './shared/uriTemplate.js';
|
||||
export * from './types/types.js';
|
||||
export * from './util/inMemory.js';
|
||||
export * from './util/zod-compat.js';
|
||||
export * from './util/zod-json-schema-compat.js';
|
||||
export * from './util/zodCompat.js';
|
||||
export * from './util/zodJsonSchemaCompat.js';
|
||||
|
||||
// experimental exports
|
||||
export * from './experimental/index.js';
|
||||
export * from './validation/ajv-provider.js';
|
||||
export * from './validation/cfworker-provider.js';
|
||||
export * from './validation/ajvProvider.js';
|
||||
export * from './validation/cfWorkerProvider.js';
|
||||
/**
|
||||
* JSON Schema validation
|
||||
*
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ export const OAuthErrorResponseSchema = z.object({
|
|||
/**
|
||||
* Optional version of SafeUrlSchema that allows empty string for retrocompatibility on tos_uri and logo_uri
|
||||
*/
|
||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||
export const OptionalSafeUrlSchema = SafeUrlSchema.optional().or(z.literal('').transform(() => undefined));
|
||||
|
||||
/**
|
||||
|
|
|
|||
55
packages/core/src/shared/authUtils.ts
Normal file
55
packages/core/src/shared/authUtils.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Utilities for handling OAuth resource URIs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converts a server URL to a resource URL by removing the fragment.
|
||||
* RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component".
|
||||
* Keeps everything else unchanged (scheme, domain, port, path, query).
|
||||
*/
|
||||
export function resourceUrlFromServerUrl(url: URL | string): URL {
|
||||
const resourceURL = typeof url === 'string' ? new URL(url) : new URL(url.href);
|
||||
resourceURL.hash = ''; // Remove fragment
|
||||
return resourceURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a requested resource URL matches a configured resource URL.
|
||||
* A requested resource matches if it has the same scheme, domain, port,
|
||||
* and its path starts with the configured resource's path.
|
||||
*
|
||||
* @param requestedResource The resource URL being requested
|
||||
* @param configuredResource The resource URL that has been configured
|
||||
* @returns true if the requested resource matches the configured resource, false otherwise
|
||||
*/
|
||||
export function checkResourceAllowed({
|
||||
requestedResource,
|
||||
configuredResource
|
||||
}: {
|
||||
requestedResource: URL | string;
|
||||
configuredResource: URL | string;
|
||||
}): boolean {
|
||||
const requested = typeof requestedResource === 'string' ? new URL(requestedResource) : new URL(requestedResource.href);
|
||||
const configured = typeof configuredResource === 'string' ? new URL(configuredResource) : new URL(configuredResource.href);
|
||||
|
||||
// Compare the origin (scheme, domain, and port)
|
||||
if (requested.origin !== configured.origin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle cases like requested=/foo and configured=/foo/
|
||||
if (requested.pathname.length < configured.pathname.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the requested path starts with the configured path
|
||||
// Ensure both paths end with / for proper comparison
|
||||
// This ensures that if we have paths like "/api" and "/api/users",
|
||||
// we properly detect that "/api/users" is a subpath of "/api"
|
||||
// By adding a trailing slash if missing, we avoid false positives
|
||||
// where paths like "/api123" would incorrectly match "/api"
|
||||
const requestedPath = requested.pathname.endsWith('/') ? requested.pathname : requested.pathname + '/';
|
||||
const configuredPath = configured.pathname.endsWith('/') ? configured.pathname : configured.pathname + '/';
|
||||
|
||||
return requestedPath.startsWith(configuredPath);
|
||||
}
|
||||
|
|
@ -49,9 +49,9 @@ import {
|
|||
RELATED_TASK_META_KEY,
|
||||
TaskStatusNotificationSchema
|
||||
} from '../types/types.js';
|
||||
import type { AnyObjectSchema, AnySchema, SchemaOutput } from '../util/zod-compat.js';
|
||||
import { safeParse } from '../util/zod-compat.js';
|
||||
import { getMethodLiteral, parseWithCompat } from '../util/zod-json-schema-compat.js';
|
||||
import type { AnyObjectSchema, AnySchema, SchemaOutput } from '../util/zodCompat.js';
|
||||
import { safeParse } from '../util/zodCompat.js';
|
||||
import { getMethodLiteral, parseWithCompat } from '../util/zodJsonSchemaCompat.js';
|
||||
import type { ResponseMessage } from './responseMessage.js';
|
||||
import type { Transport, TransportSendOptions } from './transport.js';
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ export type ProtocolOptions = {
|
|||
/**
|
||||
* The default request timeout, in miliseconds.
|
||||
*/
|
||||
export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60000;
|
||||
export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60_000;
|
||||
|
||||
/**
|
||||
* Options that can be given per request.
|
||||
|
|
@ -780,19 +780,17 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
|
|||
};
|
||||
|
||||
// Queue or send the response based on whether this is a task-related request
|
||||
if (relatedTaskId && this._taskMessageQueue) {
|
||||
await this._enqueueTaskMessage(
|
||||
relatedTaskId,
|
||||
{
|
||||
type: 'response',
|
||||
message: response,
|
||||
timestamp: Date.now()
|
||||
},
|
||||
capturedTransport?.sessionId
|
||||
);
|
||||
} else {
|
||||
await capturedTransport?.send(response);
|
||||
}
|
||||
await (relatedTaskId && this._taskMessageQueue
|
||||
? this._enqueueTaskMessage(
|
||||
relatedTaskId,
|
||||
{
|
||||
type: 'response',
|
||||
message: response,
|
||||
timestamp: Date.now()
|
||||
},
|
||||
capturedTransport?.sessionId
|
||||
)
|
||||
: capturedTransport?.send(response));
|
||||
},
|
||||
async error => {
|
||||
if (abortController.signal.aborted) {
|
||||
|
|
@ -811,19 +809,17 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
|
|||
};
|
||||
|
||||
// Queue or send the error response based on whether this is a task-related request
|
||||
if (relatedTaskId && this._taskMessageQueue) {
|
||||
await this._enqueueTaskMessage(
|
||||
relatedTaskId,
|
||||
{
|
||||
type: 'error',
|
||||
message: errorResponse,
|
||||
timestamp: Date.now()
|
||||
},
|
||||
capturedTransport?.sessionId
|
||||
);
|
||||
} else {
|
||||
await capturedTransport?.send(errorResponse);
|
||||
}
|
||||
await (relatedTaskId && this._taskMessageQueue
|
||||
? this._enqueueTaskMessage(
|
||||
relatedTaskId,
|
||||
{
|
||||
type: 'error',
|
||||
message: errorResponse,
|
||||
timestamp: Date.now()
|
||||
},
|
||||
capturedTransport?.sessionId
|
||||
)
|
||||
: capturedTransport?.send(errorResponse));
|
||||
}
|
||||
)
|
||||
.catch(error => this._onerror(new Error(`Failed to send response: ${error}`)))
|
||||
|
|
@ -1028,20 +1024,31 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
|
|||
|
||||
// Check if task is terminal
|
||||
if (isTerminal(task.status)) {
|
||||
if (task.status === 'completed') {
|
||||
// Get the final result
|
||||
const result = await this.getTaskResult({ taskId }, resultSchema, options);
|
||||
yield { type: 'result', result };
|
||||
} else if (task.status === 'failed') {
|
||||
yield {
|
||||
type: 'error',
|
||||
error: new McpError(ErrorCode.InternalError, `Task ${taskId} failed`)
|
||||
};
|
||||
} else if (task.status === 'cancelled') {
|
||||
yield {
|
||||
type: 'error',
|
||||
error: new McpError(ErrorCode.InternalError, `Task ${taskId} was cancelled`)
|
||||
};
|
||||
switch (task.status) {
|
||||
case 'completed': {
|
||||
// Get the final result
|
||||
const result = await this.getTaskResult({ taskId }, resultSchema, options);
|
||||
yield { type: 'result', result };
|
||||
|
||||
break;
|
||||
}
|
||||
case 'failed': {
|
||||
yield {
|
||||
type: 'error',
|
||||
error: new McpError(ErrorCode.InternalError, `Task ${taskId} failed`)
|
||||
};
|
||||
|
||||
break;
|
||||
}
|
||||
case 'cancelled': {
|
||||
yield {
|
||||
type: 'error',
|
||||
error: new McpError(ErrorCode.InternalError, `Task ${taskId} was cancelled`)
|
||||
};
|
||||
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -1096,8 +1103,8 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
|
|||
if (task) {
|
||||
this.assertTaskCapability(request.method);
|
||||
}
|
||||
} catch (e) {
|
||||
earlyReject(e);
|
||||
} catch (error) {
|
||||
earlyReject(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -1116,7 +1123,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
|
|||
jsonrpcRequest.params = {
|
||||
...request.params,
|
||||
_meta: {
|
||||
...(request.params?._meta || {}),
|
||||
...request.params?._meta,
|
||||
progressToken: messageId
|
||||
}
|
||||
};
|
||||
|
|
@ -1135,7 +1142,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
|
|||
jsonrpcRequest.params = {
|
||||
...jsonrpcRequest.params,
|
||||
_meta: {
|
||||
...(jsonrpcRequest.params?._meta || {}),
|
||||
...jsonrpcRequest.params?._meta,
|
||||
[RELATED_TASK_META_KEY]: relatedTask
|
||||
}
|
||||
};
|
||||
|
|
@ -1176,11 +1183,11 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
|
|||
|
||||
try {
|
||||
const parseResult = safeParse(resultSchema, response.result);
|
||||
if (!parseResult.success) {
|
||||
if (parseResult.success) {
|
||||
resolve(parseResult.data as SchemaOutput<T>);
|
||||
} else {
|
||||
// Type guard: if success is false, error is guaranteed to exist
|
||||
reject(parseResult.error);
|
||||
} else {
|
||||
resolve(parseResult.data as SchemaOutput<T>);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
|
|
@ -1296,7 +1303,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
|
|||
params: {
|
||||
...notification.params,
|
||||
_meta: {
|
||||
...(notification.params?._meta || {}),
|
||||
...notification.params?._meta,
|
||||
[RELATED_TASK_META_KEY]: options.relatedTask
|
||||
}
|
||||
}
|
||||
|
|
@ -1351,7 +1358,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
|
|||
params: {
|
||||
...jsonrpcNotification.params,
|
||||
_meta: {
|
||||
...(jsonrpcNotification.params?._meta || {}),
|
||||
...jsonrpcNotification.params?._meta,
|
||||
[RELATED_TASK_META_KEY]: options.relatedTask
|
||||
}
|
||||
}
|
||||
|
|
@ -1379,7 +1386,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
|
|||
params: {
|
||||
...jsonrpcNotification.params,
|
||||
_meta: {
|
||||
...(jsonrpcNotification.params?._meta || {}),
|
||||
...jsonrpcNotification.params?._meta,
|
||||
[RELATED_TASK_META_KEY]: options.relatedTask
|
||||
}
|
||||
}
|
||||
|
|
@ -1651,11 +1658,10 @@ export function mergeCapabilities<T extends ServerCapabilities | ClientCapabilit
|
|||
const addValue = additional[k];
|
||||
if (addValue === undefined) continue;
|
||||
const baseValue = result[k];
|
||||
if (isPlainObject(baseValue) && isPlainObject(addValue)) {
|
||||
result[k] = { ...(baseValue as Record<string, unknown>), ...(addValue as Record<string, unknown>) } as T[typeof k];
|
||||
} else {
|
||||
result[k] = addValue as T[typeof k];
|
||||
}
|
||||
result[k] =
|
||||
isPlainObject(baseValue) && isPlainObject(addValue)
|
||||
? ({ ...(baseValue as Record<string, unknown>), ...(addValue as Record<string, unknown>) } as T[typeof k])
|
||||
: (addValue as T[typeof k]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,8 +59,7 @@ export function validateToolName(name: string): {
|
|||
|
||||
// Check for invalid characters
|
||||
if (!TOOL_NAME_REGEX.test(name)) {
|
||||
const invalidChars = name
|
||||
.split('')
|
||||
const invalidChars = [...name]
|
||||
.filter(char => !/[A-Za-z0-9._-]/.test(char))
|
||||
.filter((char, index, arr) => arr.indexOf(char) === index); // Remove duplicates
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
export type Variables = Record<string, string | string[]>;
|
||||
|
||||
const MAX_TEMPLATE_LENGTH = 1000000; // 1MB
|
||||
const MAX_VARIABLE_LENGTH = 1000000; // 1MB
|
||||
const MAX_TEMPLATE_EXPRESSIONS = 10000;
|
||||
const MAX_REGEX_LENGTH = 1000000; // 1MB
|
||||
const MAX_TEMPLATE_LENGTH = 1_000_000; // 1MB
|
||||
const MAX_VARIABLE_LENGTH = 1_000_000; // 1MB
|
||||
const MAX_TEMPLATE_EXPRESSIONS = 10_000;
|
||||
const MAX_REGEX_LENGTH = 1_000_000; // 1MB
|
||||
|
||||
export class UriTemplate {
|
||||
/**
|
||||
|
|
@ -148,18 +148,24 @@ export class UriTemplate {
|
|||
const encoded = values.map(v => this.encodeValue(v, part.operator));
|
||||
|
||||
switch (part.operator) {
|
||||
case '':
|
||||
case '': {
|
||||
return encoded.join(',');
|
||||
case '+':
|
||||
}
|
||||
case '+': {
|
||||
return encoded.join(',');
|
||||
case '#':
|
||||
}
|
||||
case '#': {
|
||||
return '#' + encoded.join(',');
|
||||
case '.':
|
||||
}
|
||||
case '.': {
|
||||
return '.' + encoded.join('.');
|
||||
case '/':
|
||||
}
|
||||
case '/': {
|
||||
return '/' + encoded.join('/');
|
||||
default:
|
||||
}
|
||||
default: {
|
||||
return encoded.join(',');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -177,11 +183,7 @@ export class UriTemplate {
|
|||
if (!expanded) continue;
|
||||
|
||||
// Convert ? to & if we already have a query parameter
|
||||
if ((part.operator === '?' || part.operator === '&') && hasQueryParam) {
|
||||
result += expanded.replace('?', '&');
|
||||
} else {
|
||||
result += expanded;
|
||||
}
|
||||
result += (part.operator === '?' || part.operator === '&') && hasQueryParam ? expanded.replace('?', '&') : expanded;
|
||||
|
||||
if (part.operator === '?' || part.operator === '&') {
|
||||
hasQueryParam = true;
|
||||
|
|
@ -192,7 +194,7 @@ export class UriTemplate {
|
|||
}
|
||||
|
||||
private escapeRegExp(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return str.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
||||
}
|
||||
|
||||
private partToRegExp(part: {
|
||||
|
|
@ -224,21 +226,26 @@ export class UriTemplate {
|
|||
const name = part.name;
|
||||
|
||||
switch (part.operator) {
|
||||
case '':
|
||||
case '': {
|
||||
pattern = part.exploded ? '([^/,]+(?:,[^/,]+)*)' : '([^/,]+)';
|
||||
break;
|
||||
}
|
||||
case '+':
|
||||
case '#':
|
||||
case '#': {
|
||||
pattern = '(.+)';
|
||||
break;
|
||||
case '.':
|
||||
pattern = '\\.([^/,]+)';
|
||||
}
|
||||
case '.': {
|
||||
pattern = String.raw`\.([^/,]+)`;
|
||||
break;
|
||||
case '/':
|
||||
}
|
||||
case '/': {
|
||||
pattern = '/' + (part.exploded ? '([^/,]+(?:,[^/,]+)*)' : '([^/,]+)');
|
||||
break;
|
||||
default:
|
||||
}
|
||||
default: {
|
||||
pattern = '([^/]+)';
|
||||
}
|
||||
}
|
||||
|
||||
patterns.push({ pattern, name });
|
||||
|
|
@ -270,16 +277,12 @@ export class UriTemplate {
|
|||
if (!match) return null;
|
||||
|
||||
const result: Variables = {};
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const { name, exploded } = names[i]!;
|
||||
for (const [i, name_] of names.entries()) {
|
||||
const { name, exploded } = name_!;
|
||||
const value = match[i + 1]!;
|
||||
const cleanName = name.replace('*', '');
|
||||
|
||||
if (exploded && value.includes(',')) {
|
||||
result[cleanName] = value.split(',');
|
||||
} else {
|
||||
result[cleanName] = value;
|
||||
}
|
||||
result[cleanName] = exploded && value.includes(',') ? value.split(',') : value;
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -227,18 +227,18 @@ export const isJSONRPCResponse = isJSONRPCResultResponse;
|
|||
*/
|
||||
export enum ErrorCode {
|
||||
// SDK error codes
|
||||
ConnectionClosed = -32000,
|
||||
RequestTimeout = -32001,
|
||||
ConnectionClosed = -32_000,
|
||||
RequestTimeout = -32_001,
|
||||
|
||||
// Standard JSON-RPC error codes
|
||||
ParseError = -32700,
|
||||
InvalidRequest = -32600,
|
||||
MethodNotFound = -32601,
|
||||
InvalidParams = -32602,
|
||||
InternalError = -32603,
|
||||
ParseError = -32_700,
|
||||
InvalidRequest = -32_600,
|
||||
MethodNotFound = -32_601,
|
||||
InvalidParams = -32_602,
|
||||
InternalError = -32_603,
|
||||
|
||||
// MCP-specific error codes
|
||||
UrlElicitationRequired = -32042
|
||||
UrlElicitationRequired = -32_042
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -424,10 +424,8 @@ const FormElicitationCapabilitySchema = z.intersection(
|
|||
|
||||
const ElicitationCapabilitySchema = z.preprocess(
|
||||
value => {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
if (Object.keys(value as Record<string, unknown>).length === 0) {
|
||||
return { form: {} };
|
||||
}
|
||||
if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length === 0) {
|
||||
return { form: {} };
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
|
|
|||
275
packages/core/src/util/zodCompat.ts
Normal file
275
packages/core/src/util/zodCompat.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
// zod-compat.ts
|
||||
// ----------------------------------------------------
|
||||
// Unified types + helpers to accept Zod v3 and v4 (Mini)
|
||||
// ----------------------------------------------------
|
||||
|
||||
import type * as z3 from 'zod/v3';
|
||||
import * as z3rt from 'zod/v3';
|
||||
import type * as z4 from 'zod/v4/core';
|
||||
import * as z4mini from 'zod/v4-mini';
|
||||
|
||||
// --- Unified schema types ---
|
||||
export type AnySchema = z3.ZodTypeAny | z4.$ZodType;
|
||||
export type AnyObjectSchema = z3.AnyZodObject | z4.$ZodObject | AnySchema;
|
||||
export type ZodRawShapeCompat = Record<string, AnySchema>;
|
||||
|
||||
// --- Internal property access helpers ---
|
||||
// These types help us safely access internal properties that differ between v3 and v4
|
||||
export interface ZodV3Internal {
|
||||
_def?: {
|
||||
typeName?: string;
|
||||
value?: unknown;
|
||||
values?: unknown[];
|
||||
shape?: Record<string, AnySchema> | (() => Record<string, AnySchema>);
|
||||
description?: string;
|
||||
};
|
||||
shape?: Record<string, AnySchema> | (() => Record<string, AnySchema>);
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export interface ZodV4Internal {
|
||||
_zod?: {
|
||||
def?: {
|
||||
type?: string;
|
||||
value?: unknown;
|
||||
values?: unknown[];
|
||||
shape?: Record<string, AnySchema> | (() => Record<string, AnySchema>);
|
||||
};
|
||||
};
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
// --- Type inference helpers ---
|
||||
export type SchemaOutput<S> = S extends z3.ZodTypeAny ? z3.infer<S> : S extends z4.$ZodType ? z4.output<S> : never;
|
||||
|
||||
export type SchemaInput<S> = S extends z3.ZodTypeAny ? z3.input<S> : S extends z4.$ZodType ? z4.input<S> : never;
|
||||
|
||||
/**
|
||||
* Infers the output type from a ZodRawShapeCompat (raw shape object).
|
||||
* Maps over each key in the shape and infers the output type from each schema.
|
||||
*/
|
||||
export type ShapeOutput<Shape extends ZodRawShapeCompat> = {
|
||||
[K in keyof Shape]: SchemaOutput<Shape[K]>;
|
||||
};
|
||||
|
||||
// --- Runtime detection ---
|
||||
export function isZ4Schema(s: AnySchema): s is z4.$ZodType {
|
||||
// Present on Zod 4 (Classic & Mini) schemas; absent on Zod 3
|
||||
const schema = s as unknown as ZodV4Internal;
|
||||
return !!schema._zod;
|
||||
}
|
||||
|
||||
// --- Schema construction ---
|
||||
export function objectFromShape(shape: ZodRawShapeCompat): AnyObjectSchema {
|
||||
const values = Object.values(shape);
|
||||
if (values.length === 0) return z4mini.object({}); // default to v4 Mini
|
||||
|
||||
const allV4 = values.every(element => isZ4Schema(element));
|
||||
const allV3 = values.every(s => !isZ4Schema(s));
|
||||
|
||||
if (allV4) return z4mini.object(shape as Record<string, z4.$ZodType>);
|
||||
if (allV3) return z3rt.object(shape as Record<string, z3.ZodTypeAny>);
|
||||
|
||||
throw new Error('Mixed Zod versions detected in object shape.');
|
||||
}
|
||||
|
||||
// --- Unified parsing ---
|
||||
export function safeParse<S extends AnySchema>(
|
||||
schema: S,
|
||||
data: unknown
|
||||
): { success: true; data: SchemaOutput<S> } | { success: false; error: unknown } {
|
||||
if (isZ4Schema(schema)) {
|
||||
// Mini exposes top-level safeParse
|
||||
const result = z4mini.safeParse(schema, data);
|
||||
return result as { success: true; data: SchemaOutput<S> } | { success: false; error: unknown };
|
||||
}
|
||||
const v3Schema = schema as z3.ZodTypeAny;
|
||||
const result = v3Schema.safeParse(data);
|
||||
return result as { success: true; data: SchemaOutput<S> } | { success: false; error: unknown };
|
||||
}
|
||||
|
||||
export async function safeParseAsync<S extends AnySchema>(
|
||||
schema: S,
|
||||
data: unknown
|
||||
): Promise<{ success: true; data: SchemaOutput<S> } | { success: false; error: unknown }> {
|
||||
if (isZ4Schema(schema)) {
|
||||
// Mini exposes top-level safeParseAsync
|
||||
const result = await z4mini.safeParseAsync(schema, data);
|
||||
return result as { success: true; data: SchemaOutput<S> } | { success: false; error: unknown };
|
||||
}
|
||||
const v3Schema = schema as z3.ZodTypeAny;
|
||||
const result = await v3Schema.safeParseAsync(data);
|
||||
return result as { success: true; data: SchemaOutput<S> } | { success: false; error: unknown };
|
||||
}
|
||||
|
||||
// --- Shape extraction ---
|
||||
export function getObjectShape(schema: AnyObjectSchema | undefined): Record<string, AnySchema> | undefined {
|
||||
if (!schema) return undefined;
|
||||
|
||||
// Zod v3 exposes `.shape`; Zod v4 keeps the shape on `_zod.def.shape`
|
||||
let rawShape: Record<string, AnySchema> | (() => Record<string, AnySchema>) | undefined;
|
||||
|
||||
if (isZ4Schema(schema)) {
|
||||
const v4Schema = schema as unknown as ZodV4Internal;
|
||||
rawShape = v4Schema._zod?.def?.shape;
|
||||
} else {
|
||||
const v3Schema = schema as unknown as ZodV3Internal;
|
||||
rawShape = v3Schema.shape;
|
||||
}
|
||||
|
||||
if (!rawShape) return undefined;
|
||||
|
||||
if (typeof rawShape === 'function') {
|
||||
try {
|
||||
return rawShape();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return rawShape;
|
||||
}
|
||||
|
||||
// --- Schema normalization ---
|
||||
/**
|
||||
* Normalizes a schema to an object schema. Handles both:
|
||||
* - Already-constructed object schemas (v3 or v4)
|
||||
* - Raw shapes that need to be wrapped into object schemas
|
||||
*/
|
||||
export function normalizeObjectSchema(schema: AnySchema | ZodRawShapeCompat | undefined): AnyObjectSchema | undefined {
|
||||
if (!schema) return undefined;
|
||||
|
||||
// First check if it's a raw shape (Record<string, AnySchema>)
|
||||
// Raw shapes don't have _def or _zod properties and aren't schemas themselves
|
||||
if (typeof schema === 'object') {
|
||||
// Check if it's actually a ZodRawShapeCompat (not a schema instance)
|
||||
// by checking if it lacks schema-like internal properties
|
||||
const asV3 = schema as unknown as ZodV3Internal;
|
||||
const asV4 = schema as unknown as ZodV4Internal;
|
||||
|
||||
// If it's not a schema instance (no _def or _zod), it might be a raw shape
|
||||
if (!asV3._def && !asV4._zod) {
|
||||
// Check if all values are schemas (heuristic to confirm it's a raw shape)
|
||||
const values = Object.values(schema);
|
||||
if (
|
||||
values.length > 0 &&
|
||||
values.every(
|
||||
v =>
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
((v as unknown as ZodV3Internal)._def !== undefined ||
|
||||
(v as unknown as ZodV4Internal)._zod !== undefined ||
|
||||
typeof (v as { parse?: unknown }).parse === 'function')
|
||||
)
|
||||
) {
|
||||
return objectFromShape(schema as ZodRawShapeCompat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, it should be an AnySchema (not a raw shape)
|
||||
// Check if it's already an object schema
|
||||
if (isZ4Schema(schema as AnySchema)) {
|
||||
// Check if it's a v4 object
|
||||
const v4Schema = schema as unknown as ZodV4Internal;
|
||||
const def = v4Schema._zod?.def;
|
||||
if (def && (def.type === 'object' || def.shape !== undefined)) {
|
||||
return schema as AnyObjectSchema;
|
||||
}
|
||||
} else {
|
||||
// Check if it's a v3 object
|
||||
const v3Schema = schema as unknown as ZodV3Internal;
|
||||
if (v3Schema.shape !== undefined) {
|
||||
return schema as AnyObjectSchema;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// --- Error message extraction ---
|
||||
/**
|
||||
* Safely extracts an error message from a parse result error.
|
||||
* Zod errors can have different structures, so we handle various cases.
|
||||
*/
|
||||
export function getParseErrorMessage(error: unknown): string {
|
||||
if (error && typeof error === 'object') {
|
||||
// Try common error structures
|
||||
if ('message' in error && typeof error.message === 'string') {
|
||||
return error.message;
|
||||
}
|
||||
if ('issues' in error && Array.isArray(error.issues) && error.issues.length > 0) {
|
||||
const firstIssue = error.issues[0];
|
||||
if (firstIssue && typeof firstIssue === 'object' && 'message' in firstIssue) {
|
||||
return String(firstIssue.message);
|
||||
}
|
||||
}
|
||||
// Fallback: try to stringify the error
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
// --- Schema metadata access ---
|
||||
/**
|
||||
* Gets the description from a schema, if available.
|
||||
* Works with both Zod v3 and v4.
|
||||
*
|
||||
* Both versions expose a `.description` getter that returns the description
|
||||
* from their respective internal storage (v3: _def, v4: globalRegistry).
|
||||
*/
|
||||
export function getSchemaDescription(schema: AnySchema): string | undefined {
|
||||
return (schema as { description?: string }).description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a schema is optional.
|
||||
* Works with both Zod v3 and v4.
|
||||
*/
|
||||
export function isSchemaOptional(schema: AnySchema): boolean {
|
||||
if (isZ4Schema(schema)) {
|
||||
const v4Schema = schema as unknown as ZodV4Internal;
|
||||
return v4Schema._zod?.def?.type === 'optional';
|
||||
}
|
||||
const v3Schema = schema as unknown as ZodV3Internal;
|
||||
// v3 has isOptional() method
|
||||
if (typeof (schema as { isOptional?: () => boolean }).isOptional === 'function') {
|
||||
return (schema as { isOptional: () => boolean }).isOptional();
|
||||
}
|
||||
return v3Schema._def?.typeName === 'ZodOptional';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the literal value from a schema, if it's a literal schema.
|
||||
* Works with both Zod v3 and v4.
|
||||
* Returns undefined if the schema is not a literal or the value cannot be determined.
|
||||
*/
|
||||
export function getLiteralValue(schema: AnySchema): unknown {
|
||||
if (isZ4Schema(schema)) {
|
||||
const v4Schema = schema as unknown as ZodV4Internal;
|
||||
const def = v4Schema._zod?.def;
|
||||
if (def) {
|
||||
// Try various ways to get the literal value
|
||||
if (def.value !== undefined) return def.value;
|
||||
if (Array.isArray(def.values) && def.values.length > 0) {
|
||||
return def.values[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
const v3Schema = schema as unknown as ZodV3Internal;
|
||||
const def = v3Schema._def;
|
||||
if (def) {
|
||||
if (def.value !== undefined) return def.value;
|
||||
if (Array.isArray(def.values) && def.values.length > 0) {
|
||||
return def.values[0];
|
||||
}
|
||||
}
|
||||
// Fallback: check for direct value property (some Zod versions)
|
||||
const directValue = (schema as { value?: unknown }).value;
|
||||
if (directValue !== undefined) return directValue;
|
||||
return undefined;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue