blob: 3efb6961ab37e06a1ba770a66b6e9b1b6320057f [file]
import { randomUUID } from 'node:crypto';
import {
CLIENT_CAPABILITY_MAX_RESULT_BYTES,
CLIENT_CAPABILITY_RESULT_CHUNK_MAX_BYTES,
decodeClientCapabilityReplaceInput,
decodeClientCapabilityResult,
type ClientCapabilityCallFrame,
type ClientCapabilityClientFrame,
type ClientCapabilityHostFrame,
type ClientCapabilityReplaceInput,
type ClientCapabilityReplaceResult,
type ClientCapabilityServiceCallFrame,
type ClientCapabilityUnregisterInput,
type ClientCapabilityUnregisterResult,
} from '../protocol/index.js';
import type { ClientCapabilityProvider } from './client-capability.js';
interface ClientCapabilityRegistration {
readonly registrationId: string;
readonly provider: ClientCapabilityProvider;
readonly offers: ReturnType<ClientCapabilityProvider['offers']>;
readonly services: NonNullable<ReturnType<NonNullable<ClientCapabilityProvider['services']>>>;
}
interface ClientCapabilityInvocation {
readonly controller: AbortController;
admission?: ClientCapabilityAdmission;
released: boolean;
}
interface ClientCapabilityAdmission {
readonly promise: Promise<void>;
resolve(): boolean;
reject(error: unknown): boolean;
}
export interface ClientCapabilityChannelOptions {
readonly write: (frame: ClientCapabilityClientFrame) => Promise<void>;
readonly replace: (
input: ClientCapabilityReplaceInput,
timeoutMs: number,
) => Promise<ClientCapabilityReplaceResult>;
readonly unregister: (
input: ClientCapabilityUnregisterInput,
timeoutMs: number,
) => Promise<ClientCapabilityUnregisterResult>;
readonly onFailure: (error: Error) => void;
}
/** Client-owned registration, reverse invocation, and teardown lifecycle. */
export class ClientCapabilityChannel {
readonly #options: ClientCapabilityChannelOptions;
readonly #registrations = new Map<string, ClientCapabilityRegistration>();
readonly #invocations = new Map<string, ClientCapabilityInvocation>();
readonly #releasedRegistrationIds = new Set<string>();
#currentRegistrationId: string | undefined;
#mutationPending = false;
#closedError: Error | undefined;
constructor(options: ClientCapabilityChannelOptions) {
this.#options = options;
}
async replace(
provider: ClientCapabilityProvider,
timeoutMs: number,
): Promise<ClientCapabilityReplaceResult> {
this.#assertOpen();
if (this.#mutationPending) {
throw new Error('A Client Capability registration mutation is already pending');
}
this.#mutationPending = true;
const registrationId = randomUUID();
let registration: ClientCapabilityRegistration | undefined;
try {
const services = provider.services?.() ?? [];
const canonical = decodeClientCapabilityReplaceInput({
registrationId,
offers: provider.offers(),
...(services.length === 0 ? {} : { services }),
});
registration = {
registrationId,
provider,
offers: canonical.offers,
services: canonical.services ?? [],
};
this.#registrations.set(registrationId, registration);
const result = await this.#options.replace(canonical, timeoutMs);
if (result.registrationId !== registrationId) {
throw new Error('Runtime Host replaced a different Client Capability registration');
}
this.#currentRegistrationId = registrationId;
this.#collectReleasedRegistrations();
return result;
} catch (error) {
if (registration && this.#currentRegistrationId !== registrationId) {
this.#registrations.delete(registrationId);
}
throw error;
} finally {
this.#mutationPending = false;
}
}
async unregister(timeoutMs: number): Promise<ClientCapabilityUnregisterResult> {
this.#assertOpen();
if (this.#mutationPending) {
throw new Error('A Client Capability registration mutation is already pending');
}
const registrationId = this.#currentRegistrationId;
if (!registrationId) throw new Error('No Client Capability registration is active');
this.#mutationPending = true;
try {
const result = await this.#options.unregister({ registrationId }, timeoutMs);
if (result.registrationId !== registrationId) {
throw new Error('Runtime Host unregistered a different Client Capability registration');
}
if (this.#currentRegistrationId === registrationId) {
this.#currentRegistrationId = undefined;
}
this.#collectReleasedRegistrations();
return result;
} finally {
this.#mutationPending = false;
}
}
accept(frame: ClientCapabilityHostFrame): void {
this.#assertOpen();
switch (frame.kind) {
case 'client.capability.call':
this.#acceptCall(frame);
return;
case 'client.capability.service_call':
this.#acceptServiceCall(frame);
return;
case 'client.capability.cancel': {
const invocation = this.#invocations.get(frame.invocationId);
if (!invocation) return;
invocation.released = true;
invocation.controller.abort(
new DOMException('Client Capability invocation was cancelled', 'AbortError'),
);
invocation.admission?.reject(capabilityInvocationAbortReason(invocation));
return;
}
case 'client.capability.release': {
const invocation = this.#invocations.get(frame.invocationId);
if (!invocation) return;
invocation.released = true;
invocation.controller.abort(
new DOMException('Client Capability invocation was released', 'AbortError'),
);
invocation.admission?.reject(capabilityInvocationAbortReason(invocation));
this.#invocations.delete(frame.invocationId);
return;
}
case 'client.capability.registration_release':
this.#releasedRegistrationIds.add(frame.registrationId);
this.#collectReleasedRegistrations();
return;
case 'client.capability.admitted': {
const invocation = this.#invocations.get(frame.invocationId);
if (!invocation?.admission || !invocation.admission.resolve()) {
throw new Error('Runtime Host returned an unmatched capability admission');
}
return;
}
}
}
close(error: Error): void {
if (this.#closedError) return;
this.#closedError = error;
for (const invocation of this.#invocations.values()) {
invocation.released = true;
invocation.controller.abort(error);
invocation.admission?.reject(error);
}
this.#invocations.clear();
const providers = new Set(
[...this.#registrations.values()].map((registration) => registration.provider),
);
this.#registrations.clear();
this.#releasedRegistrationIds.clear();
this.#currentRegistrationId = undefined;
for (const provider of providers) this.#closeProvider(provider);
}
#acceptCall(frame: ClientCapabilityCallFrame): void {
if (this.#invocations.has(frame.invocationId)) {
throw new Error('Runtime Host repeated a Client Capability invocation identity');
}
const registration = this.#registrations.get(frame.registrationId);
const offered = registration?.offers
.find((offer) => offer.offerId === frame.offerId)
?.tools.some((tool) => tool.serverId === frame.serverId && tool.name === frame.toolName);
if (!registration || !offered || !registration.provider.call) {
void this.#options
.write({
kind: 'client.capability.rejected',
invocationId: frame.invocationId,
message: 'Client Capability registration or tool is unavailable',
})
.catch((error: unknown) => this.#options.onFailure(asError(error)));
return;
}
const invocation: ClientCapabilityInvocation = {
controller: new AbortController(),
released: false,
};
this.#invocations.set(frame.invocationId, invocation);
void this.#runInvocation(frame.invocationId, invocation, (options) =>
registration.provider.call!(frame, options),
);
}
#acceptServiceCall(frame: ClientCapabilityServiceCallFrame): void {
if (this.#invocations.has(frame.invocationId)) {
throw new Error('Runtime Host repeated a Client Capability invocation identity');
}
const registration = this.#registrations.get(frame.registrationId);
const offered = registration?.services.some(
(service) => service.serviceId === frame.serviceId && service.version === frame.version,
);
if (!registration || !offered || !registration.provider.callService) {
void this.#options
.write({
kind: 'client.capability.rejected',
invocationId: frame.invocationId,
message: 'Client Capability registration or service is unavailable',
})
.catch((error: unknown) => this.#options.onFailure(asError(error)));
return;
}
const invocation: ClientCapabilityInvocation = {
controller: new AbortController(),
released: false,
};
this.#invocations.set(frame.invocationId, invocation);
void this.#runInvocation(frame.invocationId, invocation, async (options) =>
decodeClientCapabilityResult({
content: [],
structuredContent: await registration.provider.callService!(frame, options),
}),
);
}
async #runInvocation(
invocationId: string,
invocation: ClientCapabilityInvocation,
execute: (options: {
readonly signal: AbortSignal;
accept(): Promise<void>;
}) => Promise<ReturnType<typeof decodeClientCapabilityResult>>,
): Promise<void> {
let accepted = false;
let accepting: Promise<void> | undefined;
const accept = (): Promise<void> => {
if (invocation.released) {
return Promise.reject(capabilityInvocationAbortReason(invocation));
}
const admission = (invocation.admission ??= createClientCapabilityAdmission());
accepting ??= Promise.all([
this.#options.write({
kind: 'client.capability.accepted',
invocationId,
}),
admission.promise,
]).then(() => {
accepted = true;
if (invocation.released) throw capabilityInvocationAbortReason(invocation);
});
return accepting;
};
try {
const result = decodeClientCapabilityResult(
await execute({
signal: invocation.controller.signal,
accept,
}),
);
if (invocation.released) return;
await accept();
await this.#sendResult(invocationId, result, invocation);
} catch (error) {
if (invocation.released) return;
try {
await this.#options.write({
kind: accepted ? 'client.capability.failed' : 'client.capability.rejected',
invocationId,
message: capabilityFailureMessage(error),
});
} catch (writeError) {
this.#options.onFailure(asError(writeError));
}
}
}
async #sendResult(
invocationId: string,
result: ReturnType<typeof decodeClientCapabilityResult>,
invocation: ClientCapabilityInvocation,
): Promise<void> {
const encoded = Buffer.from(JSON.stringify(result), 'utf8');
if (encoded.byteLength > CLIENT_CAPABILITY_MAX_RESULT_BYTES) {
throw new Error('Client Capability result exceeds the byte limit');
}
if (encoded.byteLength <= 32 * 1024) {
await this.#options.write({
kind: 'client.capability.result',
invocationId,
result,
});
return;
}
const chunkCount = Math.ceil(encoded.byteLength / CLIENT_CAPABILITY_RESULT_CHUNK_MAX_BYTES);
await this.#options.write({
kind: 'client.capability.result_start',
invocationId,
byteLength: encoded.byteLength,
chunkCount,
});
for (let index = 0; index < chunkCount; index += 1) {
if (invocation.released) return;
const start = index * CLIENT_CAPABILITY_RESULT_CHUNK_MAX_BYTES;
await this.#options.write({
kind: 'client.capability.result_chunk',
invocationId,
index,
data: encoded
.subarray(start, start + CLIENT_CAPABILITY_RESULT_CHUNK_MAX_BYTES)
.toString('base64'),
});
}
}
#collectReleasedRegistrations(): void {
for (const registrationId of this.#releasedRegistrationIds) {
if (registrationId === this.#currentRegistrationId) continue;
this.#releasedRegistrationIds.delete(registrationId);
const registration = this.#registrations.get(registrationId);
if (!registration) continue;
this.#registrations.delete(registrationId);
if (
![...this.#registrations.values()].some(
(candidate) => candidate.provider === registration.provider,
)
) {
this.#closeProvider(registration.provider);
}
}
}
#closeProvider(provider: ClientCapabilityProvider): void {
try {
void Promise.resolve(provider.close?.()).catch((error: unknown) =>
this.#options.onFailure(asError(error)),
);
} catch (error) {
this.#options.onFailure(asError(error));
}
}
#assertOpen(): void {
if (this.#closedError) throw this.#closedError;
}
}
function createClientCapabilityAdmission(): ClientCapabilityAdmission {
let state: 'pending' | 'resolved' | 'rejected' = 'pending';
let resolvePromise!: () => void;
let rejectPromise!: (error: unknown) => void;
const promise = new Promise<void>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
return {
promise,
resolve: () => {
if (state !== 'pending') return false;
state = 'resolved';
resolvePromise();
return true;
},
reject: (error) => {
if (state !== 'pending') return false;
state = 'rejected';
rejectPromise(error);
return true;
},
};
}
function capabilityInvocationAbortReason(invocation: ClientCapabilityInvocation): unknown {
return (
invocation.controller.signal.reason ??
new DOMException('Client Capability invocation was released', 'AbortError')
);
}
function capabilityFailureMessage(value: unknown): string {
const message = asError(value).message.trim() || 'Client Capability provider failed';
return message.slice(0, 4_096);
}
function asError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value));
}