Download src/mcp/client.ts from bolikoto/zcode-api: direct link, hf CLI and curl.
- Browser
- Download file 11.6 kB
-
https://huggingface.co/spaces/bolikoto/zcode-api/resolve/main/src/mcp/client.ts
- Command line
-
hf download hf://spaces/bolikoto/zcode-api/src/mcp/client.ts
-
curl -L -o client.ts https://huggingface.co/spaces/bolikoto/zcode-api/resolve/main/src/mcp/client.ts
11.6 kB
| /** | |
| * Minimal MCP (Model Context Protocol) JSON-RPC client for GLM's remote MCP | |
| * servers. Designed for the GLM wire shape verified by live probing (see | |
| * `_reverse/NOTEPAD.md` and the exploration notes): | |
| * | |
| * - Transport: Streamable HTTP over POST; responses are ALWAYS | |
| * `text/event-stream` (server ignores `Accept: application/json`). | |
| * - Each SSE frame is a 3-line block: | |
| * `id:1` (SSE event id, fixed β NOT the JSON-RPC id, ignore it) | |
| * `event:message` | |
| * `data:{<json-rpc payload>}` | |
| * - Sessions are mandatory: `initialize` returns `Mcp-Session-Id`, and every | |
| * subsequent request MUST carry it back as a header. | |
| * - Protocol version: client sends `2025-06-18`; server downgrades to | |
| * `2024-11-05`. We accept whatever the server returns. | |
| * - `notifications/initialized` must be sent once after `initialize` (HTTP | |
| * 200 with empty body, no SSE). | |
| * - Auth failure returns HTTP 200 with a non-JSON-RPC envelope: | |
| * `{"code":1001,"msg":"...","success":false}` (not a JSON-RPC error) β | |
| * the client detects this and throws `McpAuthError`. | |
| * - Tool results carry a `content[]` array of `{type:"text",text}` blocks; | |
| * some tools (web_search_prime) return a *doubly-encoded* JSON string in | |
| * `text` (a stringified JSON array) β the caller decodes. | |
| * | |
| * No external dependencies. Pure `fetch()`. Works on both Bun and Node. | |
| * | |
| * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle | |
| * @see https://modelcontextprotocol.io/specification/2025-06-18/server/tools | |
| */ | |
| import type { ProviderId } from "../provider/types.js"; | |
| /** MCP tool definition (subset we care about). */ | |
| export interface McpToolDef { | |
| name: string; | |
| description?: string; | |
| inputSchema?: Record<string, unknown>; | |
| } | |
| /** Result of a successful tools/call. */ | |
| export interface McpToolCallResult { | |
| content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType?: string }>; | |
| isError: boolean; | |
| } | |
| /** Thrown when the server returns the non-JSON-RPC auth envelope. */ | |
| export class McpAuthError extends Error { | |
| readonly code: number; | |
| constructor(code: number, msg: string) { | |
| super(`MCP auth error (code=${code}): ${msg}`); | |
| this.name = "McpAuthError"; | |
| this.code = code; | |
| } | |
| } | |
| /** Thrown when the server returns a JSON-RPC `error` object. */ | |
| export class McpRpcError extends Error { | |
| readonly code: number; | |
| constructor(code: number, message: string) { | |
| super(`MCP RPC error (code=${code}): ${message}`); | |
| this.name = "McpRpcError"; | |
| this.code = code; | |
| } | |
| } | |
| export interface McpClientOptions { | |
| /** Full MCP endpoint URL, e.g. `https://open.bigmodel.cn/api/mcp/web_search_prime/mcp`. */ | |
| url: string; | |
| /** Bearer credential β same GLM API key used for the LLM upstream. */ | |
| apiKey: string; | |
| /** Protocol version to advertise in `initialize`; default `2025-06-18`. */ | |
| protocolVersion?: string; | |
| /** Per-request timeout in ms; default 30000. */ | |
| timeoutMs?: number; | |
| /** DI seam for tests. Defaults to global `fetch`. */ | |
| fetchImpl?: typeof fetch; | |
| } | |
| interface JsonRpcResponse { | |
| jsonrpc: "2.0"; | |
| id?: number | string; | |
| result?: unknown; | |
| error?: { code: number; message: string; data?: unknown }; | |
| } | |
| const DEFAULT_PROTOCOL_VERSION = "2025-06-18"; | |
| const DEFAULT_TIMEOUT_MS = 30000; | |
| /** | |
| * Stateless-per-instance is forbidden by GLM (session is required), so a client | |
| * owns its session. One instance per (server, apiKey) β pool via `GlmMcpPool`. | |
| */ | |
| export class McpClient { | |
| private readonly url: string; | |
| private readonly apiKey: string; | |
| private readonly protocolVersion: string; | |
| private readonly timeoutMs: number; | |
| private readonly fetchImpl: typeof fetch; | |
| private sessionId: string | undefined; | |
| private nextId = 1; | |
| private initialized = false; | |
| constructor(opts: McpClientOptions) { | |
| this.url = opts.url; | |
| this.apiKey = opts.apiKey; | |
| this.protocolVersion = opts.protocolVersion ?? DEFAULT_PROTOCOL_VERSION; | |
| this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; | |
| this.fetchImpl = opts.fetchImpl ?? fetch; | |
| } | |
| /** | |
| * Perform the MCP handshake: `initialize` (captures session id) + | |
| * `notifications/initialized`. Idempotent β subsequent calls are no-ops. | |
| * Called lazily by `listTools` / `callTool`. | |
| */ | |
| async initialize(): Promise<void> { | |
| if (this.initialized) return; | |
| const initResp = await this.postRpc("initialize", { | |
| protocolVersion: this.protocolVersion, | |
| capabilities: {}, | |
| clientInfo: { name: "zcode-proxy", version: "2.2.0" }, | |
| }); | |
| const result = initResp.json.result as { protocolVersion?: string } | undefined; | |
| // GLM downgrades the version; we accept the server's choice silently. | |
| void result?.protocolVersion; | |
| this.initialized = true; | |
| // notifications/initialized has no response body; swallow the empty SSE. | |
| try { | |
| await this.postRpc("notifications/initialized", undefined); | |
| } catch { | |
| // Some servers return empty bodies that fail SSE parse β safe to ignore. | |
| } | |
| } | |
| /** List the tools the server exposes. */ | |
| async listTools(): Promise<McpToolDef[]> { | |
| await this.initialize(); | |
| const resp = await this.postRpc("tools/list", {}); | |
| const result = resp.json.result as { tools?: McpToolDef[] } | undefined; | |
| return result?.tools ?? []; | |
| } | |
| /** Invoke a tool. Returns the raw `content[]`; callers decode per-tool. */ | |
| async callTool(name: string, args: Record<string, unknown>): Promise<McpToolCallResult> { | |
| await this.initialize(); | |
| const resp = await this.postRpc("tools/call", { name, arguments: args }); | |
| const result = resp.json.result as McpToolCallResult | undefined; | |
| if (!result || !Array.isArray(result.content)) { | |
| throw new McpRpcError(-32603, `tool "${name}" returned malformed result`); | |
| } | |
| return result; | |
| } | |
| /** Drop the session. Next call re-initializes. Safe to call when not initialized. */ | |
| reset(): void { | |
| this.sessionId = undefined; | |
| this.initialized = false; | |
| } | |
| /** | |
| * Send a JSON-RPC request and parse the SSE response. For notifications | |
| * (no `id`) the response is empty β the parser tolerates a 0-event stream. | |
| */ | |
| private async postRpc( | |
| method: string, | |
| params: unknown, | |
| ): Promise<{ json: JsonRpcResponse; headers: Headers }> { | |
| const id = method.startsWith("notifications/") ? undefined : this.nextId++; | |
| const body: string = JSON.stringify({ | |
| jsonrpc: "2.0", | |
| ...(id !== undefined ? { id } : {}), | |
| method, | |
| ...(params !== undefined ? { params } : {}), | |
| }); | |
| const controller = new AbortController(); | |
| const timer = setTimeout(() => controller.abort(), this.timeoutMs); | |
| let httpResp: Response; | |
| try { | |
| httpResp = await this.fetchImpl(this.url, { | |
| method: "POST", | |
| headers: { | |
| "content-type": "application/json", | |
| accept: "application/json, text/event-stream", | |
| authorization: `Bearer ${this.apiKey}`, | |
| ...(this.sessionId ? { "mcp-session-id": this.sessionId } : {}), | |
| }, | |
| body, | |
| signal: controller.signal, | |
| }); | |
| } finally { | |
| clearTimeout(timer); | |
| } | |
| if (!httpResp.ok) { | |
| throw new McpRpcError(-32000, `HTTP ${httpResp.status} ${httpResp.statusText}`); | |
| } | |
| // Capture the session id on the FIRST response (initialize). | |
| if (!this.sessionId) { | |
| const sid = httpResp.headers.get("mcp-session-id"); | |
| if (sid) this.sessionId = sid; | |
| } | |
| const contentType = httpResp.headers.get("content-type") ?? ""; | |
| const raw = await httpResp.text(); | |
| // GLM's auth failures are HTTP 200 + a non-JSON-RPC envelope. | |
| if (!contentType.includes("event-stream") && !contentType.includes("application/json")) { | |
| // Still inspect the body β some auth failures come back without a proper CT. | |
| throw this.classifyEnvelope(raw); | |
| } | |
| // Empty body (notifications/initialized) β no JSON-RPC payload to return. | |
| if (raw.length === 0 || raw.trim() === "") { | |
| return { json: { jsonrpc: "2.0" }, headers: httpResp.headers }; | |
| } | |
| const payload = contentType.includes("event-stream") | |
| ? parseSseFrame(raw) | |
| : safeParseJson(raw); | |
| if (payload === null) { | |
| // Could be the auth envelope on a 200 with no/odd CT. | |
| throw this.classifyEnvelope(raw); | |
| } | |
| // Detect GLM's auth envelope even when it leaks into a JSON-looking CT. | |
| if ( | |
| typeof payload === "object" | |
| && payload !== null | |
| && "success" in payload | |
| && payload.success === false | |
| && typeof (payload as { code?: unknown }).code === "number" | |
| && !("jsonrpc" in payload) | |
| ) { | |
| const env = payload as unknown as { code: number; msg?: string }; | |
| throw new McpAuthError(env.code, env.msg ?? "auth failed"); | |
| } | |
| const jsonRpc = payload as JsonRpcResponse; | |
| if (jsonRpc.error) { | |
| throw new McpRpcError(jsonRpc.error.code, jsonRpc.error.message); | |
| } | |
| return { json: jsonRpc, headers: httpResp.headers }; | |
| } | |
| /** Parse a body as JSON; if it looks like GLM's `{code, msg, success:false}` envelope, return the right error. */ | |
| private classifyEnvelope(raw: string): Error { | |
| const parsed = safeParseJson(raw); | |
| if ( | |
| parsed !== null | |
| && typeof parsed === "object" | |
| && parsed !== null | |
| && "success" in parsed | |
| && (parsed as { success?: unknown }).success === false | |
| && typeof (parsed as { code?: unknown }).code === "number" | |
| ) { | |
| const env = parsed as unknown as { code: number; msg?: string }; | |
| return new McpAuthError(env.code, env.msg ?? "auth failed"); | |
| } | |
| return new McpRpcError(-32700, `unparseable MCP response (content-type missing, body: ${raw.slice(0, 200)})`); | |
| } | |
| } | |
| /** | |
| * Parse one SSE frame from GLM's response into a JSON value. | |
| * | |
| * GLM emits exactly one `data:` line per response, so this is simpler than a | |
| * full streaming parser. The frame shape (verified by probing): | |
| * | |
| * id:1 | |
| * event:message | |
| * data:{"jsonrpc":"2.0",...} | |
| * | |
| * `id:1` is the SSE event id (fixed at 1 by the server β not the JSON-RPC id). | |
| * We ignore it and parse only the `data:` payload. | |
| */ | |
| function parseSseFrame(raw: string): unknown { | |
| let dataLine: string | null = null; | |
| for (const line of raw.split(/\r?\n/)) { | |
| if (line.startsWith("data:")) { | |
| // GLM uses `data:{...}` (no space). Tolerate both forms. | |
| dataLine = line.slice(5).replace(/^\s/, ""); | |
| break; | |
| } | |
| } | |
| if (dataLine === null) return null; | |
| return safeParseJson(dataLine); | |
| } | |
| function safeParseJson(raw: string): unknown { | |
| try { | |
| return JSON.parse(raw); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // Provider-aware endpoint derivation | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| /** GLM MCP host per provider (verified: both providers serve identical MCP shapes). */ | |
| const MCP_HOSTS: Record<ProviderId, string> = { | |
| bigmodel: "https://open.bigmodel.cn", | |
| zai: "https://api.z.ai", | |
| }; | |
| /** GLM MCP server identifiers. Path is `/api/mcp/{server}/mcp` for both providers. */ | |
| export type GlmMcpServerId = "web_search_prime" | "web_reader" | "zread"; | |
| /** Build the full MCP endpoint URL for a (provider, server) pair. */ | |
| export function glmMcpEndpoint(provider: ProviderId, server: GlmMcpServerId): string { | |
| return `${MCP_HOSTS[provider]}/api/mcp/${server}/mcp`; | |
| } | |