Skip to content
142 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.1/**
2 * A small client for MCP servers over Streamable HTTP
3 * (docs.g1t.sh/guides/agent-abilities/, "MCP servers"): lists a server's
4 * tools when an owner adds it, and calls one when an agent's ability
5 * allows. JSON-RPC, one request per call, a fresh session each time; an
6 * answer may come back as JSON or as a server-sent event stream. No
7 * sign-in: servers that need a key aren't supported yet, and the docs say
8 * so. Pure apart from `fetch`, so it is tested with a fake.
9 */
10import type { McpTool } from "@g1t/contracts";
11
12import { MAX_MCP_TOOLS } from "../../../packages/contracts/src/abilities.ts";
13
14const PROTOCOL = "2025-06-18";
15/** How long one request to a server may take. */
16const TIMEOUT_MS = 15_000;
17/** The most of a tool's answer an agent is given. */
18const MAX_TEXT = 40_000;
19
20export type Fetch = (url: string, init: RequestInit) => Promise<Response>;
21
22type Rpc = { id?: number | string | null; result?: unknown; error?: { code?: number; message?: string } };
23
24/** One JSON-RPC exchange with the server; the body of the matching answer, or why there is none. */
25async function exchange(fetchFn: Fetch, url: string, session: string | null, body: Record<string, unknown>, expectAnswer: boolean): Promise<{ ok: true; answer: Rpc | null; session: string | null } | { ok: false; message: string }> {
26 let response: Response;
27 try {
28 response = await fetchFn(url, {
29 method: "POST",
30 headers: {
31 "content-type": "application/json",
32 accept: "application/json, text/event-stream",
33 "mcp-protocol-version": PROTOCOL,
34 ...(session ? { "mcp-session-id": session } : {}),
35 },
36 body: JSON.stringify({ jsonrpc: "2.0", ...body }),
37 signal: AbortSignal.timeout(TIMEOUT_MS),
38 redirect: "error",
39 });
40 } catch (error) {
41 return { ok: false, message: `The server couldn't be reached (${String(error).slice(0, 120)}).` };
42 }
43 const next = response.headers.get("mcp-session-id") ?? session;
44 if (!expectAnswer) return { ok: true, answer: null, session: next };
45 if (!response.ok) return { ok: false, message: `The server answered ${response.status}.` };
46 const type = (response.headers.get("content-type") ?? "").toLowerCase();
47 let text: string;
48 try {
49 text = await response.text();
50 } catch {
51 return { ok: false, message: "The server's answer couldn't be read." };
52 }
53 const wanted = body.id;
54 const candidates: Rpc[] = [];
55 if (type.includes("text/event-stream")) {
56 for (const event of text.split(/\n\n+/)) {
57 const data = event
58 .split("\n")
59 .filter((line) => line.startsWith("data:"))
60 .map((line) => line.slice(5).trim())
61 .join("\n");
62 if (!data) continue;
63 try {
64 const parsed = JSON.parse(data) as Rpc | Rpc[];
65 candidates.push(...(Array.isArray(parsed) ? parsed : [parsed]));
66 } catch {
67 // Not JSON: a keep-alive or a comment.
68 }
69 }
70 } else {
71 try {
72 const parsed = JSON.parse(text) as Rpc | Rpc[];
73 candidates.push(...(Array.isArray(parsed) ? parsed : [parsed]));
74 } catch {
75 return { ok: false, message: "The server didn't answer with JSON-RPC." };
76 }
77 }
78 const answer = candidates.find((c) => c && typeof c === "object" && c.id === wanted) ?? null;
79 if (!answer) return { ok: false, message: "The server didn't answer the request." };
80 return { ok: true, answer, session: next };
81}
82
83/** Opens a session: initialize, then the initialized notification. The session id, if the server gave one. */
84async function handshake(fetchFn: Fetch, url: string): Promise<{ ok: true; session: string | null } | { ok: false; message: string }> {
85 const opened = await exchange(
86 fetchFn,
87 url,
88 null,
89 { id: 1, method: "initialize", params: { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: "g1t-agents", version: "1" } } },
90 true,
91 );
92 if (!opened.ok) return opened;
93 if (opened.answer?.error) return { ok: false, message: `The server refused to start: ${opened.answer.error.message ?? "no reason given"}.` };
94 const told = await exchange(fetchFn, url, opened.session, { method: "notifications/initialized" }, false);
95 return { ok: true, session: told.ok ? told.session : opened.session };
96}
97
98/** A tool as the server lists it, as kept: its kind from `readOnlyHint`, a write when the server doesn't say. */
99function toolOf(raw: unknown): McpTool | null {
100 if (!raw || typeof raw !== "object") return null;
101 const t = raw as { name?: unknown; description?: unknown; inputSchema?: unknown; annotations?: { readOnlyHint?: unknown } };
102 if (typeof t.name !== "string" || !/^[A-Za-z0-9_.-]{1,64}$/.test(t.name)) return null;
103 const schema = t.inputSchema && typeof t.inputSchema === "object" && !Array.isArray(t.inputSchema) ? (t.inputSchema as Record<string, unknown>) : { type: "object", properties: {} };
104 return {
105 name: t.name,
106 description: typeof t.description === "string" ? t.description.trim().slice(0, 500) : "",
107 kind: t.annotations?.readOnlyHint === true ? "read" : "write",
108 input_schema: JSON.stringify(schema).length > 20_000 ? { type: "object", properties: {} } : schema,
109 };
110}
111
112/** The server's tools, at most `MAX_MCP_TOOLS`, or why they couldn't be listed. */
113export async function listMcpTools(url: string, fetchFn: Fetch = (u, init) => fetch(u, init)): Promise<{ ok: true; tools: McpTool[] } | { ok: false; message: string }> {
114 const opened = await handshake(fetchFn, url);
115 if (!opened.ok) return opened;
116 const listed = await exchange(fetchFn, url, opened.session, { id: 2, method: "tools/list", params: {} }, true);
117 if (!listed.ok) return listed;
118 if (listed.answer?.error) return { ok: false, message: `The server couldn't list its tools: ${listed.answer.error.message ?? "no reason given"}.` };
119 const raw = (listed.answer?.result as { tools?: unknown } | undefined)?.tools;
120 if (!Array.isArray(raw)) return { ok: false, message: "The server listed no tools." };
121 const tools = raw.map(toolOf).filter((tool): tool is McpTool => tool !== null);
122 const names = new Set<string>();
123 return { ok: true, tools: tools.filter((tool) => (names.has(tool.name) ? false : (names.add(tool.name), true))).slice(0, MAX_MCP_TOOLS) };
124}
125
126/** Calls one tool; the text it returned (its text parts joined), or why it didn't work. */
127export async function callMcpTool(url: string, name: string, args: Record<string, unknown>, fetchFn: Fetch = (u, init) => fetch(u, init)): Promise<{ ok: true; text: string } | { ok: false; message: string }> {
128 const opened = await handshake(fetchFn, url);
129 if (!opened.ok) return opened;
130 const called = await exchange(fetchFn, url, opened.session, { id: 3, method: "tools/call", params: { name, arguments: args } }, true);
131 if (!called.ok) return called;
132 if (called.answer?.error) return { ok: false, message: called.answer.error.message ?? "the server refused" };
133 const result = called.answer?.result as { content?: unknown; isError?: unknown; structuredContent?: unknown } | undefined;
134 const parts = Array.isArray(result?.content) ? (result!.content as { type?: string; text?: string }[]) : [];
135 let text = parts
136 .map((part) => (part?.type === "text" && typeof part.text === "string" ? part.text : part?.type ? `[${part.type} content]` : ""))
137 .filter(Boolean)
138 .join("\n");
139 if (!text && result?.structuredContent !== undefined) text = JSON.stringify(result.structuredContent).slice(0, MAX_TEXT);
140 if (result?.isError === true) return { ok: false, message: text.slice(0, 500) || "the tool reported an error" };
141 return { ok: true, text: (text || "(no content)").slice(0, MAX_TEXT) };
142}