v2 ESlint fixes (#1403)

Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
This commit is contained in:
Konstantin Konstantinov 2026-01-20 14:21:53 +02:00 • committed by GitHub
commit 9fc7e28f48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1672 additions and 1346 deletions

View file

@ -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;

View 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;
}
}

View file

@ -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);
}
};

View file

@ -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;
};
};

View file

@ -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) {

View file

@ -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');

View file

@ -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';

View file

@ -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';

View file

@ -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', () => ({

View file

@ -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';

View file

@ -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;
}
}
}

View 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;
}
}

View file

@ -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
*

View file

@ -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));
/**

View 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);
}

View file

@ -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;
}

View file

@ -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

View file

@ -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;

View file

@ -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;
},

View 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