File size: 7,479 Bytes
67d18ac | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | /**
* GLM MCP server registry + tool-call wrappers for the three official remote
* MCP servers (web_search_prime / web_reader / zread). All three use the same
* GLM API key as the LLM upstream, and their endpoints are derived from the
* active provider (see `glmMcpEndpoint`).
*
* Used by the Responses handler to:
* - intercept `web_search` / `web_search_preview` hosted tools and route
* them to GLM's `web_search_prime` MCP tool,
* - (P2, off by default) inject `web_reader` / `zread` as function tools the
* model can call directly.
*
* Each wrapper decodes the MCP result into a typed shape. `web_search_prime` is
* the trickiest: its `content[0].text` is a *doubly-encoded* JSON string (a
* stringified JSON array). The wrapper does `JSON.parse` once to unwrap the
* array, leaving consumers with a clean `WebSearchHit[]`.
*/
import { McpClient, glmMcpEndpoint, type GlmMcpServerId, type McpClientOptions } from "./client.js";
import type { ProviderId } from "../provider/types.js";
// βββββββββββββββββββββββββββββββββββββββββββββ
// Result types
// βββββββββββββββββββββββββββββββββββββββββββββ
export interface WebSearchHit {
title: string;
link: string;
content: string;
/** GLM's per-result reference id (e.g. "ref_1"). */
refer?: string;
}
export interface WebPageContent {
/** Decoded page content (markdown or text, per `return_format`). */
text: string;
/** Raw MCP content blocks (preserved for future field extraction). */
raw: Array<{ type: string; text?: string }>;
}
export interface ZreadSearchResult {
// GLM zread returns free-form text content; keep it loose.
text: string;
}
export interface ZreadRepoStructure {
text: string;
}
export interface ZreadFileContent {
text: string;
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// Pool: one McpClient per (provider, server) β reuses the session.
// βββββββββββββββββββββββββββββββββββββββββββββ
export interface GlmMcpPoolOptions {
provider: ProviderId;
apiKey: string;
fetchImpl?: typeof fetch;
timeoutMs?: number;
}
/**
* Lazily-initialised pool of MCP clients keyed by server id. Reuses sessions
* across calls (GLM sessions are stateful and expensive to re-establish).
* Call `close()` on shutdown to reset all sessions (best-effort).
*/
export class GlmMcpPool {
private readonly opts: GlmMcpPoolOptions;
private readonly clients = new Map<GlmMcpServerId, McpClient>();
constructor(opts: GlmMcpPoolOptions) {
this.opts = opts;
}
private client(server: GlmMcpServerId): McpClient {
let c = this.clients.get(server);
if (!c) {
const clientOpts: McpClientOptions = {
url: glmMcpEndpoint(this.opts.provider, server),
apiKey: this.opts.apiKey,
fetchImpl: this.opts.fetchImpl,
timeoutMs: this.opts.timeoutMs,
};
c = new McpClient(clientOpts);
this.clients.set(server, c);
}
return c;
}
/** Reset all cached sessions (e.g. on credential rotation). */
reset(): void {
for (const c of this.clients.values()) c.reset();
}
// ββ web_search_prime ββ
/**
* Search the web via GLM's MCP. `arguments` mirror GLM's inputSchema
* (verified by live `tools/list`):
* - `search_query` (required)
* - `search_domain_filter`, `search_recency_filter`, `content_size`, `location`
*/
async webSearch(args: {
search_query: string;
search_domain_filter?: string;
search_recency_filter?: "oneDay" | "oneWeek" | "oneMonth" | "oneYear" | "noLimit";
content_size?: "medium" | "high";
location?: "cn" | "us";
}): Promise<WebSearchHit[]> {
const result = await this.client("web_search_prime").callTool("web_search_prime", args as Record<string, unknown>);
return decodeWebSearchResult(result.content);
}
// ββ web_reader ββ
/** Fetch a URL's content via GLM's MCP `webReader` tool. */
async webRead(args: {
url: string;
return_format?: "markdown" | "text";
no_cache?: boolean;
retain_images?: boolean;
no_gfm?: boolean;
with_images_summary?: boolean;
with_links_summary?: boolean;
timeout?: number;
}): Promise<WebPageContent> {
const result = await this.client("web_reader").callTool("webReader", args as Record<string, unknown>);
return { text: joinText(result.content), raw: result.content as Array<{ type: string; text?: string }> };
}
// ββ zread ββ
/** Search a GitHub repo's docs/issues/commits via GLM's `zread` MCP. */
async zreadSearchDoc(args: { repo_name: string; query: string; language?: "zh" | "en" }): Promise<ZreadSearchResult> {
const result = await this.client("zread").callTool("search_doc", args as Record<string, unknown>);
return { text: joinText(result.content) };
}
/** Get a GitHub repo's directory structure via GLM's `zread` MCP. */
async zreadGetRepoStructure(args: { repo_name: string; dir_path?: string }): Promise<ZreadRepoStructure> {
const result = await this.client("zread").callTool("get_repo_structure", args as Record<string, unknown>);
return { text: joinText(result.content) };
}
/** Read a file in a GitHub repo via GLM's `zread` MCP. */
async zreadReadFile(args: { repo_name: string; file_path: string }): Promise<ZreadFileContent> {
const result = await this.client("zread").callTool("read_file", args as Record<string, unknown>);
return { text: joinText(result.content) };
}
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// Decoders
// βββββββββββββββββββββββββββββββββββββββββββββ
/**
* Decode `web_search_prime`'s doubly-encoded result. The MCP `content[0].text`
* field holds a JSON-stringified array: `"[{\"title\":...,\"link\":..., ...}]"`.
* Caller-friendly: returns `[]` on any parse failure rather than throwing.
*/
export function decodeWebSearchResult(contentBlocks: Array<{ type: string; text?: string }>): WebSearchHit[] {
const text = contentBlocks.find((c) => c.type === "text")?.text;
if (!text) return [];
// First unwrap: text β JSON array string.
let arrayStr: string;
try {
// The text field itself may be a quoted JSON string ("[...]" β [...])
const unquoted = JSON.parse(text);
arrayStr = typeof unquoted === "string" ? unquoted : text;
} catch {
arrayStr = text;
}
let arr: unknown;
try {
arr = JSON.parse(arrayStr);
} catch {
return [];
}
if (!Array.isArray(arr)) return [];
return arr.filter((h): h is WebSearchHit => {
if (typeof h !== "object" || h === null) return false;
const hit = h as Record<string, unknown>;
return typeof hit.title === "string" && typeof hit.link === "string";
});
}
/** Concatenate all `text` blocks in an MCP result into a single string. */
function joinText(content: Array<{ type: string; text?: string }>): string {
return content
.filter((c) => c.type === "text" && typeof c.text === "string")
.map((c) => c.text as string)
.join("\n");
}
|