From 0b664331e0a3771a0621671d26f4ca5ec32f4874 Mon Sep 17 00:00:00 2001 From: TriDefender <173548745+TriDefender@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:56:31 +0000 Subject: [PATCH] Added MCP support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将zcode的套餐内MCP也反代出来用 --- config.example.yaml | 15 ++ package.json | 3 +- scripts/regen-mcp-catalogue.ts | 269 +++++++++++++++++++++ src/config/loader.test.ts | 64 ++++- src/config/loader.ts | 17 ++ src/config/types.ts | 25 ++ src/mcp/catalogue.ts | 108 +++++++++ src/mcp/official-catalogue.json | 277 ++++++++++++++++++++++ src/mcp/relay.test.ts | 162 +++++++++++++ src/mcp/relay.ts | 138 +++++++++++ src/proxy/handler-debug.test.ts | 2 +- src/proxy/handler-resilience.test.ts | 2 +- src/proxy/handler-routing-signing.test.ts | 2 +- src/proxy/ordered-transport.test.ts | 4 +- src/proxy/responses-handler.test.ts | 2 +- src/proxy/session-context.test.ts | 2 +- src/proxy/upstream.test.ts | 12 +- src/server/routes-async.test.ts | 2 +- src/server/routes-mcp.test.ts | 222 +++++++++++++++++ src/server/routes-mcp.ts | 200 ++++++++++++++++ src/server/routes-quota.test.ts | 2 +- src/server/server.test.ts | 20 +- src/server/server.ts | 68 +++++- 23 files changed, 1585 insertions(+), 33 deletions(-) create mode 100644 scripts/regen-mcp-catalogue.ts create mode 100644 src/mcp/catalogue.ts create mode 100644 src/mcp/official-catalogue.json create mode 100644 src/mcp/relay.test.ts create mode 100644 src/mcp/relay.ts create mode 100644 src/server/routes-mcp.test.ts create mode 100644 src/server/routes-mcp.ts diff --git a/config.example.yaml b/config.example.yaml index 862a55c..4337e80 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -101,6 +101,21 @@ mcp: webReader: false # (planned) Inject the three `zread` tools (search_doc/get_repo_structure/read_file). zread: false + # Official plugin-MCP gateway relay (tianyancha / Wind / 同花顺 iFinD ...). + # GET /mcp lists the served endpoints; /mcp/{server} relays MCP streamable + # HTTP to Z.AI's official gateway with your OAuth login injected — inbound + # callers authenticate with the same proxyApiKey as the LLM routes. + # The server table ships with the build (scripts/regen-mcp-catalogue.ts). + # Note: `mcp.enabled` above gates only the (unwired) GLM hosted-tool plane — + # this relay is controlled independently by `gateway.enabled`. Since the + # relay spends your paid data entitlements, setting auth.proxyApiKey is + # strongly recommended (CORS headers are only emitted when a key is set). + gateway: + enabled: true + # `${ZCODE_BASE_URL}` substituted into plugin .mcp.json URLs. + # Production default per the desktop bundle (`jee`); the + # chatglm.site variant is the TEST env origin. + upstreamOrigin: https://zcode.z.ai # Async (off-peak / "idle plan") bridge. # When enabled, exposes POST /async/v1/messages and /async/v1/chat/completions diff --git a/package.json b/package.json index c64bbaf..7444e91 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "build:darwin-x64": "bun build --compile --target bun-darwin-x64 --define \"require.resolve=undefined\" src/index.ts --outfile zcode-proxy-darwin-x64", "build:darwin-arm64": "bun build --compile --target bun-darwin-arm64 --define \"require.resolve=undefined\" src/index.ts --outfile zcode-proxy-darwin-arm64", "build:android-bundle": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/android/server.cjs --define:process.env.NODE_ENV=\\\"production\\\"", - "build:android-apk": "bash scripts/build-android-apk.sh" + "build:android-apk": "bash scripts/build-android-apk.sh", + "regen:mcp": "bun run scripts/regen-mcp-catalogue.ts" }, "dependencies": { "happy-dom": "^20.11.6", diff --git a/scripts/regen-mcp-catalogue.ts b/scripts/regen-mcp-catalogue.ts new file mode 100644 index 0000000..556deb2 --- /dev/null +++ b/scripts/regen-mcp-catalogue.ts @@ -0,0 +1,269 @@ +/** + * Regenerate src/mcp/official-catalogue.json from ZCode's official plugin + * marketplace (maintainer-side, offline tooling — NOT part of the runtime). + * + * What it does (mirrors the ZCode desktop plugin-install dance, minus the + * installation): fetch marketplace.json → download every plugin.zip → verify + * sha256 against the marketplace pin → unzip → parse .mcp.json + + * .zcode-plugin/plugin.json → emit the static server table the gateway serves + * at /mcp/{server-key}. + * + * Run when ZCode ships new plugins, then cut a release: + * bun run scripts/regen-mcp-catalogue.ts + * + * Self-contained on purpose (no imports from src/): the runtime never parses + * zips; this script is the only zip consumer in the repo. + */ +import { createHash } from "node:crypto"; +import { inflateRawSync } from "node:zlib"; +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const MARKETPLACE_URL = "https://cdn-zcode.z.ai/zcode/official-plugin/marketplace.json"; +const OUT_FILE = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "mcp", "official-catalogue.json"); +// Production fallback (`jee`) per the desktop glm bundle `p1`/`air`; the test +// env fallback is https://zcode.chatglm.site (`sYe`). +const DEFAULT_UPSTREAM_ORIGIN = "https://zcode.z.ai"; +const OFFICIAL_UPSTREAM_ORIGINS = new Set(["https://zcode.z.ai", "https://zcode.chatglm.site"]); +const URL_TEMPLATE_PREFIX = "${ZCODE_BASE_URL}"; +const DOWNLOAD_CONCURRENCY = 6; + +// ───────────────────────────────────────────────────────────────────────────── +// Minimal zip reader (central-directory walk; methods 0=stored and 8=deflate). +// These plugin zips are tiny and zip64-free; a full archive lib would break +// the 3-dependency runtime budget for zero benefit. +// ───────────────────────────────────────────────────────────────────────────── + +interface ZipEntry { + name: string; + data: Buffer; +} + +function readZip(buf: Buffer): ZipEntry[] { + // Locate EOCD (signature 0x06054b50) scanning backwards over the comment. + let eocd = -1; + for (let i = buf.length - 22; i >= 0; i--) { + if (buf.readUInt32LE(i) === 0x06054b50) { + eocd = i; + break; + } + } + if (eocd < 0) throw new Error("zip: end of central directory not found"); + const entryCount = buf.readUInt16LE(eocd + 10); + let off = buf.readUInt32LE(eocd + 16); // central directory offset + + const entries: ZipEntry[] = []; + for (let n = 0; n < entryCount; n++) { + if (buf.readUInt32LE(off) !== 0x02014b50) throw new Error(`zip: bad central directory entry #${n}`); + const method = buf.readUInt16LE(off + 10); + const compressedSize = buf.readUInt32LE(off + 20); + const nameLen = buf.readUInt16LE(off + 28); + const extraLen = buf.readUInt16LE(off + 30); + const commentLen = buf.readUInt16LE(off + 32); + const localOffset = buf.readUInt32LE(off + 42); + const name = buf.subarray(off + 46, off + 46 + nameLen).toString("utf8"); + + // Local header: fixed 30 bytes, then its OWN name/extra lengths (data + // descriptor flag makes local sizes unreliable — always slice by the + // central directory's compressedSize). + if (buf.readUInt32LE(localOffset) !== 0x04034b50) throw new Error(`zip: bad local header for ${name}`); + const localNameLen = buf.readUInt16LE(localOffset + 26); + const localExtraLen = buf.readUInt16LE(localOffset + 28); + const dataStart = localOffset + 30 + localNameLen + localExtraLen; + const raw = buf.subarray(dataStart, dataStart + compressedSize); + + let data: Buffer; + if (method === 0) data = Buffer.from(raw); + else if (method === 8) data = inflateRawSync(raw); + else throw new Error(`zip: unsupported compression method ${method} for ${name}`); + + if (name.endsWith("/")) continue; // directory entry + entries.push({ name, data }); + off += 46 + nameLen + extraLen + commentLen; + } + return entries; +} + +/** Match an entry by path suffix — archives nest content under `{plugin}/`. */ +function findEntry(entries: ZipEntry[], suffix: string): ZipEntry | undefined { + const normalized = suffix.replace(/^\//, ""); + return entries.find((e) => e.name === normalized || e.name.endsWith(`/${normalized}`)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Marketplace parsing +// ───────────────────────────────────────────────────────────────────────────── + +interface MarketplacePlugin { + name: string; + version: string; + description?: string; + description_i18n?: Record; + displayName?: string; + displayName_i18n?: Record; + requiresPaidPlan?: boolean; + category?: string; + source?: { url?: string; sha256?: string }; +} + +interface CatalogueServer { + key: string; + routeId: string; + path: string; + plugin: string; + version: string; + marketplace: string; + requiresPaidPlan: boolean; + displayName: Record; + description: Record; + category?: string; +} + +function sha256Hex(buf: Buffer): string { + return createHash("sha256").update(buf).digest("hex"); +} + +function pickI18n(base: string | undefined, i18n: Record | undefined): Record { + const out: Record = { en: i18n?.en ?? base ?? "" }; + const zh = i18n?.["zh-CN"] ?? i18n?.zh; + if (zh) out["zh-CN"] = zh; + return out; +} + +/** + * Accept only the verified official shape: type http + zcode_official/jwt_token + * auth + a ${ZCODE_BASE_URL}/api/v1/mcp/server/{route} URL. Anything else + * (stdio servers, third-party URLs, unknown auth) is reported and skipped — + * the gateway must never point at origins it cannot trust-inject creds into + * (desktop enforces the same via `official_mcp_origin_untrusted`). + */ +function extractRoutePath(url: string): { path: string; routeId: string } | null { + let rest: string | null = null; + if (url.startsWith(URL_TEMPLATE_PREFIX)) rest = url.slice(URL_TEMPLATE_PREFIX.length); + else { + try { + const u = new URL(url); + if (OFFICIAL_UPSTREAM_ORIGINS.has(u.origin)) rest = u.pathname; + } catch { + rest = null; + } + } + if (!rest || !rest.startsWith("/api/v1/mcp/server/")) return null; + const routeId = rest.slice("/api/v1/mcp/server/".length).replace(/\/+$/, ""); + if (!routeId || routeId.includes("/")) return null; + return { path: rest, routeId }; +} + +async function main(): Promise { + console.log(`Fetching marketplace: ${MARKETPLACE_URL}`); + const mpResp = await fetch(MARKETPLACE_URL); + if (!mpResp.ok) throw new Error(`marketplace fetch failed: ${mpResp.status}`); + const marketplace = (await mpResp.json()) as { plugins?: MarketplacePlugin[] }; + const plugins = marketplace.plugins ?? []; + console.log(`Marketplace lists ${plugins.length} plugins`); + + const results: CatalogueServer[] = []; + const skipped: string[] = []; + const warnings: string[] = []; + + const queue = [...plugins]; + const worker = async (): Promise => { + while (queue.length > 0) { + const p = queue.shift(); + if (!p) return; + try { + if (!p.source?.url || !p.source.sha256) { + skipped.push(`${p.name}: no pinned artifact`); + continue; + } + const resp = await fetch(p.source.url); + if (!resp.ok) throw new Error(`zip fetch ${resp.status}`); + const zipBuf = Buffer.from(await resp.arrayBuffer()); + const actual = sha256Hex(zipBuf); + if (actual !== p.source.sha256) throw new Error(`sha256 mismatch (marketplace ${p.source.sha256.slice(0, 12)}…, got ${actual.slice(0, 12)}…)`); + + const entries = readZip(zipBuf); + const mcpEntry = findEntry(entries, ".mcp.json"); + if (!mcpEntry) { + skipped.push(`${p.name}@${p.version}: no .mcp.json (non-MCP plugin)`); + continue; + } + let mcp: { mcpServers?: Record }; + try { + mcp = JSON.parse(mcpEntry.data.toString("utf8")); + } catch (err) { + throw new Error(`.mcp.json parse failed: ${(err as Error).message}`); + } + const servers = mcp.mcpServers ?? {}; + let any = false; + for (const [key, def] of Object.entries(servers)) { + if (def.type !== "http") { + warnings.push(`${p.name}/${key}: unsupported type ${String(def.type)} — skipped`); + continue; + } + if (def.auth?.type !== "zcode_official" || def.auth.provider !== "jwt_token") { + warnings.push(`${p.name}/${key}: auth ${String(def.auth?.type)}/${String(def.auth?.provider)} != zcode_official/jwt_token — skipped`); + continue; + } + const route = def.url ? extractRoutePath(def.url) : null; + if (!route) { + warnings.push(`${p.name}/${key}: url not an official gateway route (${String(def.url)}) — skipped`); + continue; + } + const dup = results.find((r) => r.key === key); + if (dup) throw new Error(`server key collision: "${key}" declared by both ${dup.plugin} and ${p.name}`); + const pluginEntry = findEntry(entries, ".zcode-plugin/plugin.json"); + let meta: MarketplacePlugin = p; + if (pluginEntry) { + try { + meta = { ...p, ...(JSON.parse(pluginEntry.data.toString("utf8")) as MarketplacePlugin) }; + } catch { /* marketplace metadata is enough */ } + } + results.push({ + key, + routeId: route.routeId, + path: route.path, + plugin: p.name, + version: p.version, + marketplace: "zcode-plugins-official", + requiresPaidPlan: meta.requiresPaidPlan === true, + displayName: pickI18n(meta.displayName, meta.displayName_i18n), + description: pickI18n(meta.description, meta.description_i18n), + ...(meta.category ? { category: meta.category } : {}), + }); + any = true; + } + if (!any) skipped.push(`${p.name}@${p.version}: no supported MCP servers in .mcp.json`); + } catch (err) { + warnings.push(`${p.name}@${p.version}: ${(err as Error).message}`); + } + } + }; + await Promise.all(Array.from({ length: DOWNLOAD_CONCURRENCY }, worker)); + + results.sort((a, b) => a.key.localeCompare(b.key)); + const catalogue = { + generatedAt: new Date().toISOString(), + marketplaceUrl: MARKETPLACE_URL, + defaultUpstreamOrigin: DEFAULT_UPSTREAM_ORIGIN, + servers: results, + }; + writeFileSync(OUT_FILE, `${JSON.stringify(catalogue, null, 2)}\n`, "utf8"); + + console.log(`\nCatalogue: ${results.length} MCP servers → ${OUT_FILE}`); + for (const s of results) console.log(` ${s.key.padEnd(24)} → ${s.routeId} (${s.plugin}@${s.version}${s.requiresPaidPlan ? ", paid-plan" : ""})`); + if (skipped.length > 0) { + console.log(`\nSkipped (${skipped.length}):`); + for (const s of skipped) console.log(` - ${s}`); + } + if (warnings.length > 0) { + console.log(`\nWarnings (${warnings.length}):`); + for (const w of warnings) console.log(` ! ${w}`); + } +} + +main().catch((err) => { + console.error(`regen failed: ${(err as Error).message}`); + process.exit(1); +}); diff --git a/src/config/loader.test.ts b/src/config/loader.test.ts index 3cdfb86..00e9039 100644 --- a/src/config/loader.test.ts +++ b/src/config/loader.test.ts @@ -31,6 +31,8 @@ beforeEach(() => { delete process.env.ZCODE_CLAIM_AUTO; delete process.env.ZCODE_CLAIM_ORIGIN; delete process.env.ZCODE_CLAIM_POLL_INTERVAL_MS; + delete process.env.ZCODE_MCP_GATEWAY; + delete process.env.ZCODE_MCP_GATEWAY_ORIGIN; }); afterEach(() => { @@ -125,7 +127,7 @@ logging: expect(cfg.providers.bigmodel.openaiBase).toBe("https://open.bigmodel.cn/api/coding/paas/v4"); expect(cfg.clientIdentity).toEqual({ mode: "observe", ttlSeconds: 900, maxSessions: 1024 }); expect(cfg.responses).toEqual({ enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }); - expect(cfg.mcp).toEqual({ enabled: true, webSearch: true, webReader: false, zread: false }); + expect(cfg.mcp).toEqual({ enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.z.ai" } }); expect(cfg.async).toEqual({ enabled: false, origin: "https://zcode.z.ai", @@ -173,9 +175,67 @@ mcp: `); const cfg = loadConfig(path); expect(cfg.responses).toEqual({ enabled: false, storeMaxEntries: 50, storeTtlMs: 3600000 }); - expect(cfg.mcp).toEqual({ enabled: true, webSearch: false, webReader: true, zread: true }); + expect(cfg.mcp).toEqual({ enabled: true, webSearch: false, webReader: true, zread: true, gateway: { enabled: true, upstreamOrigin: "https://zcode.z.ai" } }); }); + describe("mcp.gateway", () => { + it("rejects a non-https non-loopback upstreamOrigin", () => { + const path = writeYaml(` +server: + port: 9090 +provider: zai +mcp: + gateway: + upstreamOrigin: "http://zcode.example.com" +`); + expect(() => loadConfig(path)).toThrow("mcp.gateway.upstreamOrigin"); + }); + + it("YAML overrides enabled and upstreamOrigin", () => { + const path = writeYaml(` +server: + port: 9090 +provider: zai +mcp: + gateway: + enabled: false + upstreamOrigin: "https://mirror.example.com" +`); + const cfg = loadConfig(path); + expect(cfg.mcp.gateway).toEqual({ enabled: false, upstreamOrigin: "https://mirror.example.com" }); + }); + + it("env overrides beat YAML", () => { + process.env.ZCODE_MCP_GATEWAY = "false"; + process.env.ZCODE_MCP_GATEWAY_ORIGIN = "https://env.example.com"; + const path = writeYaml(` +server: + port: 9090 +provider: zai +mcp: + gateway: + enabled: true + upstreamOrigin: "https://yaml.example.com" +`); + const cfg = loadConfig(path); + expect(cfg.mcp.gateway).toEqual({ enabled: false, upstreamOrigin: "https://env.example.com" }); + }); + + it("env enabled=true does not shadow the YAML origin", () => { + process.env.ZCODE_MCP_GATEWAY = "true"; + const path = writeYaml(` +server: + port: 9090 +provider: zai +mcp: + gateway: + upstreamOrigin: "https://yaml.example.com" +`); + const cfg = loadConfig(path); + expect(cfg.mcp.gateway).toEqual({ enabled: true, upstreamOrigin: "https://yaml.example.com" }); + }); +}); + it("async: YAML values override defaults", () => { const path = writeYaml(` async: diff --git a/src/config/loader.ts b/src/config/loader.ts index 7e9d4e2..1fb68f2 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -24,6 +24,8 @@ const ENV = { CLAIM_POLL_INTERVAL_MS: "ZCODE_CLAIM_POLL_INTERVAL_MS", ENDPOINT_ROUTING_ENABLED: "ZCODE_ENDPOINT_ROUTING", CLIENT_SIGNING_ENABLED: "ZCODE_CLIENT_SIGNING", + MCP_GATEWAY_ENABLED: "ZCODE_MCP_GATEWAY", + MCP_GATEWAY_ORIGIN: "ZCODE_MCP_GATEWAY_ORIGIN", } as const; /** Mirrors the ZCode desktop release (`_reverse/NOTEPAD.md`); bump per client @@ -54,6 +56,11 @@ const DEFAULTS = { MCP_WEB_SEARCH: true, MCP_WEB_READER: false, MCP_ZREAD: false, + MCP_GATEWAY_ENABLED: true, + // Production default for `${ZCODE_BASE_URL}` in plugin .mcp.json URLs (glm + // bundle `jee`; the `sYe` fallback "https://zcode.chatglm.site" is the TEST + // env origin — NOT for production traffic. 3.14.3 `p1`/`air`). + MCP_GATEWAY_ORIGIN: "https://zcode.z.ai", ASYNC_ENABLED: false, ASYNC_ORIGIN: "https://zcode.z.ai", ASYNC_POLL_INTERVAL_MS: 5000, @@ -187,11 +194,21 @@ function resolveResponsesConfig(raw: unknown): ResponsesConfig { function resolveMcpConfig(raw: unknown): McpConfig { const obj = raw && typeof raw === "object" ? raw as Record : {}; + const gwRaw = obj.gateway && typeof obj.gateway === "object" ? obj.gateway as Record : {}; + const gwEnabledEnv = process.env[ENV.MCP_GATEWAY_ENABLED]; + const gwOriginEnv = process.env[ENV.MCP_GATEWAY_ORIGIN]; + const gwOrigin = (gwOriginEnv ?? (typeof gwRaw.upstreamOrigin === "string" ? gwRaw.upstreamOrigin : DEFAULTS.MCP_GATEWAY_ORIGIN)).trim() + || DEFAULTS.MCP_GATEWAY_ORIGIN; + validateOrigin(gwOrigin, "mcp.gateway.upstreamOrigin"); return { enabled: resolveBool(obj.enabled, DEFAULTS.MCP_ENABLED), webSearch: resolveBool(obj.webSearch ?? obj.web_search, DEFAULTS.MCP_WEB_SEARCH), webReader: resolveBool(obj.webReader ?? obj.web_reader, DEFAULTS.MCP_WEB_READER), zread: resolveBool(obj.zread, DEFAULTS.MCP_ZREAD), + gateway: { + enabled: gwEnabledEnv !== undefined ? resolveBool(gwEnabledEnv, DEFAULTS.MCP_GATEWAY_ENABLED) : resolveBool(gwRaw.enabled, DEFAULTS.MCP_GATEWAY_ENABLED), + upstreamOrigin: gwOrigin, + }, }; } diff --git a/src/config/types.ts b/src/config/types.ts index 032ca27..05b80e9 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -71,6 +71,28 @@ export interface ResponsesConfig { storeTtlMs: number; } +/** + * Official plugin-MCP gateway relay (`/mcp/*` routes) — mirrors the ZCode + * desktop's `zcode_official` MCP plane: each catalogue server is proxied to + * `{upstreamOrigin}/api/v1/mcp/server/{routeId}` with the stored OAuth + * credential injected as official auth headers. + * + * The server table is a build-time artifact (`src/mcp/official-catalogue.json`, + * regenerated by `scripts/regen-mcp-catalogue.ts`) — the runtime never fetches + * the marketplace. + * + * @see _reverse/NOTEPAD.md "Official plugin MCP" (host bundle `tw`/`nw`/`lme`, + * 3.14.3) for the header contract. + */ +export interface McpGatewayConfig { + /** Expose `GET /mcp` and `/mcp/{server}` relay routes. Default `true`. */ + enabled: boolean; + /** Base origin substituted for `${ZCODE_BASE_URL}` in plugin `.mcp.json` + * URLs. Default `https://zcode.z.ai` (desktop bundle production fallback + * `jee`; `sYe` = chatglm.site is the test-env origin). */ + upstreamOrigin: string; +} + /** GLM MCP hosted-tool configuration. Endpoints are derived from the active provider. */ export interface McpConfig { /** Enable MCP interception (web_search) and function-tool injection (web_reader/zread). Default `true`. */ @@ -81,6 +103,9 @@ export interface McpConfig { webReader: boolean; /** Inject the three `zread` tools as function tools. Default `false`. */ zread: boolean; + /** Official plugin-MCP gateway relay (`/mcp/*`). Separate plane from the + * GLM hosted-tool fields above (those remain unwired). */ + gateway: McpGatewayConfig; } /** diff --git a/src/mcp/catalogue.ts b/src/mcp/catalogue.ts new file mode 100644 index 0000000..04c0b2a --- /dev/null +++ b/src/mcp/catalogue.ts @@ -0,0 +1,108 @@ +/** + * Build-time official plugin-MCP catalogue (sidecar asset, inlined into the + * single-file binary via the json import attribute — same pattern as + * `src/proxy/zcode_system.json`). + * + * Regenerated by `scripts/regen-mcp-catalogue.ts` when ZCode ships plugins; + * the runtime NEVER fetches the marketplace. Shape is validated at load so a + * drifted artifact fails loudly at startup instead of serving broken routes. + * + * @see _reverse/NOTEPAD.md "Official plugin MCP" + */ +import raw from "./official-catalogue.json" with { type: "json" }; + +export interface OfficialMcpServerDef { + /** mcpServers key from the plugin's .mcp.json — the /mcp/{key} route name. */ + key: string; + /** Gateway route identifier (path segment after /api/v1/mcp/server/). */ + routeId: string; + /** Upstream path (`/api/v1/mcp/server/{routeId}`), pre-origin. */ + path: string; + plugin: string; + version: string; + marketplace: string; + requiresPaidPlan: boolean; + displayName: Record; + description: Record; + category?: string; +} + +export interface OfficialMcpCatalogue { + generatedAt: string; + marketplaceUrl: string; + defaultUpstreamOrigin: string; + servers: OfficialMcpServerDef[]; +} + +interface RawServer { + key?: unknown; + routeId?: unknown; + path?: unknown; + plugin?: unknown; + version?: unknown; + marketplace?: unknown; + requiresPaidPlan?: unknown; + displayName?: unknown; + description?: unknown; + category?: unknown; +} + +function expectString(v: unknown, field: string): string { + if (typeof v !== "string" || v.length === 0) { + throw new Error(`official-catalogue.json: field "${field}" must be a non-empty string (regen with scripts/regen-mcp-catalogue.ts)`); + } + return v; +} + +function expectStringMap(v: unknown, field: string): Record { + if (typeof v !== "object" || v === null) { + throw new Error(`official-catalogue.json: field "${field}" must be an object`); + } + const out: Record = {}; + for (const [k, val] of Object.entries(v as Record)) { + if (typeof val !== "string") { + throw new Error(`official-catalogue.json: field "${field}.${k}" must be a string`); + } + out[k] = val; + } + return out; +} + +function parseServer(entry: RawServer, index: number): OfficialMcpServerDef { + const at = `servers[${index}]`; + const def: OfficialMcpServerDef = { + key: expectString(entry.key, `${at}.key`), + routeId: expectString(entry.routeId, `${at}.routeId`), + path: expectString(entry.path, `${at}.path`), + plugin: expectString(entry.plugin, `${at}.plugin`), + version: expectString(entry.version, `${at}.version`), + marketplace: expectString(entry.marketplace, `${at}.marketplace`), + requiresPaidPlan: entry.requiresPaidPlan === true, + displayName: expectStringMap(entry.displayName, `${at}.displayName`), + description: expectStringMap(entry.description, `${at}.description`), + }; + if (entry.category !== undefined) def.category = expectString(entry.category, `${at}.category`); + if (!def.path.startsWith("/api/v1/mcp/server/") || def.path !== `/api/v1/mcp/server/${def.routeId}`) { + throw new Error(`official-catalogue.json: ${at}.path "${def.path}" is not the canonical /api/v1/mcp/server/{routeId} form`); + } + return def; +} + +const rawServers = (raw as { servers?: unknown }).servers; +if (!Array.isArray(rawServers)) { + throw new Error("official-catalogue.json: \"servers\" must be an array (regen with scripts/regen-mcp-catalogue.ts)"); +} +const servers = rawServers.map((s, index) => parseServer(s as RawServer, index)); + +export const OFFICIAL_MCP_CATALOGUE: OfficialMcpCatalogue = { + generatedAt: expectString((raw as { generatedAt?: unknown }).generatedAt, "generatedAt"), + marketplaceUrl: expectString((raw as { marketplaceUrl?: unknown }).marketplaceUrl, "marketplaceUrl"), + defaultUpstreamOrigin: expectString((raw as { defaultUpstreamOrigin?: unknown }).defaultUpstreamOrigin, "defaultUpstreamOrigin"), + servers, +}; + +const byKey = new Map(servers.map((s) => [s.key, s])); + +export function lookupOfficialMcpServer(key: string): OfficialMcpServerDef | undefined { + return byKey.get(key); +} diff --git a/src/mcp/official-catalogue.json b/src/mcp/official-catalogue.json new file mode 100644 index 0000000..fe63bbb --- /dev/null +++ b/src/mcp/official-catalogue.json @@ -0,0 +1,277 @@ +{ + "generatedAt": "2026-09-25T15:20:02.957Z", + "marketplaceUrl": "https://cdn-zcode.z.ai/zcode/official-plugin/marketplace.json", + "defaultUpstreamOrigin": "https://zcode.z.ai", + "servers": [ + { + "key": "finance-search", + "routeId": "finance_search", + "path": "/api/v1/mcp/server/finance_search", + "plugin": "finance-search", + "version": "0.1.0", + "marketplace": "zcode-plugins-official", + "requiresPaidPlan": true, + "displayName": { + "en": "Financial Aggregated Search", + "zh-CN": "金融聚合搜索" + }, + "description": { + "en": "MCP services for SEC EDGAR filing search and financial web and news search.", + "zh-CN": "SEC EDGAR 文件检索与财经网页、新闻搜索 MCP 服务。" + }, + "category": "finance" + }, + { + "key": "hexin-bond", + "routeId": "finance_hexin_bond", + "path": "/api/v1/mcp/server/finance_hexin_bond", + "plugin": "hexin", + "version": "0.1.0", + "marketplace": "zcode-plugins-official", + "requiresPaidPlan": true, + "displayName": { + "en": "RoyalFlush iFinD", + "zh-CN": "同花顺" + }, + "description": { + "en": "MCP services for RoyalFlush iFinD stock, global stock, index, fund, and bond data.", + "zh-CN": "同花顺股票、海外股票、指数、基金与债券数据 MCP 服务。" + }, + "category": "finance" + }, + { + "key": "hexin-fund", + "routeId": "finance_hexin_fund", + "path": "/api/v1/mcp/server/finance_hexin_fund", + "plugin": "hexin", + "version": "0.1.0", + "marketplace": "zcode-plugins-official", + "requiresPaidPlan": true, + "displayName": { + "en": "RoyalFlush iFinD", + "zh-CN": "同花顺" + }, + "description": { + "en": "MCP services for RoyalFlush iFinD stock, global stock, index, fund, and bond data.", + "zh-CN": "同花顺股票、海外股票、指数、基金与债券数据 MCP 服务。" + }, + "category": "finance" + }, + { + "key": "hexin-global-stock", + "routeId": "finance_hexin_global_stock", + "path": "/api/v1/mcp/server/finance_hexin_global_stock", + "plugin": "hexin", + "version": "0.1.0", + "marketplace": "zcode-plugins-official", + "requiresPaidPlan": true, + "displayName": { + "en": "RoyalFlush iFinD", + "zh-CN": "同花顺" + }, + "description": { + "en": "MCP services for RoyalFlush iFinD stock, global stock, index, fund, and bond data.", + "zh-CN": "同花顺股票、海外股票、指数、基金与债券数据 MCP 服务。" + }, + "category": "finance" + }, + { + "key": "hexin-index", + "routeId": "finance_hexin_index", + "path": "/api/v1/mcp/server/finance_hexin_index", + "plugin": "hexin", + "version": "0.1.0", + "marketplace": "zcode-plugins-official", + "requiresPaidPlan": true, + "displayName": { + "en": "RoyalFlush iFinD", + "zh-CN": "同花顺" + }, + "description": { + "en": "MCP services for RoyalFlush iFinD stock, global stock, index, fund, and bond data.", + "zh-CN": "同花顺股票、海外股票、指数、基金与债券数据 MCP 服务。" + }, + "category": "finance" + }, + { + "key": "hexin-stock", + "routeId": "finance_hexin_stock", + "path": "/api/v1/mcp/server/finance_hexin_stock", + "plugin": "hexin", + "version": "0.1.0", + "marketplace": "zcode-plugins-official", + "requiresPaidPlan": true, + "displayName": { + "en": "RoyalFlush iFinD", + "zh-CN": "同花顺" + }, + "description": { + "en": "MCP services for RoyalFlush iFinD stock, global stock, index, fund, and bond data.", + "zh-CN": "同花顺股票、海外股票、指数、基金与债券数据 MCP 服务。" + }, + "category": "finance" + }, + { + "key": "sec-search", + "routeId": "finance_sec_search", + "path": "/api/v1/mcp/server/finance_sec_search", + "plugin": "finance-search", + "version": "0.1.0", + "marketplace": "zcode-plugins-official", + "requiresPaidPlan": true, + "displayName": { + "en": "Financial Aggregated Search", + "zh-CN": "金融聚合搜索" + }, + "description": { + "en": "MCP services for SEC EDGAR filing search and financial web and news search.", + "zh-CN": "SEC EDGAR 文件检索与财经网页、新闻搜索 MCP 服务。" + }, + "category": "finance" + }, + { + "key": "tianyancha", + "routeId": "finance_tianyancha", + "path": "/api/v1/mcp/server/finance_tianyancha", + "plugin": "tianyancha", + "version": "0.1.0", + "marketplace": "zcode-plugins-official", + "requiresPaidPlan": true, + "displayName": { + "en": "Tianyancha", + "zh-CN": "天眼查" + }, + "description": { + "en": "MCP service for Tianyancha company information queries.", + "zh-CN": "天眼查企业信息查询 MCP 服务。" + }, + "category": "finance" + }, + { + "key": "wind-bond", + "routeId": "finance_wind_bond", + "path": "/api/v1/mcp/server/finance_wind_bond", + "plugin": "wind", + "version": "0.1.0", + "marketplace": "zcode-plugins-official", + "requiresPaidPlan": true, + "displayName": { + "en": "Wind", + "zh-CN": "Wind 万得" + }, + "description": { + "en": "MCP services for Wind stock, global stock, index, fund, bond, economic, and document data.", + "zh-CN": "万得股票、海外股票、指数、基金、债券、宏观经济与公告研报数据 MCP 服务。" + }, + "category": "finance" + }, + { + "key": "wind-docs", + "routeId": "finance_wind_docs", + "path": "/api/v1/mcp/server/finance_wind_docs", + "plugin": "wind", + "version": "0.1.0", + "marketplace": "zcode-plugins-official", + "requiresPaidPlan": true, + "displayName": { + "en": "Wind", + "zh-CN": "Wind 万得" + }, + "description": { + "en": "MCP services for Wind stock, global stock, index, fund, bond, economic, and document data.", + "zh-CN": "万得股票、海外股票、指数、基金、债券、宏观经济与公告研报数据 MCP 服务。" + }, + "category": "finance" + }, + { + "key": "wind-economic", + "routeId": "finance_wind_economic", + "path": "/api/v1/mcp/server/finance_wind_economic", + "plugin": "wind", + "version": "0.1.0", + "marketplace": "zcode-plugins-official", + "requiresPaidPlan": true, + "displayName": { + "en": "Wind", + "zh-CN": "Wind 万得" + }, + "description": { + "en": "MCP services for Wind stock, global stock, index, fund, bond, economic, and document data.", + "zh-CN": "万得股票、海外股票、指数、基金、债券、宏观经济与公告研报数据 MCP 服务。" + }, + "category": "finance" + }, + { + "key": "wind-fund", + "routeId": "finance_wind_fund", + "path": "/api/v1/mcp/server/finance_wind_fund", + "plugin": "wind", + "version": "0.1.0", + "marketplace": "zcode-plugins-official", + "requiresPaidPlan": true, + "displayName": { + "en": "Wind", + "zh-CN": "Wind 万得" + }, + "description": { + "en": "MCP services for Wind stock, global stock, index, fund, bond, economic, and document data.", + "zh-CN": "万得股票、海外股票、指数、基金、债券、宏观经济与公告研报数据 MCP 服务。" + }, + "category": "finance" + }, + { + "key": "wind-global-stock", + "routeId": "finance_wind_global_stock", + "path": "/api/v1/mcp/server/finance_wind_global_stock", + "plugin": "wind", + "version": "0.1.0", + "marketplace": "zcode-plugins-official", + "requiresPaidPlan": true, + "displayName": { + "en": "Wind", + "zh-CN": "Wind 万得" + }, + "description": { + "en": "MCP services for Wind stock, global stock, index, fund, bond, economic, and document data.", + "zh-CN": "万得股票、海外股票、指数、基金、债券、宏观经济与公告研报数据 MCP 服务。" + }, + "category": "finance" + }, + { + "key": "wind-index", + "routeId": "finance_wind_index", + "path": "/api/v1/mcp/server/finance_wind_index", + "plugin": "wind", + "version": "0.1.0", + "marketplace": "zcode-plugins-official", + "requiresPaidPlan": true, + "displayName": { + "en": "Wind", + "zh-CN": "Wind 万得" + }, + "description": { + "en": "MCP services for Wind stock, global stock, index, fund, bond, economic, and document data.", + "zh-CN": "万得股票、海外股票、指数、基金、债券、宏观经济与公告研报数据 MCP 服务。" + }, + "category": "finance" + }, + { + "key": "wind-stock", + "routeId": "finance_wind_stock", + "path": "/api/v1/mcp/server/finance_wind_stock", + "plugin": "wind", + "version": "0.1.0", + "marketplace": "zcode-plugins-official", + "requiresPaidPlan": true, + "displayName": { + "en": "Wind", + "zh-CN": "Wind 万得" + }, + "description": { + "en": "MCP services for Wind stock, global stock, index, fund, bond, economic, and document data.", + "zh-CN": "万得股票、海外股票、指数、基金、债券、宏观经济与公告研报数据 MCP 服务。" + }, + "category": "finance" + } + ] +} diff --git a/src/mcp/relay.test.ts b/src/mcp/relay.test.ts new file mode 100644 index 0000000..bcc6f38 --- /dev/null +++ b/src/mcp/relay.test.ts @@ -0,0 +1,162 @@ +/** + * Tests for the official-MCP relay auth header matrix and streamable-HTTP + * forwarding (header contract LIVE-VERIFIED 2026-09-25 — + * @see _reverse/NOTEPAD.md "Official plugin MCP"). + */ +import { describe, it, expect } from "bun:test"; +import { + buildOfficialMcpAuthHeaders, + relayOfficialMcpRequest, + McpCredentialsUnavailableError, +} from "./relay.js"; +import type { Credential } from "../auth/types.js"; + +function cred(fields: Partial = {}): Credential { + return { + apiKey: "ce3dtest.testsecret", + provider: "zai", + jwt: "jwt-abc", + ...fields, + }; +} + +describe("buildOfficialMcpAuthHeaders", () => { + it("emits the live-verified header set in order", () => { + const { mode, headers } = buildOfficialMcpAuthHeaders(cred()); + expect(mode).toBe("coding-plan"); + expect(Object.keys(headers)).toEqual(["Authorization", "X-Bigmodel-Authorization", "Bigmodel-Target-Type"]); + expect(headers.Authorization).toBe("Bearer jwt-abc"); + expect(headers["X-Bigmodel-Authorization"]).toBe("Bearer ce3dtest.testsecret"); + expect(headers["Bigmodel-Target-Type"]).toBe("PERSONAL"); + }); + + it("zai composes apiKey.secret into the plan header; bigmodel uses the bare key", () => { + const zai = buildOfficialMcpAuthHeaders(cred({ provider: "zai", apiKey: "id-part", secret: "sec-part" })); + const bigmodel = buildOfficialMcpAuthHeaders(cred({ provider: "bigmodel", apiKey: "id-part", secret: undefined })); + expect(zai.headers["X-Bigmodel-Authorization"]).toBe("Bearer id-part.sec-part"); + expect(bigmodel.headers["X-Bigmodel-Authorization"]).toBe("Bearer id-part"); + }); + + it("fails fast when the credential has no zcode JWT", () => { + expect(() => buildOfficialMcpAuthHeaders(cred({ jwt: undefined }))).toThrow( + McpCredentialsUnavailableError, + ); + try { + buildOfficialMcpAuthHeaders(cred({ jwt: undefined })); + } catch (err) { + expect((err as McpCredentialsUnavailableError).reason).toBe("missing_jwt"); + expect((err as Error).message).toContain("auth login"); + } + }); + + it("trims whitespace before building header values", () => { + const { headers } = buildOfficialMcpAuthHeaders(cred({ jwt: " jwt-abc ", apiKey: " key " })); + expect(headers.Authorization).toBe("Bearer jwt-abc"); + expect(headers["X-Bigmodel-Authorization"]).toBe("Bearer key"); + }); +}); + +/** Capturing fetch stub: records the last (url, init) and returns a canned Response. */ +function captureFetch(resp: Response): { fetch: typeof fetch; seen: () => { url: string; init: RequestInit } } { + let captured: { url: string; init: RequestInit } | null = null; + const impl = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + captured = { url: String(input), init: init ?? (input instanceof Request ? {} : {}) }; + return resp; + }) as typeof fetch; + return { fetch: impl, seen: () => captured! }; +} + +describe("relayOfficialMcpRequest", () => { + const auth = buildOfficialMcpAuthHeaders(cred()); + const UPSTREAM = "https://zcode.z.ai/api/v1/mcp/server/finance_tianyancha"; + + it("POST: forwards body, forwards only allowlisted client headers, injects auth", async () => { + const { fetch, seen } = captureFetch( + new Response('{"jsonrpc":"2.0","id":1,"result":{}}', { + status: 200, + headers: { "content-type": "application/json", "mcp-session-id": "sess-1", "x-internal": "nope" }, + }), + ); + const req = new Request("http://127.0.0.1:8080/mcp/tianyancha", { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + authorization: "Bearer inbound-proxy-key", + "x-api-key": "inbound-proxy-key", + "mcp-protocol-version": "2025-06-18", + "mcp-session-id": "sess-1", + "user-agent": "some-client", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }); + + const { response } = await relayOfficialMcpRequest(req, auth, UPSTREAM, fetch); + const { url, init } = seen(); + + expect(url).toBe(UPSTREAM); + expect(init.method).toBe("POST"); + const headers = init.headers as Record; + // Inbound proxy credentials must never reach the upstream in any form. + expect(JSON.stringify(headers)).not.toContain("inbound-proxy-key"); + expect(headers.Authorization).toBe("Bearer jwt-abc"); + expect(headers["X-Bigmodel-Authorization"]).toBe("Bearer ce3dtest.testsecret"); + expect(headers["Bigmodel-Target-Type"]).toBe("PERSONAL"); + expect(headers["mcp-protocol-version"]).toBe("2025-06-18"); + expect(headers["mcp-session-id"]).toBe("sess-1"); + expect(headers.accept).toContain("text/event-stream"); + expect(headers["user-agent"]).toBeUndefined(); + expect(JSON.parse(Buffer.from(init.body as ArrayBuffer).toString("utf8")).method).toBe("tools/list"); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(response.headers.get("mcp-session-id")).toBe("sess-1"); + expect(response.headers.get("x-internal")).toBeNull(); + expect(await response.json()).toEqual({ jsonrpc: "2.0", id: 1, result: {} }); + }); + + it("GET: query string is preserved and no body is sent", async () => { + const { fetch, seen } = captureFetch( + new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("event: message\ndata: {}\n\n")); + controller.close(); + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } }), + ); + const req = new Request("http://127.0.0.1:8080/mcp/tianyancha?cursor=3", { method: "GET" }); + + const { response } = await relayOfficialMcpRequest(req, auth, `${UPSTREAM}?cursor=3`, fetch); + const { url, init } = seen(); + expect(url).toBe(`${UPSTREAM}?cursor=3`); + expect(init.method).toBe("GET"); + expect(init.body).toBeUndefined(); + + // SSE body streams through untouched. + expect(response.headers.get("content-type")).toBe("text/event-stream"); + expect(await response.text()).toBe("event: message\ndata: {}\n\n"); + }); + + it("DELETE: forwarded with status passthrough", async () => { + const { fetch, seen } = captureFetch(new Response(null, { status: 204 })); + const req = new Request("http://127.0.0.1:8080/mcp/tianyancha", { + method: "DELETE", + headers: { "mcp-session-id": "sess-1" }, + }); + const { response } = await relayOfficialMcpRequest(req, auth, UPSTREAM, fetch); + expect(seen().init.method).toBe("DELETE"); + expect(response.status).toBe(204); + }); + + it("upstream network failure maps to 502 mcp_upstream_unreachable", async () => { + const failing = (async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch; + const req = new Request("http://127.0.0.1:8080/mcp/tianyancha", { method: "POST", body: "{}" }); + const { response } = await relayOfficialMcpRequest(req, auth, UPSTREAM, failing); + expect(response.status).toBe(502); + const body = await response.json(); + expect(body.error.type).toBe("mcp_upstream_unreachable"); + expect(body.error.message).toContain("ECONNREFUSED"); + }); +}); diff --git a/src/mcp/relay.ts b/src/mcp/relay.ts new file mode 100644 index 0000000..c3696d4 --- /dev/null +++ b/src/mcp/relay.ts @@ -0,0 +1,138 @@ +/** + * Official plugin-MCP relay: inbound `/mcp/{server}` requests → + * `{origin}/api/v1/mcp/server/{routeId}` with the desktop's `zcode_official` + * credential injection. + * + * Header contract — LIVE-VERIFIED 2026-09-25 against + * `https://zcode.z.ai/api/v1/mcp/server/finance_tianyancha` (initialize → + * 200 + serverInfo `zcode-official-mcp`): + * Authorization: Bearer (Credential.jwt) + * X-Bigmodel-Authorization: Bearer (credentialString) + * Bigmodel-Target-Type: PERSONAL (individual plan) + * + * Corrections vs the 3.14.3 host bundle reading (see NOTEPAD §F): the bundle's + * `tw`/`nw` suggests the plan header carries the raw OAuth access token from + * the `oauth:{family}:access_token` slot — but that slot is `enc:v1:`-encrypted + * in the desktop store (content unverifiable), and the API-key variant is what + * actually authenticates. Identity-only (Authorization alone) is rejected by + * the gateway with JSON-RPC 3101 "coding plan is required", so start-plan + * credentials cannot serve this plane at all (routes surface that as 400 + * `mcp_plan_unsupported`, mirroring the async-route pattern). + * + * @see _reverse/NOTEPAD.md "Official plugin MCP" + */ +import { credentialString, type Credential } from "../auth/types.js"; + +export type OfficialMcpAuthMode = "coding-plan"; +export type McpCredentialReason = "missing_jwt"; + +export class McpCredentialsUnavailableError extends Error { + constructor( + public readonly reason: McpCredentialReason, + message: string, + ) { + super(message); + this.name = "McpCredentialsUnavailableError"; + } +} + +export interface OfficialMcpAuth { + mode: OfficialMcpAuthMode; + headers: Record; +} + +/** + * Build the official-MCP auth headers for a coding-plan credential. + * @throws McpCredentialsUnavailableError when the credential has no zcode JWT. + */ +export function buildOfficialMcpAuthHeaders(cred: Credential): OfficialMcpAuth { + const jwt = cred.jwt?.trim(); + if (!jwt) { + throw new McpCredentialsUnavailableError( + "missing_jwt", + "stored credential has no ZCode JWT — re-run `auth login` (or `auth login --import`) to use the /mcp relay", + ); + } + return { + mode: "coding-plan", + headers: { + Authorization: `Bearer ${jwt}`, + "X-Bigmodel-Authorization": `Bearer ${credentialString(cred).trim()}`, + "Bigmodel-Target-Type": "PERSONAL", + }, + }; +} + +/** + * Client headers forwarded to the gateway. `authorization` is deliberately + * absent: the inbound proxy API key must never reach the upstream, and the + * relay's own credential headers override it regardless. + */ +const FORWARD_REQUEST_HEADERS = new Set([ + "content-type", + "accept", + "mcp-session-id", + "mcp-protocol-version", + "last-event-id", +]); + +/** Response headers surfaced back to the client (session binding + type). */ +const FORWARD_RESPONSE_HEADERS = ["content-type", "mcp-session-id", "mcp-protocol-version"] as const; + +const METHODS_WITH_BODY = new Set(["POST", "PUT", "PATCH"]); + +export interface McpRelayResult { + response: Response; +} + +/** + * Forward one MCP streamable-HTTP exchange (POST JSON-RPC / GET SSE stream / + * DELETE session close) to the official gateway. The response body streams + * through untouched so SSE works; `req.signal` ties the upstream connection + * to the client's lifetime. + */ +export async function relayOfficialMcpRequest( + req: Request, + auth: OfficialMcpAuth, + upstreamUrl: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const headers: Record = {}; + for (const [name, value] of req.headers) { + if (value !== "" && FORWARD_REQUEST_HEADERS.has(name.toLowerCase())) headers[name] = value; + } + // Auth headers last: the injected credentials win over anything a client + // could smuggle into the forward set. + for (const [name, value] of Object.entries(auth.headers)) headers[name] = value; + + const init: RequestInit = { + method: req.method, + headers, + signal: req.signal, + ...(METHODS_WITH_BODY.has(req.method) ? { body: await req.arrayBuffer() } : {}), + }; + + let upstream: Response; + try { + upstream = await fetchImpl(upstreamUrl, init); + } catch (err) { + return { + response: new Response( + JSON.stringify({ + error: { + type: "mcp_upstream_unreachable", + message: `official MCP gateway unreachable: ${(err as Error).message}`, + }, + }), + { status: 502, headers: { "content-type": "application/json" } }, + ), + }; + } + + const respHeaders: Record = {}; + for (const name of FORWARD_RESPONSE_HEADERS) { + const value = upstream.headers.get(name); + if (value) respHeaders[name] = value; + } + return { response: new Response(upstream.body, { status: upstream.status, headers: respHeaders }) }; +} diff --git a/src/proxy/handler-debug.test.ts b/src/proxy/handler-debug.test.ts index 832bbd5..7c7bc3c 100644 --- a/src/proxy/handler-debug.test.ts +++ b/src/proxy/handler-debug.test.ts @@ -38,7 +38,7 @@ const TEST_CONFIG: ProxyConfig = { responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, - mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.chatglm.site" } }, async: { enabled: false, origin: "https://zcode.z.ai", pollIntervalMs: 5000, keepAliveIntervalMs: 3000, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 8000, controlTimeoutMs: 15000, defaultModel: "" }, claim: { enabled: false, auto: true, origin: "https://zcode.z.ai", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, logging: { level: "info" }, diff --git a/src/proxy/handler-resilience.test.ts b/src/proxy/handler-resilience.test.ts index e9f5c6d..14dffe8 100644 --- a/src/proxy/handler-resilience.test.ts +++ b/src/proxy/handler-resilience.test.ts @@ -40,7 +40,7 @@ const TEST_CONFIG: ProxyConfig = { responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, - mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.chatglm.site" } }, async: { enabled: false, origin: "https://zcode.z.ai", pollIntervalMs: 5000, keepAliveIntervalMs: 3000, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 8000, controlTimeoutMs: 15000, defaultModel: "" }, claim: { enabled: false, auto: true, origin: "https://zcode.z.ai", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, logging: { level: "info" }, diff --git a/src/proxy/handler-routing-signing.test.ts b/src/proxy/handler-routing-signing.test.ts index a11e2f7..2350324 100644 --- a/src/proxy/handler-routing-signing.test.ts +++ b/src/proxy/handler-routing-signing.test.ts @@ -41,7 +41,7 @@ const TEST_CONFIG: ProxyConfig = { responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, - mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.chatglm.site" } }, async: { enabled: false, origin: "https://zcode.z.ai", pollIntervalMs: 5000, keepAliveIntervalMs: 3000, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 8000, controlTimeoutMs: 15000, defaultModel: "" }, claim: { enabled: false, auto: true, origin: "https://zcode.z.ai", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, logging: { level: "info" }, diff --git a/src/proxy/ordered-transport.test.ts b/src/proxy/ordered-transport.test.ts index f0cb45d..a1f0391 100644 --- a/src/proxy/ordered-transport.test.ts +++ b/src/proxy/ordered-transport.test.ts @@ -268,7 +268,7 @@ describe("proxyRequest — ordered transport + brotli SSE (ultra gateway regress responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, - mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.chatglm.site" } }, async: { enabled: false, origin: "https://zcode.z.ai", pollIntervalMs: 5000, keepAliveIntervalMs: 3000, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 8000, controlTimeoutMs: 15000, defaultModel: "" }, claim: { enabled: false, auto: true, origin: "https://zcode.z.ai", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, logging: { level: "info" }, @@ -316,7 +316,7 @@ describe("proxyRequest — ordered transport abort (CL-04, handler level)", () = responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, - mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.chatglm.site" } }, async: { enabled: false, origin: "https://zcode.z.ai", pollIntervalMs: 5000, keepAliveIntervalMs: 3000, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 8000, controlTimeoutMs: 15000, defaultModel: "" }, claim: { enabled: false, auto: true, origin: "https://zcode.z.ai", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, logging: { level: "info" }, diff --git a/src/proxy/responses-handler.test.ts b/src/proxy/responses-handler.test.ts index 7cfad6b..17b5c98 100644 --- a/src/proxy/responses-handler.test.ts +++ b/src/proxy/responses-handler.test.ts @@ -22,7 +22,7 @@ const CONFIG: ProxyConfig = { responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, - mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.chatglm.site" } }, async: { enabled: false, origin: "https://zcode.z.ai", pollIntervalMs: 5000, keepAliveIntervalMs: 3000, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 8000, controlTimeoutMs: 15000, defaultModel: "" }, claim: { enabled: false, auto: true, origin: "https://zcode.z.ai", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, logging: { level: "info" }, diff --git a/src/proxy/session-context.test.ts b/src/proxy/session-context.test.ts index 0fd254b..d378fc0 100644 --- a/src/proxy/session-context.test.ts +++ b/src/proxy/session-context.test.ts @@ -26,7 +26,7 @@ const BASE_CONFIG: ProxyConfig = { responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, - mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.chatglm.site" } }, async: { enabled: false, origin: "https://zcode.z.ai", pollIntervalMs: 5000, keepAliveIntervalMs: 3000, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 8000, controlTimeoutMs: 15000, defaultModel: "" }, claim: { enabled: false, auto: true, origin: "https://zcode.z.ai", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, logging: { level: "info" }, diff --git a/src/proxy/upstream.test.ts b/src/proxy/upstream.test.ts index d9545d6..d2cbe5b 100644 --- a/src/proxy/upstream.test.ts +++ b/src/proxy/upstream.test.ts @@ -446,7 +446,7 @@ describe("proxyRequest", () => { responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, - mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.chatglm.site" } }, async: { enabled: false, origin: "https://zcode.z.ai", pollIntervalMs: 5000, keepAliveIntervalMs: 3000, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 8000, controlTimeoutMs: 15000, defaultModel: "" }, claim: { enabled: false, auto: true, origin: "https://zcode.z.ai", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, logging: { level: "info" }, @@ -657,7 +657,7 @@ describe("proxyRequest — OpenAI translation mode (coding-plan → Anthropic up responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, - mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.chatglm.site" } }, async: { enabled: false, origin: "https://zcode.z.ai", pollIntervalMs: 5000, keepAliveIntervalMs: 3000, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 8000, controlTimeoutMs: 15000, defaultModel: "" }, claim: { enabled: false, auto: true, origin: "https://zcode.z.ai", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, logging: { level: "info" }, @@ -920,7 +920,7 @@ describe("client gzip handling", () => { responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, - mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.chatglm.site" } }, async: { enabled: false, origin: "https://zcode.z.ai", pollIntervalMs: 5000, keepAliveIntervalMs: 3000, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 8000, controlTimeoutMs: 15000, defaultModel: "" }, claim: { enabled: false, auto: true, origin: "https://zcode.z.ai", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, logging: { level: "info" }, @@ -1053,7 +1053,7 @@ describe("proxyRequest — Anthropic compatibility mode (coding-plan)", () => { responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, - mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.chatglm.site" } }, async: { enabled: false, origin: "https://zcode.z.ai", pollIntervalMs: 5000, keepAliveIntervalMs: 3000, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 8000, controlTimeoutMs: 15000, defaultModel: "" }, claim: { enabled: false, auto: true, origin: "https://zcode.z.ai", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, logging: { level: "info" }, @@ -1401,7 +1401,7 @@ describe("proxyRequest — tool-call roundtrip (OpenAI client → Anthropic upst responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, - mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.chatglm.site" } }, async: { enabled: false, origin: "https://zcode.z.ai", pollIntervalMs: 5000, keepAliveIntervalMs: 3000, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 8000, controlTimeoutMs: 15000, defaultModel: "" }, claim: { enabled: false, auto: true, origin: "https://zcode.z.ai", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, logging: { level: "info" }, @@ -1566,7 +1566,7 @@ describe("proxyRequest — thinking endpoint matrix", () => { responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, - mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.chatglm.site" } }, async: { enabled: false, origin: "https://zcode.z.ai", pollIntervalMs: 5000, keepAliveIntervalMs: 3000, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 8000, controlTimeoutMs: 15000, defaultModel: "" }, claim: { enabled: false, auto: true, origin: "https://zcode.z.ai", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, logging: { level: "info" }, diff --git a/src/server/routes-async.test.ts b/src/server/routes-async.test.ts index 60e68c2..6d9aa6f 100644 --- a/src/server/routes-async.test.ts +++ b/src/server/routes-async.test.ts @@ -37,7 +37,7 @@ function makeConfig(overrides: Partial = {}): ProxyConfig { responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, - mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.chatglm.site" } }, async: { enabled: true, origin: "https://zcode.z.ai", diff --git a/src/server/routes-mcp.test.ts b/src/server/routes-mcp.test.ts new file mode 100644 index 0000000..8be36e3 --- /dev/null +++ b/src/server/routes-mcp.test.ts @@ -0,0 +1,222 @@ +/** + * Tests for the /mcp routes: listing, proxyApiKey gating, plan gating, + * credential matrix, and relay forwarding through the full fetch-handler dispatch. + */ +import { describe, it, expect } from "bun:test"; +import { createFetchHandler } from "./server.js"; +import type { ProxyConfig, McpGatewayConfig } from "../config/types.js"; +import { AuthManager } from "../auth/manager.js"; +import type { Credential } from "../auth/types.js"; + +function makeConfig(overrides: Partial = {}, mcpGateway: McpGatewayConfig = { enabled: true, upstreamOrigin: "https://zcode.z.ai" }): ProxyConfig { + return { + server: { port: 0, host: "127.0.0.1" }, + auth: {}, + provider: "zai", + plan: "coding-plan", + providers: { + zai: { anthropicBase: "https://api.z.ai/api/anthropic", openaiBase: "https://api.z.ai/api/coding/paas/v4" }, + bigmodel: { anthropicBase: "https://open.bigmodel.cn/api/anthropic", openaiBase: "https://open.bigmodel.cn/api/coding/paas/v4" }, + }, + defaultModel: "glm-4.6", + models: ["glm-4.6"], + identity: { appVersion: "test-1.0.0", sourceTitle: "cli", refererOrigin: "https://zcode.z.ai" }, + clientIdentity: { mode: "observe", ttlSeconds: 900, maxSessions: 1024 }, + responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, + endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, + clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: mcpGateway }, + async: { enabled: false, origin: "https://zcode.z.ai", pollIntervalMs: 5000, keepAliveIntervalMs: 3000, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 8000, controlTimeoutMs: 15000, defaultModel: "" }, + claim: { enabled: false, auto: true, origin: "https://zcode.z.ai", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, + logging: { level: "info" }, + ...overrides, + }; +} + +function fullCred(fields: Partial = {}): Credential { + return { apiKey: "sk-x.sec", provider: "zai", jwt: "jwt-1", ...fields }; +} + +/** Capturing upstream stub shared by relay tests. */ +function upstreamCapture(resp?: Response): { fetch: typeof fetch; seen: () => { url: string; init: RequestInit } } { + let captured: { url: string; init: RequestInit } | null = null; + const impl = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + captured = { url: String(input), init: init ?? {} }; + return resp ?? new Response('{"jsonrpc":"2.0","id":1,"result":{}}', { + status: 200, + headers: { "content-type": "application/json", "mcp-session-id": "sess-9" }, + }); + }) as typeof fetch; + return { fetch: impl, seen: () => captured! }; +} + +describe("GET /mcp listing", () => { + it("lists catalogue servers with route metadata (JSON default)", async () => { + const auth = new AuthManager(); + auth.setOAuthCredential(fullCred()); + const handler = createFetchHandler({ config: makeConfig(), auth, fetchImpl: upstreamCapture().fetch }); + + const resp = await handler(new Request("http://localhost/mcp", { method: "GET" })); + expect(resp.status).toBe(200); + expect(resp.headers.get("content-type")).toBe("application/json"); + const body = await resp.json(); + expect(body.object).toBe("list"); + expect(body.authReady).toBe(true); + expect(body.authMode).toBe("coding-plan"); + const tianyancha = body.servers.find((s: { key: string }) => s.key === "tianyancha"); + expect(tianyancha).toBeDefined(); + expect(tianyancha.path).toBe("/mcp/tianyancha"); + expect(tianyancha.routeId).toBe("finance_tianyancha"); + expect(tianyancha.requiresPaidPlan).toBe(true); + expect(tianyancha.methods).toEqual(["POST", "GET", "DELETE"]); + }); + + it("renders an HTML table for browsers (Accept: text/html)", async () => { + const auth = new AuthManager(); + auth.setOAuthCredential(fullCred()); + const handler = createFetchHandler({ config: makeConfig(), auth, fetchImpl: upstreamCapture().fetch }); + + const resp = await handler(new Request("http://localhost/mcp", { method: "GET", headers: { accept: "text/html,application/xhtml+xml" } })); + expect(resp.status).toBe(200); + expect(resp.headers.get("content-type")).toContain("text/html"); + const html = await resp.text(); + expect(html).toContain("tianyancha"); + expect(html).toContain("finance_tianyancha"); + }); + + it("annotates authError=plan_unsupported on start-plan", async () => { + const auth = new AuthManager(); + auth.setOAuthCredential(fullCred()); + const handler = createFetchHandler({ config: makeConfig({ plan: "start-plan" }), auth, fetchImpl: upstreamCapture().fetch }); + + const resp = await handler(new Request("http://localhost/mcp", { method: "GET" })); + const body = await resp.json(); + expect(body.authReady).toBe(false); + expect(body.authError).toBe("plan_unsupported"); + }); + + it("annotates authError=missing_jwt when the stored credential lacks a JWT", async () => { + const auth = new AuthManager(); + auth.setOAuthCredential(fullCred({ jwt: undefined })); + const handler = createFetchHandler({ config: makeConfig(), auth, fetchImpl: upstreamCapture().fetch }); + + const resp = await handler(new Request("http://localhost/mcp", { method: "GET" })); + const body = await resp.json(); + expect(body.authReady).toBe(false); + expect(body.authError).toBe("missing_jwt"); + }); +}); + +describe("/mcp proxyApiKey gating (LLM-route semantics)", () => { + const auth = new AuthManager(); + auth.setOAuthCredential(fullCred()); + + it("rejects /mcp and /mcp/{server} without the key when one is configured", async () => { + const handler = createFetchHandler({ config: makeConfig({ auth: { proxyApiKey: "secret-1" } }), auth, fetchImpl: upstreamCapture().fetch }); + const listing = await handler(new Request("http://localhost/mcp")); + const relay = await handler(new Request("http://localhost/mcp/tianyancha", { method: "POST", body: "{}" })); + expect(listing.status).toBe(401); + expect(relay.status).toBe(401); + }); + + it("accepts both routes with the correct key (authorization or x-api-key)", async () => { + const handler = createFetchHandler({ config: makeConfig({ auth: { proxyApiKey: "secret-1" } }), auth, fetchImpl: upstreamCapture().fetch }); + const listing = await handler(new Request("http://localhost/mcp", { headers: { authorization: "Bearer secret-1" } })); + const relay = await handler(new Request("http://localhost/mcp/tianyancha", { method: "POST", headers: { "x-api-key": "secret-1" }, body: "{}" })); + expect(listing.status).toBe(200); + expect(relay.status).toBe(200); + }); +}); + +describe("/mcp/{server} relay", () => { + it("POST forwards to the official gateway with injected credentials", async () => { + const auth = new AuthManager(); + auth.setOAuthCredential(fullCred()); + const upstream = upstreamCapture(); + const handler = createFetchHandler({ config: makeConfig(), auth, fetchImpl: upstream.fetch }); + + const resp = await handler(new Request("http://localhost/mcp/tianyancha", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer client-key" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + })); + + expect(resp.status).toBe(200); + expect(resp.headers.get("mcp-session-id")).toBe("sess-9"); + const { url, init } = upstream.seen(); + expect(url).toBe("https://zcode.z.ai/api/v1/mcp/server/finance_tianyancha"); + const headers = init.headers as Record; + expect(headers.Authorization).toBe("Bearer jwt-1"); + expect(headers["X-Bigmodel-Authorization"]).toBe("Bearer sk-x.sec"); + expect(headers["Bigmodel-Target-Type"]).toBe("PERSONAL"); + }); + + it("respects a configured upstreamOrigin override", async () => { + const auth = new AuthManager(); + auth.setOAuthCredential(fullCred()); + const upstream = upstreamCapture(); + const handler = createFetchHandler({ + config: makeConfig({}, { enabled: true, upstreamOrigin: "http://127.0.0.1:9999" }), + auth, + fetchImpl: upstream.fetch, + }); + const resp = await handler(new Request("http://localhost/mcp/wind-stock", { method: "POST", body: "{}" })); + expect(resp.status).toBe(200); + expect(upstream.seen().url).toBe("http://127.0.0.1:9999/api/v1/mcp/server/finance_wind_stock"); + }); + + it("unknown server → 404 mcp_server_not_found", async () => { + const auth = new AuthManager(); + auth.setOAuthCredential(fullCred()); + const handler = createFetchHandler({ config: makeConfig(), auth, fetchImpl: upstreamCapture().fetch }); + const resp = await handler(new Request("http://localhost/mcp/nope", { method: "POST", body: "{}" })); + expect(resp.status).toBe(404); + const body = await resp.json(); + expect(body.error.type).toBe("mcp_server_not_found"); + expect(body.error.message).toContain("GET /mcp"); + }); + + it("malformed percent-escape in the server key → 404, not 500", async () => { + const auth = new AuthManager(); + auth.setOAuthCredential(fullCred()); + const handler = createFetchHandler({ config: makeConfig(), auth, fetchImpl: upstreamCapture().fetch }); + const resp = await handler(new Request("http://localhost/mcp/%zz", { method: "POST", body: "{}" })); + expect(resp.status).toBe(404); + const body = await resp.json(); + expect(body.error.type).toBe("mcp_server_not_found"); + }); + + it("start-plan → 400 mcp_plan_unsupported (explicit, mirrors async routes)", async () => { + const auth = new AuthManager(); + auth.setOAuthCredential(fullCred()); + const handler = createFetchHandler({ config: makeConfig({ plan: "start-plan" }), auth, fetchImpl: upstreamCapture().fetch }); + const resp = await handler(new Request("http://localhost/mcp/tianyancha", { method: "POST", body: "{}" })); + expect(resp.status).toBe(400); + const body = await resp.json(); + expect(body.error.type).toBe("mcp_plan_unsupported"); + expect(body.error.message).toContain("coding-plan"); + }); + + it("not logged in → 400 mcp_credentials_unavailable", async () => { + const handler = createFetchHandler({ config: makeConfig(), auth: new AuthManager(), fetchImpl: upstreamCapture().fetch }); + const resp = await handler(new Request("http://localhost/mcp/tianyancha", { method: "POST", body: "{}" })); + expect(resp.status).toBe(400); + const body = await resp.json(); + expect(body.error.type).toBe("mcp_credentials_unavailable"); + expect(body.error.message).toContain("auth login"); + }); + + it("gateway disabled → routes disappear (404)", async () => { + const auth = new AuthManager(); + auth.setOAuthCredential(fullCred()); + const handler = createFetchHandler({ + config: makeConfig({}, { enabled: false, upstreamOrigin: "https://zcode.z.ai" }), + auth, + fetchImpl: upstreamCapture().fetch, + }); + const listing = await handler(new Request("http://localhost/mcp")); + const relay = await handler(new Request("http://localhost/mcp/tianyancha", { method: "POST", body: "{}" })); + expect(listing.status).toBe(404); + expect(relay.status).toBe(404); + }); +}); diff --git a/src/server/routes-mcp.ts b/src/server/routes-mcp.ts new file mode 100644 index 0000000..7cbb8fa --- /dev/null +++ b/src/server/routes-mcp.ts @@ -0,0 +1,200 @@ +/** + * Official plugin-MCP route handlers: `GET /mcp` (endpoint listing) and + * `/mcp/{server}` (streamable-HTTP relay to Z.AI's official gateway). + * + * Inbound auth is the shared proxyApiKey gate in `server.ts::createFetchHandler` + * (same semantics as the LLM routes); outbound auth is the stored OAuth + * credential via `src/mcp/relay.ts` (coding-plan only — the gateway rejects + * identity-only credentials with JSON-RPC 3101, so start-plan gets an explicit + * 400 here, mirroring the async-route pattern). + * + * Route registration lives in `server.ts` (gated by `config.mcp.gateway.enabled`). + * + * @see _reverse/NOTEPAD.md "Official plugin MCP" + */ +import { + buildOfficialMcpAuthHeaders, + relayOfficialMcpRequest, + McpCredentialsUnavailableError, + type OfficialMcpAuth, + type McpCredentialReason, +} from "../mcp/relay.js"; +import { OFFICIAL_MCP_CATALOGUE } from "../mcp/catalogue.js"; +import { errorResponse } from "../proxy/handler.js"; +import type { Credential } from "../auth/types.js"; +import type { AuthManager } from "../auth/manager.js"; +import type { ProxyConfig } from "../config/types.js"; + +export interface McpRouteOptions { + config: ProxyConfig; + auth: AuthManager; + fetchImpl?: typeof fetch; +} + +export type McpAuthStatus = + | { ok: true; auth: OfficialMcpAuth } + | { ok: false; reason: McpCredentialReason | "not_logged_in" | "plan_unsupported"; message: string }; + +function resolveMcpAuth(cred: Credential | null, plan: ProxyConfig["plan"]): McpAuthStatus { + if (plan !== "coding-plan") { + return { + ok: false, + reason: "plan_unsupported", + message: `official MCP endpoints require plan "coding-plan" (current plan: ${plan})`, + }; + } + if (!cred) { + return { + ok: false, + reason: "not_logged_in", + message: "not logged in — run: zcode-proxy auth login ", + }; + } + try { + return { ok: true, auth: buildOfficialMcpAuthHeaders(cred) }; + } catch (err) { + if (err instanceof McpCredentialsUnavailableError) { + return { ok: false, reason: err.reason, message: err.message }; + } + throw err; + } +} + +interface ListedServer { + key: string; + path: string; + methods: string[]; + routeId: string; + plugin: string; + pluginVersion: string; + requiresPaidPlan: boolean; + displayName: Record; + description: Record; + category?: string; +} + +/** GET /mcp — what is being served right now (JSON; HTML table for browsers). */ +export async function handleMcpListingRoute(req: Request, opts: McpRouteOptions): Promise { + const origin = opts.config.mcp.gateway.upstreamOrigin; + let cred: Credential | null = null; + try { + cred = await opts.auth.getCredential(); + } catch { + cred = null; + } + const status = resolveMcpAuth(cred, opts.config.plan); + + const servers: ListedServer[] = OFFICIAL_MCP_CATALOGUE.servers.map((s) => ({ + key: s.key, + path: `/mcp/${s.key}`, + methods: ["POST", "GET", "DELETE"], + routeId: s.routeId, + plugin: s.plugin, + pluginVersion: s.version, + requiresPaidPlan: s.requiresPaidPlan, + displayName: s.displayName, + description: s.description, + ...(s.category ? { category: s.category } : {}), + })); + + const body = { + object: "list", + generatedAt: OFFICIAL_MCP_CATALOGUE.generatedAt, + upstreamOrigin: origin, + authReady: status.ok, + authMode: status.ok ? status.auth.mode : null, + authError: status.ok ? null : status.reason, + servers, + }; + + const accept = req.headers.get("accept") ?? ""; + if (accept.includes("text/html")) { + return new Response(renderListingHtml(body), { + status: 200, + headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" }, + }); + } + return new Response(JSON.stringify(body, null, 2), { + status: 200, + headers: { "content-type": "application/json", "cache-control": "no-cache" }, + }); +} + +/** Any of POST/GET/DELETE /mcp/{server} — relay to the official gateway. */ +export async function handleMcpRelayRoute(req: Request, serverKey: string, opts: McpRouteOptions): Promise { + const def = OFFICIAL_MCP_CATALOGUE.servers.find((s) => s.key === serverKey); + if (!def) { + return errorResponse(404, "mcp_server_not_found", `Unknown MCP server "${serverKey}" — see GET /mcp for the served endpoints`); + } + if (opts.config.plan !== "coding-plan") { + return errorResponse( + 400, + "mcp_plan_unsupported", + `official MCP endpoints require plan "coding-plan" (current plan: ${opts.config.plan})`, + ); + } + + let cred: Credential; + try { + cred = await opts.auth.getCredential(); + } catch (err) { + return errorResponse(400, "mcp_credentials_unavailable", (err as Error).message); + } + let auth: OfficialMcpAuth; + try { + auth = buildOfficialMcpAuthHeaders(cred); + } catch (err) { + if (err instanceof McpCredentialsUnavailableError) { + return errorResponse(400, "mcp_credentials_unavailable", err.message); + } + throw err; + } + + const incoming = new URL(req.url); + const upstreamUrl = `${opts.config.mcp.gateway.upstreamOrigin}${def.path}${incoming.search}`; + const { response } = await relayOfficialMcpRequest(req, auth, upstreamUrl, opts.fetchImpl); + return response; +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +interface ListingBody { + generatedAt: string; + upstreamOrigin: string; + authReady: boolean; + authMode: string | null; + authError: string | null; + servers: ListedServer[]; +} + +function renderListingHtml(body: ListingBody): string { + const rows = body.servers + .map((s) => { + const name = s.displayName["zh-CN"] ?? s.displayName.en ?? s.key; + const desc = s.description["zh-CN"] ?? s.description.en ?? ""; + return ` ${escapeHtml(s.key)}${escapeHtml(name)}${escapeHtml(desc)}${escapeHtml(s.routeId)}${s.requiresPaidPlan ? "yes" : "no"}`; + }) + .join("\n"); + const authLine = body.authReady + ? `auth: ready (${escapeHtml(String(body.authMode))})` + : `auth: not ready (${escapeHtml(String(body.authError))})`; + return ` + +zcode-proxy · MCP endpoints + + + +

