typescript-sdk/src/shared/protocol.ts
2024-11-14 13:57:04 +00:00

489 lines
13 KiB
TypeScript

import { ZodLiteral, ZodObject, ZodType, z } from "zod";
import {
CancelledNotificationSchema,
ErrorCode,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
McpError,
Notification,
PingRequestSchema,
Progress,
ProgressNotification,
ProgressNotificationSchema,
Request,
RequestId,
Result,
} from "../types.js";
import { Transport } from "./transport.js";
/**
* Callback for progress notifications.
*/
export type ProgressCallback = (progress: Progress) => void;
/**
* Additional initialization options.
*/
export type ProtocolOptions = {
/**
* Whether to restrict emitted requests to only those that the remote side has indicated that they can handle, through their advertised capabilities.
*
* Note that this DOES NOT affect checking of _local_ side capabilities, as it is considered a logic error to mis-specify those.
*
* Currently this defaults to false, for backwards compatibility with SDK versions that did not advertise capabilities correctly. In future, this will default to true.
*/
enforceStrictCapabilities?: boolean;
};
/**
* Options that can be given per request.
*/
export type RequestOptions = {
/**
* If set, requests progress notifications from the remote end (if supported). When progress notifications are received, this callback will be invoked.
*/
onprogress?: ProgressCallback;
/**
* Can be used to cancel an in-flight request. This will cause an AbortError to be raised from request().
*/
signal?: AbortSignal;
};
/**
* Extra data given to request handlers.
*/
export type RequestHandlerExtra = {
/**
* An abort signal used to communicate if the request was cancelled from the sender's side.
*/
signal: AbortSignal;
};
/**
* Implements MCP protocol framing on top of a pluggable transport, including
* features like request/response linking, notifications, and progress.
*/
export abstract class Protocol<
SendRequestT extends Request,
SendNotificationT extends Notification,
SendResultT extends Result,
> {
private _transport?: Transport;
private _requestMessageId = 0;
private _requestHandlers: Map<
string,
(
request: JSONRPCRequest,
extra: RequestHandlerExtra,
) => Promise<SendResultT>
> = new Map();
private _requestHandlerAbortControllers: Map<RequestId, AbortController> =
new Map();
private _notificationHandlers: Map<
string,
(notification: JSONRPCNotification) => Promise<void>
> = new Map();
private _responseHandlers: Map<
number,
(response: JSONRPCResponse | Error) => void
> = new Map();
private _progressHandlers: Map<number, ProgressCallback> = new Map();
/**
* Callback for when the connection is closed for any reason.
*
* This is invoked when close() is called as well.
*/
onclose?: () => void;
/**
* Callback for when an error occurs.
*
* Note that errors are not necessarily fatal; they are used for reporting any kind of exceptional condition out of band.
*/
onerror?: (error: Error) => void;
/**
* A handler to invoke for any request types that do not have their own handler installed.
*/
fallbackRequestHandler?: (request: Request) => Promise<SendResultT>;
/**
* A handler to invoke for any notification types that do not have their own handler installed.
*/
fallbackNotificationHandler?: (notification: Notification) => Promise<void>;
constructor(private _options?: ProtocolOptions) {
this.setNotificationHandler(CancelledNotificationSchema, (notification) => {
const controller = this._requestHandlerAbortControllers.get(
notification.params.requestId,
);
controller?.abort(notification.params.reason);
});
this.setNotificationHandler(ProgressNotificationSchema, (notification) => {
this._onprogress(notification as unknown as ProgressNotification);
});
this.setRequestHandler(
PingRequestSchema,
// Automatic pong by default.
(_request) => ({}) as SendResultT,
);
}
/**
* Attaches to the given transport, starts it, and starts listening for messages.
*
* The Protocol object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward.
*/
async connect(transport: Transport): Promise<void> {
this._transport = transport;
this._transport.onclose = () => {
this._onclose();
};
this._transport.onerror = (error: Error) => {
this._onerror(error);
};
this._transport.onmessage = (message) => {
if (!("method" in message)) {
this._onresponse(message);
} else if ("id" in message) {
this._onrequest(message);
} else {
this._onnotification(message);
}
};
await this._transport.start();
}
private _onclose(): void {
const responseHandlers = this._responseHandlers;
this._responseHandlers = new Map();
this._progressHandlers.clear();
this._transport = undefined;
this.onclose?.();
const error = new McpError(ErrorCode.ConnectionClosed, "Connection closed");
for (const handler of responseHandlers.values()) {
handler(error);
}
}
private _onerror(error: Error): void {
this.onerror?.(error);
}
private _onnotification(notification: JSONRPCNotification): void {
const handler =
this._notificationHandlers.get(notification.method) ??
this.fallbackNotificationHandler;
// Ignore notifications not being subscribed to.
if (handler === undefined) {
return;
}
handler(notification).catch((error) =>
this._onerror(
new Error(`Uncaught error in notification handler: ${error}`),
),
);
}
private _onrequest(request: JSONRPCRequest): void {
const handler =
this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler;
if (handler === undefined) {
this._transport
?.send({
jsonrpc: "2.0",
id: request.id,
error: {
code: ErrorCode.MethodNotFound,
message: "Method not found",
},
})
.catch((error) =>
this._onerror(
new Error(`Failed to send an error response: ${error}`),
),
);
return;
}
const abortController = new AbortController();
this._requestHandlerAbortControllers.set(request.id, abortController);
handler(request, { signal: abortController.signal })
.then(
(result) => {
if (abortController.signal.aborted) {
return;
}
return this._transport?.send({
result,
jsonrpc: "2.0",
id: request.id,
});
},
(error) => {
if (abortController.signal.aborted) {
return;
}
return this._transport?.send({
jsonrpc: "2.0",
id: request.id,
error: {
code: Number.isSafeInteger(error["code"])
? error["code"]
: ErrorCode.InternalError,
message: error.message ?? "Internal error",
},
});
},
)
.catch((error) =>
this._onerror(new Error(`Failed to send response: ${error}`)),
)
.finally(() => {
this._requestHandlerAbortControllers.delete(request.id);
});
}
private _onprogress(notification: ProgressNotification): void {
const { progress, total, progressToken } = notification.params;
const handler = this._progressHandlers.get(Number(progressToken));
if (handler === undefined) {
this._onerror(
new Error(
`Received a progress notification for an unknown token: ${JSON.stringify(notification)}`,
),
);
return;
}
handler({ progress, total });
}
private _onresponse(response: JSONRPCResponse | JSONRPCError): void {
const messageId = response.id;
const handler = this._responseHandlers.get(Number(messageId));
if (handler === undefined) {
this._onerror(
new Error(
`Received a response for an unknown message ID: ${JSON.stringify(response)}`,
),
);
return;
}
this._responseHandlers.delete(Number(messageId));
this._progressHandlers.delete(Number(messageId));
if ("result" in response) {
handler(response);
} else {
const error = new McpError(
response.error.code,
response.error.message,
response.error.data,
);
handler(error);
}
}
get transport(): Transport | undefined {
return this._transport;
}
/**
* Closes the connection.
*/
async close(): Promise<void> {
await this._transport?.close();
}
/**
* A method to check if a capability is supported by the remote side, for the given method to be called.
*
* This should be implemented by subclasses.
*/
protected abstract assertCapabilityForMethod(
method: SendRequestT["method"],
): void;
/**
* A method to check if a notification is supported by the local side, for the given method to be sent.
*
* This should be implemented by subclasses.
*/
protected abstract assertNotificationCapability(
method: SendNotificationT["method"],
): void;
/**
* A method to check if a request handler is supported by the local side, for the given method to be handled.
*
* This should be implemented by subclasses.
*/
protected abstract assertRequestHandlerCapability(method: string): void;
/**
* Sends a request and wait for a response.
*
* Do not use this method to emit notifications! Use notification() instead.
*/
request<T extends ZodType<object>>(
request: SendRequestT,
resultSchema: T,
options?: RequestOptions,
): Promise<z.infer<T>> {
return new Promise((resolve, reject) => {
if (!this._transport) {
reject(new Error("Not connected"));
return;
}
if (this._options?.enforceStrictCapabilities === true) {
this.assertCapabilityForMethod(request.method);
}
options?.signal?.throwIfAborted();
const messageId = this._requestMessageId++;
const jsonrpcRequest: JSONRPCRequest = {
...request,
jsonrpc: "2.0",
id: messageId,
};
if (options?.onprogress) {
this._progressHandlers.set(messageId, options.onprogress);
jsonrpcRequest.params = {
...request.params,
_meta: { progressToken: messageId },
};
}
this._responseHandlers.set(messageId, (response) => {
if (options?.signal?.aborted) {
return;
}
if (response instanceof Error) {
return reject(response);
}
try {
const result = resultSchema.parse(response.result);
resolve(result);
} catch (error) {
reject(error);
}
});
options?.signal?.addEventListener("abort", () => {
const reason = options?.signal?.reason;
this._responseHandlers.delete(messageId);
this._progressHandlers.delete(messageId);
this._transport?.send({
jsonrpc: "2.0",
method: "cancelled",
params: {
requestId: messageId,
reason: String(reason),
},
});
reject(reason);
});
this._transport.send(jsonrpcRequest).catch(reject);
});
}
/**
* Emits a notification, which is a one-way message that does not expect a response.
*/
async notification(notification: SendNotificationT): Promise<void> {
if (!this._transport) {
throw new Error("Not connected");
}
this.assertNotificationCapability(notification.method);
const jsonrpcNotification: JSONRPCNotification = {
...notification,
jsonrpc: "2.0",
};
await this._transport.send(jsonrpcNotification);
}
/**
* Registers a handler to invoke when this protocol object receives a request with the given method.
*
* Note that this will replace any previous request handler for the same method.
*/
setRequestHandler<
T extends ZodObject<{
method: ZodLiteral<string>;
}>,
>(
requestSchema: T,
handler: (
request: z.infer<T>,
extra: RequestHandlerExtra,
) => SendResultT | Promise<SendResultT>,
): void {
const method = requestSchema.shape.method.value;
this.assertRequestHandlerCapability(method);
this._requestHandlers.set(method, (request, extra) =>
Promise.resolve(handler(requestSchema.parse(request), extra)),
);
}
/**
* Removes the request handler for the given method.
*/
removeRequestHandler(method: string): void {
this._requestHandlers.delete(method);
}
/**
* Registers a handler to invoke when this protocol object receives a notification with the given method.
*
* Note that this will replace any previous notification handler for the same method.
*/
setNotificationHandler<
T extends ZodObject<{
method: ZodLiteral<string>;
}>,
>(
notificationSchema: T,
handler: (notification: z.infer<T>) => void | Promise<void>,
): void {
this._notificationHandlers.set(
notificationSchema.shape.method.value,
(notification) =>
Promise.resolve(handler(notificationSchema.parse(notification))),
);
}
/**
* Removes the notification handler for the given method.
*/
removeNotificationHandler(method: string): void {
this._notificationHandlers.delete(method);
}
}