repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/web/mcp.ts
Raw/** Single-call MCP sessions shared by search providers, not a general MCP client. */
export interface McpToolResult {
isError?: boolean;
content?: Array<{ type?: string; text?: string }>;
}
export class McpError extends Error {
readonly status: number;
readonly code?: number | string;
readonly response?: Response;
constructor(
message: string,
status: number,
code?: number | string,
response?: Response,
) {
super(message);
this.name = "McpError";
this.status = status;
this.code = code;
this.response = response;
}
}
interface RpcMessage {
id?: number;
result?: unknown;
error?: { code?: number | string; message?: string };
}
function parseMessage(text: string, id: number, status: number): RpcMessage {
let messages: RpcMessage[];
try {
messages = text.trimStart().startsWith("{")
? [JSON.parse(text)]
: text.split(/\r?\n\r?\n/).flatMap((event) => {
const data = event
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart())
.join("\n");
return data && data !== "[DONE]" ? [JSON.parse(data)] : [];
});
} catch {
throw new McpError(`Malformed MCP response: ${text.slice(0, 500)}`, status);
}
const message = messages.find((entry) => entry?.id === id);
if (!message) throw new McpError("no matching MCP response", status);
if (message.error)
throw new McpError(
message.error.message ?? "request failed",
status,
message.error.code,
);
return message;
}
export async function callMcpTool(options: {
url: string;
protocolVersion: string;
headers?: Record<string, string>;
signal: AbortSignal;
name: string;
arguments: Record<string, unknown>;
}): Promise<McpToolResult> {
let sessionId: string | undefined;
let protocolVersion = options.protocolVersion;
async function post(
method: string,
params: object,
id?: number,
): Promise<unknown> {
options.signal.throwIfAborted();
const response = await fetch(options.url, {
method: "POST",
redirect: "error",
headers: {
...options.headers,
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"MCP-Protocol-Version": protocolVersion,
...(sessionId ? { "mcp-session-id": sessionId } : {}),
},
body: JSON.stringify({
jsonrpc: "2.0",
...(id === undefined ? {} : { id }),
method,
params,
}),
signal: options.signal,
});
if (!response.ok)
throw new McpError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
undefined,
response,
);
sessionId = response.headers.get("mcp-session-id") ?? sessionId;
const text = await response.text();
if (id === undefined) return undefined;
const result = parseMessage(text, id, response.status).result;
if (method === "initialize") {
const initialized = result as { protocolVersion?: unknown } | undefined;
if (
typeof initialized?.protocolVersion !== "string" ||
!initialized.protocolVersion
) {
throw new McpError("MCP initialization failed", response.status);
}
protocolVersion = initialized.protocolVersion;
}
return result;
}
await post(
"initialize",
{
protocolVersion,
capabilities: {},
clientInfo: { name: "pi-web-extension", version: "0.1.0" },
},
1,
);
await post("notifications/initialized", {});
const result = await post(
"tools/call",
{ name: options.name, arguments: options.arguments },
2,
);
if (!result || typeof result !== "object" || Array.isArray(result)) {
throw new McpError("MCP tool returned an invalid result", 200);
}
return result as McpToolResult;
}