| import { |
| assertExactKeys, |
| requireCount, |
| requireEntityId, |
| requireExactRecord, |
| requireRecord, |
| requireString, |
| } from './codec.js'; |
| import { invalidProtocolFrame } from './errors.js'; |
| import { defineHostPathOperation, defineOperation } from './operation-spec.js'; |
| |
| export interface ClientCapabilityToolAnnotations { |
| readonly title?: string; |
| readonly readOnlyHint?: boolean; |
| readonly destructiveHint?: boolean; |
| readonly idempotentHint?: boolean; |
| readonly openWorldHint?: boolean; |
| } |
| |
| export interface ClientCapabilityToolDescriptor { |
| readonly serverId: string; |
| readonly name: string; |
| readonly description?: string; |
| readonly inputSchema: Record<string, unknown>; |
| readonly annotations?: ClientCapabilityToolAnnotations; |
| } |
| |
| export type ClientCapabilityContentBlock = |
| | { readonly type: 'text'; readonly text: string } |
| | { readonly type: 'image'; readonly data: string; readonly mimeType: string } |
| | { readonly type: 'audio'; readonly data: string; readonly mimeType: string } |
| | { |
| readonly type: 'resource'; |
| readonly uri: string; |
| readonly mimeType?: string; |
| readonly text?: string; |
| readonly blob?: string; |
| } |
| | { |
| readonly type: 'resource_link'; |
| readonly uri: string; |
| readonly name?: string; |
| readonly description?: string; |
| readonly mimeType?: string; |
| } |
| | { readonly type: 'unknown'; readonly value: unknown }; |
| |
| export interface ClientCapabilityCallResult { |
| readonly content: ClientCapabilityContentBlock[]; |
| readonly structuredContent?: unknown; |
| } |
| |
| export type ClientCapabilityAffinity = 'call' | 'turn' | 'session'; |
| export type ClientCapabilityHostPathAccess = 'none' | 'cwd'; |
| |
| export const CLIENT_CAPABILITY_MAX_OFFERS = 32; |
| export const CLIENT_CAPABILITY_MAX_SERVICES = 32; |
| export const CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER = 64; |
| export const CLIENT_CAPABILITY_MAX_TOOLS = 256; |
| export const CLIENT_CAPABILITY_MAX_MANIFEST_BYTES = 56 * 1024; |
| export const CLIENT_CAPABILITY_MAX_RESULT_BYTES = 24 * 1024 * 1024; |
| export const CLIENT_CAPABILITY_RESULT_CHUNK_MAX_BYTES = 36 * 1024; |
| export const CLIENT_CAPABILITY_MAX_RESULT_CHUNKS = Math.ceil( |
| CLIENT_CAPABILITY_MAX_RESULT_BYTES / CLIENT_CAPABILITY_RESULT_CHUNK_MAX_BYTES, |
| ); |
| |
| const CLIENT_CAPABILITY_INLINE_RESULT_MAX_BYTES = 40 * 1024; |
| const CLIENT_CAPABILITY_JSON_MAX_DEPTH = 32; |
| const CLIENT_CAPABILITY_JSON_MAX_NODES = 8_192; |
| const CLIENT_CAPABILITY_TOOL_DESCRIPTION_MAX_CHARS = 8_192; |
| const CLIENT_CAPABILITY_ERRORS = [ |
| 'host_not_ready', |
| 'host_draining', |
| 'operation_unavailable', |
| 'invalid_request', |
| 'internal_failure', |
| ] as const; |
| |
| export interface ClientCapabilityOffer { |
| readonly offerId: string; |
| readonly version: string; |
| readonly affinity: ClientCapabilityAffinity; |
| readonly hostPathAccess: ClientCapabilityHostPathAccess; |
| readonly label: string; |
| readonly description?: string; |
| readonly tools: readonly ClientCapabilityToolDescriptor[]; |
| } |
| |
| export interface ClientCapabilityServiceOffer { |
| readonly serviceId: string; |
| readonly version: string; |
| } |
| |
| export interface ClientCapabilityReplaceInput { |
| readonly registrationId: string; |
| readonly offers: readonly ClientCapabilityOffer[]; |
| readonly services?: readonly ClientCapabilityServiceOffer[]; |
| } |
| |
| export interface ClientCapabilityReplaceResult { |
| readonly registrationId: string; |
| readonly revision: number; |
| } |
| |
| export interface ClientCapabilityUnregisterInput { |
| readonly registrationId: string; |
| } |
| |
| export interface ClientCapabilityUnregisterResult { |
| readonly registrationId: string; |
| readonly revision: number; |
| } |
| |
| export interface ClientCapabilityCallFrame { |
| readonly kind: 'client.capability.call'; |
| readonly invocationId: string; |
| readonly registrationId: string; |
| readonly offerId: string; |
| readonly serverId: string; |
| readonly toolName: string; |
| readonly arguments: Record<string, unknown>; |
| readonly sessionId: string; |
| readonly turnId: string; |
| readonly toolCallId: string; |
| readonly cwd?: string; |
| } |
| |
| export interface ClientCapabilityServiceCallFrame { |
| readonly kind: 'client.capability.service_call'; |
| readonly invocationId: string; |
| readonly registrationId: string; |
| readonly serviceId: string; |
| readonly version: string; |
| readonly method: string; |
| readonly input: Record<string, unknown>; |
| } |
| |
| export interface ClientCapabilityCancelFrame { |
| readonly kind: 'client.capability.cancel'; |
| readonly invocationId: string; |
| } |
| |
| export interface ClientCapabilityReleaseFrame { |
| readonly kind: 'client.capability.release'; |
| readonly invocationId: string; |
| } |
| |
| export interface ClientCapabilityRegistrationReleaseFrame { |
| readonly kind: 'client.capability.registration_release'; |
| readonly registrationId: string; |
| } |
| |
| export interface ClientCapabilityAdmittedFrame { |
| readonly kind: 'client.capability.admitted'; |
| readonly invocationId: string; |
| } |
| |
| export type ClientCapabilityHostFrame = |
| | ClientCapabilityCallFrame |
| | ClientCapabilityServiceCallFrame |
| | ClientCapabilityCancelFrame |
| | ClientCapabilityReleaseFrame |
| | ClientCapabilityRegistrationReleaseFrame |
| | ClientCapabilityAdmittedFrame; |
| |
| export interface ClientCapabilityAcceptedFrame { |
| readonly kind: 'client.capability.accepted'; |
| readonly invocationId: string; |
| } |
| |
| export interface ClientCapabilityRejectedFrame { |
| readonly kind: 'client.capability.rejected'; |
| readonly invocationId: string; |
| readonly message: string; |
| } |
| |
| export interface ClientCapabilityFailedFrame { |
| readonly kind: 'client.capability.failed'; |
| readonly invocationId: string; |
| readonly message: string; |
| } |
| |
| export interface ClientCapabilityResultFrame { |
| readonly kind: 'client.capability.result'; |
| readonly invocationId: string; |
| readonly result: ClientCapabilityCallResult; |
| } |
| |
| export interface ClientCapabilityResultStartFrame { |
| readonly kind: 'client.capability.result_start'; |
| readonly invocationId: string; |
| readonly byteLength: number; |
| readonly chunkCount: number; |
| } |
| |
| export interface ClientCapabilityResultChunkFrame { |
| readonly kind: 'client.capability.result_chunk'; |
| readonly invocationId: string; |
| readonly index: number; |
| readonly data: string; |
| } |
| |
| export type ClientCapabilityClientFrame = |
| | ClientCapabilityAcceptedFrame |
| | ClientCapabilityRejectedFrame |
| | ClientCapabilityFailedFrame |
| | ClientCapabilityResultFrame |
| | ClientCapabilityResultStartFrame |
| | ClientCapabilityResultChunkFrame; |
| |
| export const CLIENT_CAPABILITY_OPERATION_SPECS = { |
| 'client.capability.replace': defineHostPathOperation< |
| ClientCapabilityReplaceInput, |
| ClientCapabilityReplaceResult, |
| (typeof CLIENT_CAPABILITY_ERRORS)[number] |
| >( |
| { |
| mode: 'command', |
| availability: 'ready', |
| errors: CLIENT_CAPABILITY_ERRORS, |
| decodeInput: decodeClientCapabilityReplaceInput, |
| decodeOutput: decodeClientCapabilityReplaceResult, |
| }, |
| (input) => input.offers.some((offer) => offer.hostPathAccess === 'cwd'), |
| ), |
| 'client.capability.unregister': defineOperation< |
| ClientCapabilityUnregisterInput, |
| ClientCapabilityUnregisterResult, |
| (typeof CLIENT_CAPABILITY_ERRORS)[number] |
| >({ |
| mode: 'command', |
| availability: 'ready', |
| errors: CLIENT_CAPABILITY_ERRORS, |
| decodeInput: decodeClientCapabilityUnregisterInput, |
| decodeOutput: decodeClientCapabilityUnregisterResult, |
| }), |
| } as const; |
| |
| export function decodeClientCapabilityReplaceInput(value: unknown): ClientCapabilityReplaceInput { |
| const record = requireRecord(value, 'Client Capability replacement'); |
| assertOptionalExactKeys( |
| record, |
| 'Client Capability replacement', |
| ['registrationId', 'offers'], |
| ['services'], |
| ); |
| if (!Array.isArray(record.offers) || record.offers.length > CLIENT_CAPABILITY_MAX_OFFERS) { |
| throw invalidProtocolFrame('Invalid Client Capability offers'); |
| } |
| const serviceValues = record.services ?? []; |
| if (!Array.isArray(serviceValues) || serviceValues.length > CLIENT_CAPABILITY_MAX_SERVICES) { |
| throw invalidProtocolFrame('Invalid Client Capability services'); |
| } |
| if (record.offers.length === 0 && serviceValues.length === 0) { |
| throw invalidProtocolFrame('Client Capability registration is empty'); |
| } |
| const offers = record.offers.map((offer) => decodeClientCapabilityOffer(offer)); |
| const services = serviceValues.map((service) => decodeClientCapabilityServiceOffer(service)); |
| const offerIds = new Set<string>(); |
| const serviceContracts = new Set<string>(); |
| const toolIdentities = new Set<string>(); |
| let toolCount = 0; |
| for (const offer of offers) { |
| if (offerIds.has(offer.offerId)) { |
| throw invalidProtocolFrame('Duplicate Client Capability offer'); |
| } |
| offerIds.add(offer.offerId); |
| toolCount += offer.tools.length; |
| for (const tool of offer.tools) { |
| const identity = `${tool.serverId}\0${tool.name}`; |
| if (toolIdentities.has(identity)) { |
| throw invalidProtocolFrame('Duplicate Client Capability tool'); |
| } |
| toolIdentities.add(identity); |
| } |
| } |
| if (toolCount > CLIENT_CAPABILITY_MAX_TOOLS) { |
| throw invalidProtocolFrame('Too many Client Capability tools'); |
| } |
| for (const service of services) { |
| const contract = `${service.serviceId}\0${service.version}`; |
| if (serviceContracts.has(contract)) { |
| throw invalidProtocolFrame('Duplicate Client Capability service'); |
| } |
| serviceContracts.add(contract); |
| } |
| const decoded = { |
| registrationId: requireEntityId(record.registrationId, 'registrationId'), |
| offers, |
| ...(record.services === undefined ? {} : { services }), |
| }; |
| if (jsonByteLength(decoded) > CLIENT_CAPABILITY_MAX_MANIFEST_BYTES) { |
| throw invalidProtocolFrame('Client Capability manifest is too large'); |
| } |
| return decoded; |
| } |
| |
| export function decodeClientCapabilityReplaceResult(value: unknown): ClientCapabilityReplaceResult { |
| const record = requireExactRecord(value, 'Client Capability replacement result', [ |
| 'registrationId', |
| 'revision', |
| ]); |
| return { |
| registrationId: requireEntityId(record.registrationId, 'registrationId'), |
| revision: requireCount(record.revision, 'revision'), |
| }; |
| } |
| |
| export function decodeClientCapabilityUnregisterInput( |
| value: unknown, |
| ): ClientCapabilityUnregisterInput { |
| const record = requireExactRecord(value, 'Client Capability unregister input', [ |
| 'registrationId', |
| ]); |
| return { |
| registrationId: requireEntityId(record.registrationId, 'registrationId'), |
| }; |
| } |
| |
| export function decodeClientCapabilityUnregisterResult( |
| value: unknown, |
| ): ClientCapabilityUnregisterResult { |
| const record = requireExactRecord(value, 'Client Capability unregister result', [ |
| 'registrationId', |
| 'revision', |
| ]); |
| return { |
| registrationId: requireEntityId(record.registrationId, 'registrationId'), |
| revision: requireCount(record.revision, 'revision'), |
| }; |
| } |
| |
| export function isClientCapabilityClientFrameKind( |
| value: unknown, |
| ): value is ClientCapabilityClientFrame['kind'] { |
| return ( |
| typeof value === 'string' && |
| CLIENT_CAPABILITY_CLIENT_FRAME_KINDS.has(value as ClientCapabilityClientFrame['kind']) |
| ); |
| } |
| |
| export function isClientCapabilityHostFrameKind( |
| value: unknown, |
| ): value is ClientCapabilityHostFrame['kind'] { |
| return ( |
| typeof value === 'string' && |
| CLIENT_CAPABILITY_HOST_FRAME_KINDS.has(value as ClientCapabilityHostFrame['kind']) |
| ); |
| } |
| |
| export function decodeClientCapabilityClientFrame(value: unknown): ClientCapabilityClientFrame { |
| const frame = requireRecord(value, 'Client Capability client frame'); |
| switch (frame.kind) { |
| case 'client.capability.accepted': |
| assertExactKeys(frame, 'Client Capability accepted frame', ['kind', 'invocationId']); |
| return { |
| kind: frame.kind, |
| invocationId: requireEntityId(frame.invocationId, 'invocationId'), |
| }; |
| case 'client.capability.rejected': |
| case 'client.capability.failed': |
| assertExactKeys(frame, 'Client Capability failure frame', [ |
| 'kind', |
| 'invocationId', |
| 'message', |
| ]); |
| return { |
| kind: frame.kind, |
| invocationId: requireEntityId(frame.invocationId, 'invocationId'), |
| message: requireString(frame.message, 'message', 4_096), |
| }; |
| case 'client.capability.result': |
| assertExactKeys(frame, 'Client Capability result frame', ['kind', 'invocationId', 'result']); |
| if (jsonByteLength(frame.result) > CLIENT_CAPABILITY_INLINE_RESULT_MAX_BYTES) { |
| throw invalidProtocolFrame('Client Capability inline result is too large'); |
| } |
| return { |
| kind: frame.kind, |
| invocationId: requireEntityId(frame.invocationId, 'invocationId'), |
| result: decodeClientCapabilityResult(frame.result), |
| }; |
| case 'client.capability.result_start': { |
| assertExactKeys(frame, 'Client Capability result start frame', [ |
| 'kind', |
| 'invocationId', |
| 'byteLength', |
| 'chunkCount', |
| ]); |
| const byteLength = requireCount(frame.byteLength, 'byteLength'); |
| const chunkCount = requireCount(frame.chunkCount, 'chunkCount'); |
| if ( |
| byteLength === 0 || |
| byteLength > CLIENT_CAPABILITY_MAX_RESULT_BYTES || |
| chunkCount === 0 || |
| chunkCount > CLIENT_CAPABILITY_MAX_RESULT_CHUNKS || |
| chunkCount !== Math.ceil(byteLength / CLIENT_CAPABILITY_RESULT_CHUNK_MAX_BYTES) |
| ) { |
| throw invalidProtocolFrame('Invalid Client Capability result bounds'); |
| } |
| return { |
| kind: frame.kind, |
| invocationId: requireEntityId(frame.invocationId, 'invocationId'), |
| byteLength, |
| chunkCount, |
| }; |
| } |
| case 'client.capability.result_chunk': { |
| assertExactKeys(frame, 'Client Capability result chunk frame', [ |
| 'kind', |
| 'invocationId', |
| 'index', |
| 'data', |
| ]); |
| const data = requireString( |
| frame.data, |
| 'data', |
| Math.ceil(CLIENT_CAPABILITY_RESULT_CHUNK_MAX_BYTES / 3) * 4, |
| ); |
| if (!isCanonicalBase64(data)) { |
| throw invalidProtocolFrame('Invalid Client Capability result chunk'); |
| } |
| return { |
| kind: frame.kind, |
| invocationId: requireEntityId(frame.invocationId, 'invocationId'), |
| index: requireCount(frame.index, 'index'), |
| data, |
| }; |
| } |
| default: |
| throw invalidProtocolFrame('Invalid Client Capability client frame kind'); |
| } |
| } |
| |
| export function decodeClientCapabilityHostFrame(value: unknown): ClientCapabilityHostFrame { |
| const frame = requireRecord(value, 'Client Capability Host frame'); |
| switch (frame.kind) { |
| case 'client.capability.call': { |
| assertOptionalExactKeys( |
| frame, |
| 'Client Capability call frame', |
| [ |
| 'kind', |
| 'invocationId', |
| 'registrationId', |
| 'offerId', |
| 'serverId', |
| 'toolName', |
| 'arguments', |
| 'sessionId', |
| 'turnId', |
| 'toolCallId', |
| ], |
| ['cwd'], |
| ); |
| const argumentsValue = decodeJsonRecord(frame.arguments, 'arguments'); |
| if (jsonByteLength(argumentsValue) > 40 * 1024) { |
| throw invalidProtocolFrame('Client Capability arguments are too large'); |
| } |
| return { |
| kind: frame.kind, |
| invocationId: requireEntityId(frame.invocationId, 'invocationId'), |
| registrationId: requireEntityId(frame.registrationId, 'registrationId'), |
| offerId: requireEntityId(frame.offerId, 'offerId'), |
| serverId: requireString(frame.serverId, 'serverId', 128), |
| toolName: requireString(frame.toolName, 'toolName', 128), |
| arguments: argumentsValue, |
| sessionId: requireEntityId(frame.sessionId, 'sessionId'), |
| turnId: requireEntityId(frame.turnId, 'turnId'), |
| toolCallId: requireEntityId(frame.toolCallId, 'toolCallId'), |
| ...(frame.cwd === undefined ? {} : { cwd: requireString(frame.cwd, 'cwd', 4_096) }), |
| }; |
| } |
| case 'client.capability.service_call': { |
| assertExactKeys(frame, 'Client Capability service call frame', [ |
| 'kind', |
| 'invocationId', |
| 'registrationId', |
| 'serviceId', |
| 'version', |
| 'method', |
| 'input', |
| ]); |
| const input = decodeJsonRecord(frame.input, 'input'); |
| if (jsonByteLength(input) > 40 * 1024) { |
| throw invalidProtocolFrame('Client Capability service input is too large'); |
| } |
| return { |
| kind: frame.kind, |
| invocationId: requireEntityId(frame.invocationId, 'invocationId'), |
| registrationId: requireEntityId(frame.registrationId, 'registrationId'), |
| serviceId: requireEntityId(frame.serviceId, 'serviceId'), |
| version: requireString(frame.version, 'version', 64), |
| method: requireEntityId(frame.method, 'method'), |
| input, |
| }; |
| } |
| case 'client.capability.cancel': |
| case 'client.capability.release': |
| case 'client.capability.admitted': |
| assertExactKeys(frame, 'Client Capability invocation control frame', [ |
| 'kind', |
| 'invocationId', |
| ]); |
| return { |
| kind: frame.kind, |
| invocationId: requireEntityId(frame.invocationId, 'invocationId'), |
| }; |
| case 'client.capability.registration_release': |
| assertExactKeys(frame, 'Client Capability registration release frame', [ |
| 'kind', |
| 'registrationId', |
| ]); |
| return { |
| kind: frame.kind, |
| registrationId: requireEntityId(frame.registrationId, 'registrationId'), |
| }; |
| default: |
| throw invalidProtocolFrame('Invalid Client Capability Host frame kind'); |
| } |
| } |
| |
| export function decodeClientCapabilityResult(value: unknown): ClientCapabilityCallResult { |
| const record = requireRecord(value, 'Client Capability result'); |
| assertOptionalExactKeys(record, 'Client Capability result', ['content'], ['structuredContent']); |
| if (!Array.isArray(record.content) || record.content.length > 256) { |
| throw invalidProtocolFrame('Invalid Client Capability result content'); |
| } |
| const content = record.content.map(decodeContentBlock); |
| return { |
| content, |
| ...(Object.hasOwn(record, 'structuredContent') |
| ? { |
| structuredContent: decodeJsonValue(record.structuredContent, 'structuredContent'), |
| } |
| : {}), |
| }; |
| } |
| |
| function decodeClientCapabilityOffer(value: unknown): ClientCapabilityOffer { |
| const record = requireRecord(value, 'Client Capability offer'); |
| assertOptionalExactKeys( |
| record, |
| 'Client Capability offer', |
| ['offerId', 'version', 'affinity', 'hostPathAccess', 'label', 'tools'], |
| ['description'], |
| ); |
| if ( |
| !Array.isArray(record.tools) || |
| record.tools.length === 0 || |
| record.tools.length > CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER |
| ) { |
| throw invalidProtocolFrame('Invalid Client Capability offer tools'); |
| } |
| return { |
| offerId: requireEntityId(record.offerId, 'offerId'), |
| version: requireString(record.version, 'version', 64), |
| affinity: decodeClientCapabilityAffinity(record.affinity), |
| hostPathAccess: decodeClientCapabilityHostPathAccess(record.hostPathAccess), |
| label: requireString(record.label, 'label', 128), |
| ...(record.description === undefined |
| ? {} |
| : { |
| description: requireString(record.description, 'description', 1_024), |
| }), |
| tools: record.tools.map(decodeToolDescriptor), |
| }; |
| } |
| |
| function decodeClientCapabilityServiceOffer(value: unknown): ClientCapabilityServiceOffer { |
| const record = requireExactRecord(value, 'Client Capability service offer', [ |
| 'serviceId', |
| 'version', |
| ]); |
| return { |
| serviceId: requireEntityId(record.serviceId, 'serviceId'), |
| version: requireString(record.version, 'version', 64), |
| }; |
| } |
| |
| function decodeClientCapabilityAffinity(value: unknown): ClientCapabilityAffinity { |
| if (value === 'call' || value === 'turn' || value === 'session') return value; |
| throw invalidProtocolFrame('Invalid Client Capability affinity'); |
| } |
| |
| function decodeClientCapabilityHostPathAccess(value: unknown): ClientCapabilityHostPathAccess { |
| if (value === 'none' || value === 'cwd') return value; |
| throw invalidProtocolFrame('Invalid Client Capability Host path access'); |
| } |
| |
| function decodeToolDescriptor(value: unknown): ClientCapabilityToolDescriptor { |
| const record = requireRecord(value, 'Client Capability tool'); |
| assertOptionalExactKeys( |
| record, |
| 'Client Capability tool', |
| ['serverId', 'name', 'inputSchema'], |
| ['description', 'annotations'], |
| ); |
| const inputSchema = decodeJsonRecord(record.inputSchema, 'inputSchema'); |
| if (jsonByteLength(inputSchema) > 32 * 1024) { |
| throw invalidProtocolFrame('Client Capability tool schema is too large'); |
| } |
| validateToolInputSchema(inputSchema); |
| return { |
| serverId: requireString(record.serverId, 'serverId', 128), |
| name: requireString(record.name, 'name', 128), |
| ...(record.description === undefined |
| ? {} |
| : { |
| description: requireString( |
| record.description, |
| 'description', |
| CLIENT_CAPABILITY_TOOL_DESCRIPTION_MAX_CHARS, |
| ), |
| }), |
| inputSchema, |
| ...(record.annotations === undefined |
| ? {} |
| : { annotations: decodeToolAnnotations(record.annotations) }), |
| }; |
| } |
| |
| const CLIENT_CAPABILITY_SCHEMA_TYPES = new Set([ |
| 'array', |
| 'boolean', |
| 'integer', |
| 'null', |
| 'number', |
| 'object', |
| 'string', |
| ]); |
| const CLIENT_CAPABILITY_SCHEMA_KEYWORDS = new Set([ |
| '$defs', |
| '$ref', |
| 'additionalProperties', |
| 'allOf', |
| 'anyOf', |
| 'const', |
| 'default', |
| 'definitions', |
| 'description', |
| 'enum', |
| 'examples', |
| 'exclusiveMaximum', |
| 'exclusiveMinimum', |
| 'format', |
| 'items', |
| 'maxItems', |
| 'maxLength', |
| 'maxProperties', |
| 'maximum', |
| 'minItems', |
| 'minLength', |
| 'minProperties', |
| 'minimum', |
| 'multipleOf', |
| 'oneOf', |
| 'pattern', |
| 'propertyNames', |
| 'properties', |
| 'required', |
| 'title', |
| 'type', |
| 'uniqueItems', |
| ]); |
| |
| function validateToolInputSchema(root: Record<string, unknown>): void { |
| if (!Object.hasOwn(root, 'type') || root.type !== 'object') { |
| throw invalidProtocolFrame('Client Capability tool schema root must be an object'); |
| } |
| const references: string[] = []; |
| const visit = (value: unknown): void => { |
| if (typeof value === 'boolean') return; |
| const schema = requireRecord(value, 'Client Capability tool schema'); |
| if (Object.keys(schema).some((key) => !CLIENT_CAPABILITY_SCHEMA_KEYWORDS.has(key))) { |
| throw invalidProtocolFrame('Unsupported Client Capability tool schema keyword'); |
| } |
| if (schema.type !== undefined) validateSchemaType(schema.type); |
| for (const key of ['title', 'description', 'format', 'pattern'] as const) { |
| if (schema[key] !== undefined && typeof schema[key] !== 'string') { |
| throw invalidProtocolFrame(`Invalid Client Capability tool schema ${key}`); |
| } |
| } |
| if (typeof schema.pattern === 'string') { |
| try { |
| new RegExp(schema.pattern); |
| } catch { |
| throw invalidProtocolFrame('Invalid Client Capability tool schema pattern'); |
| } |
| } |
| for (const key of [ |
| 'minimum', |
| 'maximum', |
| 'exclusiveMinimum', |
| 'exclusiveMaximum', |
| 'multipleOf', |
| ] as const) { |
| if ( |
| schema[key] !== undefined && |
| (typeof schema[key] !== 'number' || !Number.isFinite(schema[key])) |
| ) { |
| throw invalidProtocolFrame(`Invalid Client Capability tool schema ${key}`); |
| } |
| } |
| if (typeof schema.multipleOf === 'number' && schema.multipleOf <= 0) { |
| throw invalidProtocolFrame('Invalid Client Capability tool schema multipleOf'); |
| } |
| for (const key of [ |
| 'minItems', |
| 'maxItems', |
| 'minLength', |
| 'maxLength', |
| 'minProperties', |
| 'maxProperties', |
| ] as const) { |
| if ( |
| schema[key] !== undefined && |
| (!Number.isSafeInteger(schema[key]) || (schema[key] as number) < 0) |
| ) { |
| throw invalidProtocolFrame(`Invalid Client Capability tool schema ${key}`); |
| } |
| } |
| if (schema.uniqueItems !== undefined && typeof schema.uniqueItems !== 'boolean') { |
| throw invalidProtocolFrame('Invalid Client Capability tool schema uniqueItems'); |
| } |
| for (const key of ['properties', '$defs', 'definitions'] as const) { |
| if (schema[key] === undefined) continue; |
| const entries = requireRecord(schema[key], `Client Capability tool schema ${key}`); |
| for (const nested of Object.values(entries)) visit(nested); |
| } |
| if (schema.required !== undefined) { |
| if ( |
| !Array.isArray(schema.required) || |
| schema.required.some((entry) => typeof entry !== 'string') || |
| new Set(schema.required).size !== schema.required.length |
| ) { |
| throw invalidProtocolFrame('Invalid Client Capability tool schema required'); |
| } |
| } |
| if ( |
| schema.additionalProperties !== undefined && |
| typeof schema.additionalProperties !== 'boolean' |
| ) { |
| visit(schema.additionalProperties); |
| } |
| if (schema.propertyNames !== undefined) { |
| visit(schema.propertyNames); |
| } |
| if (schema.items !== undefined) { |
| if (Array.isArray(schema.items)) { |
| if (schema.items.length === 0) { |
| throw invalidProtocolFrame('Invalid Client Capability tool schema items'); |
| } |
| for (const nested of schema.items) visit(nested); |
| } else { |
| visit(schema.items); |
| } |
| } |
| for (const key of ['allOf', 'anyOf', 'oneOf'] as const) { |
| if (schema[key] === undefined) continue; |
| if (!Array.isArray(schema[key]) || schema[key].length === 0) { |
| throw invalidProtocolFrame(`Invalid Client Capability tool schema ${key}`); |
| } |
| for (const nested of schema[key]) visit(nested); |
| } |
| if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length === 0)) { |
| throw invalidProtocolFrame('Invalid Client Capability tool schema enum'); |
| } |
| if (schema.examples !== undefined && !Array.isArray(schema.examples)) { |
| throw invalidProtocolFrame('Invalid Client Capability tool schema examples'); |
| } |
| if (schema.$ref !== undefined) { |
| if ( |
| typeof schema.$ref !== 'string' || |
| !/^#\/(?:\$defs|definitions)(?:\/(?:[^~/]|~[01])+)+$/u.test(schema.$ref) |
| ) { |
| throw invalidProtocolFrame('Client Capability tool schema reference must be local'); |
| } |
| references.push(schema.$ref); |
| } |
| }; |
| visit(root); |
| for (const reference of references) { |
| if (!resolveLocalSchemaReference(root, reference)) { |
| throw invalidProtocolFrame('Client Capability tool schema reference is unresolved'); |
| } |
| } |
| } |
| |
| function validateSchemaType(value: unknown): void { |
| const values = Array.isArray(value) ? value : [value]; |
| if ( |
| values.length === 0 || |
| values.some( |
| (entry) => typeof entry !== 'string' || !CLIENT_CAPABILITY_SCHEMA_TYPES.has(entry), |
| ) || |
| new Set(values).size !== values.length |
| ) { |
| throw invalidProtocolFrame('Invalid Client Capability tool schema type'); |
| } |
| } |
| |
| function resolveLocalSchemaReference(root: Record<string, unknown>, reference: string): boolean { |
| let value: unknown = root; |
| for (const token of reference |
| .slice(2) |
| .split('/') |
| .map((part) => part.replace(/~1/gu, '/').replace(/~0/gu, '~'))) { |
| if ( |
| !value || |
| typeof value !== 'object' || |
| Array.isArray(value) || |
| !Object.hasOwn(value, token) |
| ) { |
| return false; |
| } |
| value = (value as Record<string, unknown>)[token]; |
| } |
| return typeof value === 'boolean' || (value !== null && typeof value === 'object'); |
| } |
| |
| function decodeToolAnnotations(value: unknown): ClientCapabilityToolAnnotations { |
| const record = requireRecord(value, 'Client Capability tool annotations'); |
| assertOptionalExactKeys( |
| record, |
| 'Client Capability tool annotations', |
| [], |
| ['title', 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'], |
| ); |
| return { |
| ...(record.title === undefined ? {} : { title: requireString(record.title, 'title', 128) }), |
| ...optionalBoolean(record, 'readOnlyHint'), |
| ...optionalBoolean(record, 'destructiveHint'), |
| ...optionalBoolean(record, 'idempotentHint'), |
| ...optionalBoolean(record, 'openWorldHint'), |
| }; |
| } |
| |
| function decodeContentBlock(value: unknown): ClientCapabilityContentBlock { |
| const record = requireRecord(value, 'Client Capability content block'); |
| switch (record.type) { |
| case 'text': |
| assertExactKeys(record, 'Client Capability text block', ['type', 'text']); |
| return { |
| type: 'text', |
| text: requireBoundedString(record.text, 'text', CLIENT_CAPABILITY_MAX_RESULT_BYTES), |
| }; |
| case 'image': |
| case 'audio': |
| assertExactKeys(record, `Client Capability ${record.type} block`, [ |
| 'type', |
| 'data', |
| 'mimeType', |
| ]); |
| const data = requireBoundedString(record.data, 'data', CLIENT_CAPABILITY_MAX_RESULT_BYTES); |
| if (!isCanonicalBase64(data)) { |
| throw invalidProtocolFrame(`Invalid Client Capability ${record.type} data`); |
| } |
| return { |
| type: record.type, |
| data, |
| mimeType: |
| record.type === 'image' |
| ? requireImageMimeType(record.mimeType) |
| : requireString(record.mimeType, 'mimeType', 256), |
| }; |
| case 'resource': |
| assertOptionalExactKeys( |
| record, |
| 'Client Capability resource block', |
| ['type', 'uri'], |
| ['mimeType', 'text', 'blob'], |
| ); |
| return { |
| type: 'resource', |
| uri: requireString(record.uri, 'uri', 4_096), |
| ...(record.mimeType === undefined |
| ? {} |
| : { mimeType: requireString(record.mimeType, 'mimeType', 256) }), |
| ...(record.text === undefined |
| ? {} |
| : { |
| text: requireBoundedString(record.text, 'text', CLIENT_CAPABILITY_MAX_RESULT_BYTES), |
| }), |
| ...(record.blob === undefined |
| ? {} |
| : { |
| blob: requireCanonicalBase64(record.blob, 'blob', CLIENT_CAPABILITY_MAX_RESULT_BYTES), |
| }), |
| }; |
| case 'resource_link': |
| assertOptionalExactKeys( |
| record, |
| 'Client Capability resource link block', |
| ['type', 'uri'], |
| ['name', 'description', 'mimeType'], |
| ); |
| return { |
| type: 'resource_link', |
| uri: requireString(record.uri, 'uri', 4_096), |
| ...(record.name === undefined ? {} : { name: requireString(record.name, 'name', 512) }), |
| ...(record.description === undefined |
| ? {} |
| : { |
| description: requireString(record.description, 'description', 4_096), |
| }), |
| ...(record.mimeType === undefined |
| ? {} |
| : { mimeType: requireString(record.mimeType, 'mimeType', 256) }), |
| }; |
| case 'unknown': |
| assertExactKeys(record, 'Client Capability unknown block', ['type', 'value']); |
| return { |
| type: 'unknown', |
| value: decodeJsonValue(record.value, 'value'), |
| }; |
| default: |
| throw invalidProtocolFrame('Invalid Client Capability content block type'); |
| } |
| } |
| |
| function decodeJsonRecord(value: unknown, label: string): Record<string, unknown> { |
| const decoded = decodeJsonValue(value, label); |
| return requireRecord(decoded, label); |
| } |
| |
| function decodeJsonValue(value: unknown, label: string): unknown { |
| let nodes = 0; |
| const ancestors = new WeakSet<object>(); |
| const visit = (entry: unknown, depth: number): unknown => { |
| nodes += 1; |
| if (nodes > CLIENT_CAPABILITY_JSON_MAX_NODES || depth > CLIENT_CAPABILITY_JSON_MAX_DEPTH) { |
| throw invalidProtocolFrame(`Invalid ${label}`); |
| } |
| if ( |
| entry === null || |
| typeof entry === 'string' || |
| typeof entry === 'boolean' || |
| (typeof entry === 'number' && Number.isFinite(entry)) |
| ) { |
| return entry; |
| } |
| if (Array.isArray(entry)) { |
| if (ancestors.has(entry)) throw invalidProtocolFrame(`Invalid ${label}`); |
| ancestors.add(entry); |
| try { |
| return entry.map((item) => visit(item, depth + 1)); |
| } finally { |
| ancestors.delete(entry); |
| } |
| } |
| if (entry && typeof entry === 'object') { |
| if (ancestors.has(entry)) throw invalidProtocolFrame(`Invalid ${label}`); |
| ancestors.add(entry); |
| const output: Record<string, unknown> = {}; |
| try { |
| for (const [key, item] of Object.entries(entry)) { |
| if (key.length === 0 || key.length > 256) throw invalidProtocolFrame(`Invalid ${label}`); |
| Object.defineProperty(output, key, { |
| value: visit(item, depth + 1), |
| enumerable: true, |
| configurable: true, |
| writable: true, |
| }); |
| } |
| } finally { |
| ancestors.delete(entry); |
| } |
| return output; |
| } |
| throw invalidProtocolFrame(`Invalid ${label}`); |
| }; |
| return visit(value, 0); |
| } |
| |
| function requireBoundedString(value: unknown, label: string, maxLength: number): string { |
| if (typeof value !== 'string' || value.length > maxLength) { |
| throw invalidProtocolFrame(`Invalid ${label}`); |
| } |
| return value; |
| } |
| |
| function requireCanonicalBase64(value: unknown, label: string, maxLength: number): string { |
| const data = requireBoundedString(value, label, maxLength); |
| if (!isCanonicalBase64(data)) throw invalidProtocolFrame(`Invalid ${label}`); |
| return data; |
| } |
| |
| function requireImageMimeType(value: unknown): string { |
| const mimeType = requireString(value, 'mimeType', 256); |
| if (!/^image\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/u.test(mimeType)) { |
| throw invalidProtocolFrame('Invalid Client Capability image MIME type'); |
| } |
| return mimeType; |
| } |
| |
| function assertOptionalExactKeys( |
| record: Record<string, unknown>, |
| label: string, |
| required: readonly string[], |
| optional: readonly string[], |
| ): void { |
| const allowed = new Set([...required, ...optional]); |
| if (Object.keys(record).some((key) => !allowed.has(key))) { |
| throw invalidProtocolFrame(`Unknown ${label} field`); |
| } |
| if (required.some((key) => !Object.hasOwn(record, key))) { |
| throw invalidProtocolFrame(`Invalid ${label} fields`); |
| } |
| } |
| |
| function optionalBoolean( |
| record: Record<string, unknown>, |
| key: keyof ClientCapabilityToolAnnotations, |
| ): Partial<ClientCapabilityToolAnnotations> { |
| const value = record[key]; |
| if (value === undefined) return {}; |
| if (typeof value !== 'boolean') throw invalidProtocolFrame(`Invalid ${key}`); |
| return { [key]: value }; |
| } |
| |
| function jsonByteLength(value: unknown): number { |
| let encoded: string | undefined; |
| try { |
| encoded = JSON.stringify(value); |
| } catch { |
| throw invalidProtocolFrame('Invalid JSON value'); |
| } |
| if (encoded === undefined) throw invalidProtocolFrame('Invalid JSON value'); |
| return Buffer.byteLength(encoded, 'utf8'); |
| } |
| |
| function isCanonicalBase64(value: string): boolean { |
| if (value.length === 0 || value.length % 4 !== 0) return false; |
| if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return false; |
| return Buffer.from(value, 'base64').toString('base64') === value; |
| } |
| |
| const CLIENT_CAPABILITY_CLIENT_FRAME_KINDS = new Set<ClientCapabilityClientFrame['kind']>([ |
| 'client.capability.accepted', |
| 'client.capability.rejected', |
| 'client.capability.failed', |
| 'client.capability.result', |
| 'client.capability.result_start', |
| 'client.capability.result_chunk', |
| ]); |
| |
| const CLIENT_CAPABILITY_HOST_FRAME_KINDS = new Set<ClientCapabilityHostFrame['kind']>([ |
| 'client.capability.call', |
| 'client.capability.service_call', |
| 'client.capability.cancel', |
| 'client.capability.release', |
| 'client.capability.registration_release', |
| 'client.capability.admitted', |
| ]); |