UNPKG

@tangle-network/tcloud

Version:

TypeScript SDK and CLI for Tangle Router, Sandbox, model routing, and agent service calls

299 lines (298 loc) • 10.9 kB
import { t as packageVersion } from "./version-BwTKR1na.js"; import * as readline from "node:readline"; //#region src/mcp.ts /** * Minimal Model Context Protocol (MCP) stdio server exposing Tangle capabilities as tools. * * Two tools over the Tangle router: * - `web_search` — live web results (title/url/snippet) via `client.search`. * - `deep_research` — a multi-step synthesized research answer via `client.research` * (slower/costlier; for cross-source synthesis, not single facts). * It is dependency-free on purpose: newline-delimited JSON-RPC 2.0 over stdin/stdout (the MCP * stdio transport), so ANY consumer mounts it with one config line and zero extra packages: * * opencode/claude/codex MCP config: { "command": ["tcloud", "mcp"] } * * stdout is the protocol channel — every diagnostic goes to stderr so it never corrupts a frame. */ /** Providers accepted by `web_search` — validated before forwarding so a typo * fails fast with -32602 rather than reaching the router as garbage. */ const SEARCH_PROVIDERS = /* @__PURE__ */ new Set([ "perplexity", "exa", "you", "parallel", "tavily", "brave" ]); /** Providers accepted by `deep_research` (no `brave` — it has no research API). */ const RESEARCH_PROVIDERS = /* @__PURE__ */ new Set([ "perplexity", "exa", "you", "parallel", "tavily" ]); /** Recency windows shared by both tools. */ const RECENCY_WINDOWS = /* @__PURE__ */ new Set([ "day", "week", "month", "year" ]); /** Protocol versions this server speaks. `initialize` clamps the client's * requested version to one of these; unknown requests fall back to the newest. */ const SUPPORTED_PROTOCOL_VERSIONS = /* @__PURE__ */ new Set(["2025-06-18", "2025-03-26"]); const DEFAULT_PROTOCOL_VERSION = "2025-06-18"; const DOMAIN_FILTER_SCHEMA = { type: "array", items: { type: "string" } }; /** The tools this server advertises: web_search for live results, deep_research for * synthesized multi-step answers — the two grounding tools for research workers. */ const TOOLS = [{ name: "web_search", description: "Search the web via the Tangle router and return live results (title, url, snippet). Use it to find primary sources and to VERIFY that a citation (paper, PMID/DOI, patent, fact) actually exists before relying on it.", inputSchema: { type: "object", properties: { query: { type: "string", description: "The search query." }, provider: { type: "string", description: "Optional provider: exa | parallel | perplexity | tavily | brave | you." }, maxResults: { type: "number", description: "Optional max number of results." }, recency: { type: "string", description: "Optional recency window: day | week | month | year." }, includeDomains: { ...DOMAIN_FILTER_SCHEMA, description: "Optional: restrict results to these domains." }, excludeDomains: { ...DOMAIN_FILTER_SCHEMA, description: "Optional: drop results from these domains." } }, required: ["query"] } }, { name: "deep_research", description: "Run a multi-step DEEP RESEARCH task via the Tangle router: the provider reads many sources and returns one synthesized answer with citations. Slower and costlier than web_search — use it for questions that need cross-source synthesis (current API/version landscapes, comparisons, \"state of X\"), not single-fact lookups.", inputSchema: { type: "object", properties: { query: { type: "string", description: "The research question." }, provider: { type: "string", description: "Optional provider: you | exa | perplexity | tavily | parallel." }, effort: { type: "string", description: "Optional depth/cost dial (provider-specific): you lite|standard|deep|exhaustive · perplexity minimal|low|medium|high · exa deep-lite|deep|deep-reasoning · tavily mini|pro|auto · parallel lite|base|core|pro|ultra." }, maxResults: { type: "number", description: "Optional max number of supporting sources." }, searchRecency: { type: "string", description: "Optional recency window: day | week | month | year." }, includeDomains: { ...DOMAIN_FILTER_SCHEMA, description: "Optional: restrict sources to these domains." }, excludeDomains: { ...DOMAIN_FILTER_SCHEMA, description: "Optional: drop sources from these domains." }, outputSchema: { type: "object", description: "Optional JSON schema requesting structured output from the provider." } }, required: ["query"] } }]; /** A validation failure that maps to a JSON-RPC -32602 (Invalid params). */ var InvalidParams = class extends Error {}; /** Coerce an optional maxResults to a forwardable value. Drops NaN / negative * inputs ("best effort optional") rather than failing the whole call. */ function optionalMaxResults(value) { if (value == null) return void 0; const n = Number(value); return Number.isFinite(n) && n >= 0 ? n : void 0; } /** Validate + narrow a string against an allowed set, or throw InvalidParams. */ function requireEnum(value, allowed, label) { if (value == null) return void 0; if (typeof value !== "string" || !allowed.has(value)) throw new InvalidParams(`invalid ${label}: ${String(value)}`); return value; } /** Forward only string-array domain filters; ignore anything else. */ function optionalDomains(value) { if (!Array.isArray(value)) return void 0; const domains = value.filter((d) => typeof d === "string"); return domains.length ? domains : void 0; } /** * Run the MCP stdio server against an authenticated TCloud client. Resolves when input closes. * Handles `initialize`, `tools/list`, `tools/call` (+ ignores notifications, which carry no id). * * Requests are dispatched without blocking the read loop, so a slow `deep_research` * call cannot stall concurrent `web_search` requests — each response carries its * own id. Pass `{ input, output }` to drive the transport over arbitrary streams * (tests inject in-memory streams); both default to the process stdio. */ async function runMcpServer(client, opts = {}) { const input = opts.input ?? process.stdin; const output = opts.output ?? process.stdout; const rl = readline.createInterface({ input }); const send = (msg) => { output.write(`${JSON.stringify(msg)}\n`); }; const ok = (id, result) => send({ jsonrpc: "2.0", id, result }); const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } }); /** Tool execution failed — per MCP spec, surface it as tool content, not a protocol error. */ const toolError = (id, message) => ok(id, { content: [{ type: "text", text: message }], isError: true }); process.stderr.write("tcloud mcp: web_search + deep_research server ready (stdio)\n"); async function handleRequest(req) { const { id, method, params } = req; try { if (method === "initialize") { const requested = params?.protocolVersion ?? DEFAULT_PROTOCOL_VERSION; const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.has(requested) ? requested : DEFAULT_PROTOCOL_VERSION; ok(id, { protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "tangle-tcloud", version: packageVersion() } }); } else if (method === "tools/list") ok(id, { tools: TOOLS }); else if (method === "tools/call") { const name = params?.name; if (name === "web_search") await handleWebSearch(id, params?.arguments ?? {}); else if (name === "deep_research") await handleDeepResearch(id, params?.arguments ?? {}); else fail(id, -32602, `unknown tool: ${String(name)}`); } else fail(id, -32601, `method not found: ${String(method)}`); } catch (e) { if (e instanceof InvalidParams) fail(id, -32602, e.message); else fail(id, -32603, e instanceof Error ? e.message : String(e)); } } async function handleWebSearch(id, args) { const query = String(args.query ?? "").trim(); if (!query) { fail(id, -32602, "web_search requires a non-empty \"query\""); return; } const provider = requireEnum(args.provider, SEARCH_PROVIDERS, "provider"); const searchRecency = requireEnum(args.recency, RECENCY_WINDOWS, "recency"); const maxResults = optionalMaxResults(args.maxResults); const includeDomains = optionalDomains(args.includeDomains); const excludeDomains = optionalDomains(args.excludeDomains); try { const resp = await client.search({ query, ...provider ? { provider } : {}, ...maxResults != null ? { maxResults } : {}, ...searchRecency ? { searchRecency } : {}, ...includeDomains ? { includeDomains } : {}, ...excludeDomains ? { excludeDomains } : {} }); const hits = resp.data ?? []; const text = hits.length ? hits.map((h, i) => `${i + 1}. ${h.title}\n ${h.url}${h.snippet ? `\n ${h.snippet}` : ""}`).join("\n\n") : `No results for "${resp.query ?? query}".`; ok(id, { content: [{ type: "text", text }] }); } catch (e) { toolError(id, e instanceof Error ? e.message : String(e)); } } async function handleDeepResearch(id, args) { const query = String(args.query ?? "").trim(); if (!query) { fail(id, -32602, "deep_research requires a non-empty \"query\""); return; } const provider = requireEnum(args.provider, RESEARCH_PROVIDERS, "provider"); const searchRecency = requireEnum(args.searchRecency, RECENCY_WINDOWS, "searchRecency"); const maxResults = optionalMaxResults(args.maxResults); const includeDomains = optionalDomains(args.includeDomains); const excludeDomains = optionalDomains(args.excludeDomains); try { const resp = await client.research({ query, ...provider ? { provider } : {}, ...typeof args.effort === "string" ? { effort: args.effort } : {}, ...maxResults != null ? { maxResults } : {}, ...searchRecency ? { searchRecency } : {}, ...includeDomains ? { includeDomains } : {}, ...excludeDomains ? { excludeDomains } : {}, ...args.outputSchema !== void 0 ? { outputSchema: args.outputSchema } : {} }); const sources = resp.results ?? []; const sourceList = sources.length ? `\n\nSources:\n${sources.map((h, i) => `${i + 1}. ${h.title}\n ${h.url}`).join("\n")}` : ""; const text = `${resp.answer ?? ""}${sourceList}`.trim() || `No research result for "${resp.query ?? query}".`; ok(id, { content: [{ type: "text", text }] }); } catch (e) { toolError(id, e instanceof Error ? e.message : String(e)); } } for await (const line of rl) { const trimmed = line.trim(); if (!trimmed) continue; let req; try { req = JSON.parse(trimmed); } catch { send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }); continue; } if (req.id === void 0 || req.id === null) continue; handleRequest(req); } } //#endregion export { runMcpServer };