MCP endpoints

+

${authLine} · gateway: ${escapeHtml(body.upstreamOrigin)} · catalogue: ${escapeHtml(body.generatedAt)}

+ + + +${rows} + +
endpointnamedescriptionroute idpaid plan
+ + +`; +} diff --git a/src/server/routes-quota.test.ts b/src/server/routes-quota.test.ts index 26a2c54..c7cbd9c 100644 --- a/src/server/routes-quota.test.ts +++ b/src/server/routes-quota.test.ts @@ -32,7 +32,7 @@ function makeConfig(overrides: Partial = {}): ProxyConfig { responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, - mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.chatglm.site" } }, async: { enabled: false, origin: "https://zcode.z.ai", diff --git a/src/server/server.test.ts b/src/server/server.test.ts index ffd7cdc..d1ed73a 100644 --- a/src/server/server.test.ts +++ b/src/server/server.test.ts @@ -34,7 +34,7 @@ function makeConfig(overrides: Partial = {}): ProxyConfig { responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, - mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false, gateway: { enabled: true, upstreamOrigin: "https://zcode.chatglm.site" } }, async: { enabled: false, origin: "https://zcode.z.ai", pollIntervalMs: 5000, keepAliveIntervalMs: 3000, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 8000, controlTimeoutMs: 15000, defaultModel: "" }, claim: { enabled: false, auto: true, origin: "https://zcode.z.ai", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, logging: { level: "info" }, @@ -262,14 +262,28 @@ describe("proxy API key auth", () => { }); describe("CORS", () => { - it("OPTIONS returns 204 with CORS headers", async () => { - const config = makeConfig(); + it("OPTIONS with proxyApiKey set returns 204 with CORS headers", async () => { + const config = makeConfig({ auth: { proxyApiKey: "secret" } }); const auth = oauthAuth("test"); const handler = createFetchHandler({ config, auth }); const resp = await handler(new Request("http://localhost/v1/models", { method: "OPTIONS" })); expect(resp.status).toBe(204); expect(resp.headers.get("access-control-allow-origin")).toBe("*"); + expect(resp.headers.get("access-control-allow-methods")).toContain("DELETE"); + }); + + it("keyless deployment emits no CORS headers (blocks cross-origin browsers)", async () => { + const config = makeConfig({ auth: {} }); + const auth = oauthAuth("test"); + const handler = createFetchHandler({ config, auth }); + + const preflight = await handler(new Request("http://localhost/v1/models", { method: "OPTIONS" })); + expect(preflight.status).toBe(204); + expect(preflight.headers.get("access-control-allow-origin")).toBeNull(); + + const resp = await handler(new Request("http://localhost/v1/models", { method: "GET" })); + expect(resp.headers.get("access-control-allow-origin")).toBeNull(); }); }); diff --git a/src/server/server.ts b/src/server/server.ts index 01fc2ca..76f33a2 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -17,6 +17,7 @@ import { handleChatCompletions, handleListModels } from "./routes-openai.js"; import { handleMessages } from "./routes-anthropic.js"; import { handleResponsesRoute } from "./routes-responses.js"; import { handleAsyncMessagesRoute, handleAsyncChatRoute, handleAsyncHealthRoute } from "./routes-async.js"; +import { handleMcpListingRoute, handleMcpRelayRoute, type McpRouteOptions } from "./routes-mcp.js"; import { handleQuota } from "./routes-quota.js"; import { errorResponse } from "../proxy/handler.js"; import type { ResponseStore } from "../responses/store.js"; @@ -59,15 +60,21 @@ export function createFetchHandler(opts: ServerOptions): (req: Request) => Promi fetchImpl: opts.fetchImpl, debug: opts.debug === true, }; + const mcpOpts: McpRouteOptions = { + config, + auth, + ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}), + }; return async (req: Request): Promise => { const url = new URL(req.url); const path = url.pathname; const method = req.method; + const cors = corsHeaders(Boolean(config.auth.proxyApiKey)); // CORS preflight if (method === "OPTIONS") { - return corsResponse(); + return corsResponse(cors); } if (method === "GET" && (path === "/webui" || path.startsWith("/webui/"))) { @@ -129,6 +136,28 @@ export function createFetchHandler(opts: ServerOptions): (req: Request) => Promi } } + if (config.mcp.gateway.enabled) { + if (path === "/mcp" && method === "GET") { + return handleMcpListingRoute(req, mcpOpts); + } + // MCP streamable HTTP: POST (JSON-RPC), GET (SSE stream), DELETE + // (session close). `/mcp` itself only supports GET above. + if ( + path.startsWith("/mcp/") && + (method === "POST" || method === "GET" || method === "DELETE") + ) { + let serverKey = path.slice("/mcp/".length); + try { + serverKey = decodeURIComponent(serverKey); + } catch { + // Malformed percent-escape (e.g. /mcp/%zz): decodeURIComponent throws + // URIError; the raw string can never match a catalogue key, so let + // the handler's lookup produce the 404 instead of a 500 here. + } + return handleMcpRelayRoute(req, serverKey, mcpOpts); + } + } + if (path === "/health" || path === "/") { return new Response(JSON.stringify({ status: "ok", provider: config.provider }), { status: 200, @@ -151,6 +180,7 @@ export function createFetchHandler(opts: ServerOptions): (req: Request) => Promi export function startServer(opts: ServerOptions): Promise { const handler = createFetchHandler(opts); const { port: requestedPort, host } = opts.config.server; + const cors = corsHeaders(Boolean(opts.config.auth.proxyApiKey)); const server: Server = createServer(async (req, res) => { const abortController = new AbortController(); @@ -162,15 +192,16 @@ export function startServer(opts: ServerOptions): Promise { // `/async/*` routes can hold the connection open for minutes-to-hours while // waiting for an off-peak ticket. Lift the per-request socket timeout from // the default 600s (set below via server.requestTimeout) to 24h so the long - // queue wait + LLM stream doesn't get killed mid-flight. Non-async routes - // keep the default timeout. - if ((req.url ?? "").startsWith("/async/")) { + // queue wait + LLM stream doesn't get killed mid-flight. `/mcp/*` GET SSE + // streams are equally long-lived. Other routes keep the default timeout. + const pathForTimeout = req.url ?? ""; + if (pathForTimeout.startsWith("/async/") || pathForTimeout.startsWith("/mcp/")) { req.setTimeout(24 * 60 * 60 * 1000); } try { const webReq = nodeReqToWebRequest(req, abortController.signal); - const resp = await handler(webReq).then((r) => addCorsHeaders(r)); + const resp = await handler(webReq).then((r) => addCorsHeaders(r, cors)); await writeWebResponseToNodeResp(resp, res, abortController.signal); } catch (err) { if (abortController.signal.aborted) return; @@ -299,17 +330,18 @@ function checkProxyKey(authHeader: string, expected: string): boolean { } /** Build a CORS preflight response. */ -function corsResponse(): Response { +function corsResponse(cors: Record): Response { return new Response(null, { status: 204, - headers: corsHeaders(), + headers: cors, }); } /** Add CORS headers to an existing response (non-mutating). */ -function addCorsHeaders(resp: Response): Response { +function addCorsHeaders(resp: Response, cors: Record): Response { + if (Object.keys(cors).length === 0) return resp; const headers = new Headers(resp.headers); - for (const [k, v] of Object.entries(corsHeaders())) { + for (const [k, v] of Object.entries(cors)) { headers.set(k, v); } return new Response(resp.body, { @@ -319,11 +351,23 @@ function addCorsHeaders(resp: Response): Response { }); } -function corsHeaders(): Record { +/** + * CORS headers are emitted ONLY when `auth.proxyApiKey` is set. + * + * Rationale: the /mcp relay (and /v1/*) inject the operator's real OAuth + * credentials upstream; with `access-control-allow-origin: *` on a keyless + * deployment, any webpage in a logged-in user's browser could pass preflight + * and drive those routes cross-origin (MCP JSON POSTs are preflighted, so + * withholding the headers blocks the browser before the request fires). + * Local CLI/curl tools and the same-origin /webui never needed CORS. + */ +function corsHeaders(corsEnabled: boolean): Record { + if (!corsEnabled) return {}; return { "access-control-allow-origin": "*", - "access-control-allow-methods": "GET, POST, OPTIONS", - "access-control-allow-headers": "Content-Type, Authorization, x-api-key, anthropic-version, anthropic-beta", + "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", + "access-control-allow-headers": "Content-Type, Authorization, x-api-key, anthropic-version, anthropic-beta, mcp-session-id, mcp-protocol-version, last-event-id", + "access-control-expose-headers": "mcp-session-id, mcp-protocol-version", "access-control-max-age": "86400", }; }