@neuraltrust/trustgate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +132 -0
  3. package/dist/agent.d.ts +156 -0
  4. package/dist/agent.d.ts.map +1 -0
  5. package/dist/agent.js +216 -0
  6. package/dist/agent.js.map +1 -0
  7. package/dist/client.d.ts +88 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +152 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/config.d.ts +44 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +40 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/connections.d.ts +13 -0
  16. package/dist/connections.d.ts.map +1 -0
  17. package/dist/connections.js +46 -0
  18. package/dist/connections.js.map +1 -0
  19. package/dist/errors.d.ts +91 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +144 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/formats.d.ts +44 -0
  24. package/dist/formats.d.ts.map +1 -0
  25. package/dist/formats.js +299 -0
  26. package/dist/formats.js.map +1 -0
  27. package/dist/http.d.ts +23 -0
  28. package/dist/http.d.ts.map +1 -0
  29. package/dist/http.js +94 -0
  30. package/dist/http.js.map +1 -0
  31. package/dist/index.d.ts +10 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +10 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/mcp.d.ts +43 -0
  36. package/dist/mcp.d.ts.map +1 -0
  37. package/dist/mcp.js +175 -0
  38. package/dist/mcp.js.map +1 -0
  39. package/dist/schema.d.ts +52 -0
  40. package/dist/schema.d.ts.map +1 -0
  41. package/dist/schema.js +168 -0
  42. package/dist/schema.js.map +1 -0
  43. package/dist/types.d.ts +78 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +52 -0
  46. package/dist/types.js.map +1 -0
  47. package/dist/whoami.d.ts +73 -0
  48. package/dist/whoami.d.ts.map +1 -0
  49. package/dist/whoami.js +79 -0
  50. package/dist/whoami.js.map +1 -0
  51. package/package.json +29 -0
  52. package/src/agent.ts +267 -0
  53. package/src/client.ts +203 -0
  54. package/src/config.ts +78 -0
  55. package/src/connections.ts +90 -0
  56. package/src/errors.ts +154 -0
  57. package/src/formats.ts +362 -0
  58. package/src/http.ts +106 -0
  59. package/src/index.ts +41 -0
  60. package/src/mcp.ts +203 -0
  61. package/src/schema.ts +193 -0
  62. package/src/types.ts +98 -0
  63. package/src/whoami.ts +172 -0
package/src/formats.ts ADDED
@@ -0,0 +1,362 @@
1
+ import { inlineRefs, stripInjectedNulls, toStrict } from "./schema.js";
2
+ import {
3
+ ToolFormat,
4
+ type GatewayTool,
5
+ type JSONSchema,
6
+ type ToolCall,
7
+ } from "./types.js";
8
+
9
+ export type ConversionWarning = { tool: string; reason: string };
10
+
11
+ export type Conversion = {
12
+ tools: unknown[];
13
+ warnings: ConversionWarning[];
14
+ /** The schema each tool had before translation, for the return trip. */
15
+ originals: Map<string, JSONSchema>;
16
+ };
17
+
18
+ export type ToolResult = { call: ToolCall; result: Record<string, unknown> };
19
+
20
+ /**
21
+ * Each provider's function-calling dialect, in one place.
22
+ *
23
+ * A format knows three things: how to describe a tool, how to recognise the
24
+ * model asking for one, and how to hand the answer back. They are grouped per
25
+ * provider rather than per agent framework on purpose — a framework brings its
26
+ * own MCP client and never sees any of this.
27
+ */
28
+ export type FormatAdapter = {
29
+ convert(tools: GatewayTool[], options: { strict: boolean }): Conversion;
30
+ extractCalls(output: unknown): ToolCall[];
31
+ toOutputs(results: ToolResult[]): unknown[];
32
+ };
33
+
34
+ export function adapterFor(format: ToolFormat): FormatAdapter {
35
+ const adapter = ADAPTERS[format];
36
+ if (!adapter) throw new Error(`unknown tool format "${format}"`);
37
+ return adapter;
38
+ }
39
+
40
+ /** Undoes whatever the outbound conversion added to the arguments. */
41
+ export function restoreArguments(
42
+ args: Record<string, unknown>,
43
+ original: JSONSchema | undefined,
44
+ ): Record<string, unknown> {
45
+ return stripInjectedNulls(args, original) as Record<string, unknown>;
46
+ }
47
+
48
+ function convertWith(
49
+ tools: GatewayTool[],
50
+ options: { strict: boolean },
51
+ shape: (tool: GatewayTool, schema: JSONSchema, strict: boolean) => unknown,
52
+ ): Conversion {
53
+ const warnings: ConversionWarning[] = [];
54
+ const originals = new Map<string, JSONSchema>();
55
+ const converted = tools.map((tool) => {
56
+ originals.set(tool.name, tool.inputSchema);
57
+ if (!options.strict)
58
+ return shape(tool, inlineRefs(tool.inputSchema), false);
59
+ const result = toStrict(tool.inputSchema);
60
+ if (!result.strict)
61
+ warnings.push({ tool: tool.name, reason: result.reason ?? "unknown" });
62
+ return shape(tool, result.schema, result.strict);
63
+ });
64
+ return { tools: converted, warnings, originals };
65
+ }
66
+
67
+ const openAIResponses: FormatAdapter = {
68
+ convert: (tools, options) =>
69
+ convertWith(tools, options, (tool, schema, strict) => ({
70
+ type: "function",
71
+ name: tool.name,
72
+ description: tool.description ?? "",
73
+ parameters: schema,
74
+ strict,
75
+ })),
76
+ extractCalls(output) {
77
+ // Accepts the whole response or just its output array, because both are
78
+ // what people have in hand at the call site.
79
+ const items = Array.isArray(output)
80
+ ? output
81
+ : ((output as { output?: unknown[] } | undefined)?.output ?? []);
82
+ return items.flatMap((item) => {
83
+ const call = item as Record<string, unknown>;
84
+ if (call.type !== "function_call") return [];
85
+ return [
86
+ {
87
+ id: String(call.call_id ?? call.id ?? ""),
88
+ name: String(call.name ?? ""),
89
+ arguments: parseArguments(call.arguments),
90
+ },
91
+ ];
92
+ });
93
+ },
94
+ toOutputs: (results) =>
95
+ results.map(({ call, result }) => ({
96
+ type: "function_call_output",
97
+ call_id: call.id,
98
+ output: resultToText(result),
99
+ })),
100
+ };
101
+
102
+ const openAIChat: FormatAdapter = {
103
+ convert: (tools, options) =>
104
+ convertWith(tools, options, (tool, schema, strict) => ({
105
+ type: "function",
106
+ function: {
107
+ name: tool.name,
108
+ description: tool.description ?? "",
109
+ parameters: schema,
110
+ strict,
111
+ },
112
+ })),
113
+ extractCalls(output) {
114
+ const calls = Array.isArray(output)
115
+ ? output
116
+ : (((output as { choices?: { message?: { tool_calls?: unknown[] } }[] })
117
+ ?.choices?.[0]?.message?.tool_calls ?? []) as unknown[]);
118
+ return calls.flatMap((item) => {
119
+ const call = item as {
120
+ id?: unknown;
121
+ function?: { name?: unknown; arguments?: unknown };
122
+ };
123
+ if (!call.function) return [];
124
+ return [
125
+ {
126
+ id: String(call.id ?? ""),
127
+ name: String(call.function.name ?? ""),
128
+ arguments: parseArguments(call.function.arguments),
129
+ },
130
+ ];
131
+ });
132
+ },
133
+ toOutputs: (results) =>
134
+ results.map(({ call, result }) => ({
135
+ role: "tool",
136
+ tool_call_id: call.id,
137
+ content: resultToText(result),
138
+ })),
139
+ };
140
+
141
+ const anthropicMessages: FormatAdapter = {
142
+ convert: (tools, options) =>
143
+ // Anthropic takes plain JSON Schema, so strict has nothing to add here:
144
+ // asking for it would close objects for no gain.
145
+ convertWith(tools, { strict: false }, (tool, schema) => ({
146
+ name: tool.name,
147
+ description: tool.description ?? "",
148
+ input_schema: schema,
149
+ })),
150
+ extractCalls(output) {
151
+ const content = Array.isArray(output)
152
+ ? output
153
+ : ((output as { content?: unknown[] } | undefined)?.content ?? []);
154
+ return content.flatMap((item) => {
155
+ const block = item as Record<string, unknown>;
156
+ if (block.type !== "tool_use") return [];
157
+ return [
158
+ {
159
+ id: String(block.id ?? ""),
160
+ name: String(block.name ?? ""),
161
+ arguments: (block.input as Record<string, unknown>) ?? {},
162
+ },
163
+ ];
164
+ });
165
+ },
166
+ // No results means the model called nothing, and that is what an empty array
167
+ // says. A message wrapped around no blocks says the opposite — and Anthropic
168
+ // refuses a user message with no content, so the turn a caller reads as "keep
169
+ // going" is the one that cannot be sent.
170
+ toOutputs: (results) =>
171
+ results.length === 0
172
+ ? []
173
+ : [
174
+ {
175
+ role: "user",
176
+ content: results.map(({ call, result }) => ({
177
+ type: "tool_result",
178
+ tool_use_id: call.id,
179
+ content: resultToText(result),
180
+ ...(result.isError === true ? { is_error: true } : {}),
181
+ })),
182
+ },
183
+ ],
184
+ };
185
+
186
+ const gemini: FormatAdapter = {
187
+ convert(tools, _options) {
188
+ const warnings: ConversionWarning[] = [];
189
+ const originals = new Map<string, JSONSchema>();
190
+ const declarations = tools.map((tool) => {
191
+ originals.set(tool.name, tool.inputSchema);
192
+ const { schema, dropped } = geminiSchema(inlineRefs(tool.inputSchema));
193
+ if (dropped.length > 0) {
194
+ warnings.push({
195
+ tool: tool.name,
196
+ reason: `dropped keywords Gemini does not accept: ${[...new Set(dropped)].join(", ")}`,
197
+ });
198
+ }
199
+ return {
200
+ name: tool.name,
201
+ description: tool.description ?? "",
202
+ parameters: schema,
203
+ };
204
+ });
205
+ return {
206
+ tools: [{ functionDeclarations: declarations }],
207
+ warnings,
208
+ originals,
209
+ };
210
+ },
211
+ extractCalls(output) {
212
+ const parts = Array.isArray(output)
213
+ ? output
214
+ : (((output as { candidates?: { content?: { parts?: unknown[] } }[] })
215
+ ?.candidates?.[0]?.content?.parts ?? []) as unknown[]);
216
+ return parts.flatMap((item, index) => {
217
+ const part = item as {
218
+ functionCall?: { name?: unknown; args?: unknown };
219
+ };
220
+ if (!part.functionCall) return [];
221
+ const name = String(part.functionCall.name ?? "");
222
+ return [
223
+ {
224
+ // Gemini does not give a call an id, so one is made from its
225
+ // position — enough to pair a result with its call.
226
+ id: `${name}:${index}`,
227
+ name,
228
+ arguments: (part.functionCall.args as Record<string, unknown>) ?? {},
229
+ },
230
+ ];
231
+ });
232
+ },
233
+ // Empty means the model called nothing; see anthropicMessages.
234
+ toOutputs: (results) =>
235
+ results.length === 0
236
+ ? []
237
+ : [
238
+ {
239
+ role: "user",
240
+ parts: results.map(({ call, result }) => ({
241
+ functionResponse: {
242
+ name: call.name,
243
+ response: resultToResponse(result),
244
+ },
245
+ })),
246
+ },
247
+ ],
248
+ };
249
+
250
+ const ADAPTERS: Record<string, FormatAdapter> = {
251
+ [ToolFormat.OpenAIResponses]: openAIResponses,
252
+ [ToolFormat.OpenAIChat]: openAIChat,
253
+ [ToolFormat.AnthropicMessages]: anthropicMessages,
254
+ [ToolFormat.Gemini]: gemini,
255
+ };
256
+
257
+ /** Keywords Gemini's function declarations accept; everything else is dropped. */
258
+ const GEMINI_KEYWORDS = new Set([
259
+ "type",
260
+ "format",
261
+ "description",
262
+ "nullable",
263
+ "enum",
264
+ "properties",
265
+ "required",
266
+ "items",
267
+ "anyOf",
268
+ "minimum",
269
+ "maximum",
270
+ ]);
271
+
272
+ function geminiSchema(schema: JSONSchema): {
273
+ schema: JSONSchema;
274
+ dropped: string[];
275
+ } {
276
+ const dropped: string[] = [];
277
+
278
+ // Inside `properties` the keys are the caller's own property names, not
279
+ // schema keywords, so they are carried across untouched — filtering them
280
+ // would delete the arguments rather than the syntax.
281
+ const walkProperties = (node: unknown): unknown => {
282
+ if (typeof node !== "object" || node === null) return node;
283
+ const out: Record<string, unknown> = {};
284
+ for (const [name, value] of Object.entries(
285
+ node as Record<string, unknown>,
286
+ )) {
287
+ out[name] = walk(value);
288
+ }
289
+ return out;
290
+ };
291
+
292
+ const walk = (node: unknown): unknown => {
293
+ if (Array.isArray(node)) return node.map(walk);
294
+ if (typeof node !== "object" || node === null) return node;
295
+ const out: Record<string, unknown> = {};
296
+ for (const [key, value] of Object.entries(
297
+ node as Record<string, unknown>,
298
+ )) {
299
+ if (!GEMINI_KEYWORDS.has(key)) {
300
+ dropped.push(key);
301
+ continue;
302
+ }
303
+ if (key === "properties") {
304
+ out[key] = walkProperties(value);
305
+ continue;
306
+ }
307
+ out[key] = key === "required" || key === "enum" ? value : walk(value);
308
+ }
309
+ return out;
310
+ };
311
+
312
+ return { schema: walk(schema) as JSONSchema, dropped };
313
+ }
314
+
315
+ function parseArguments(raw: unknown): Record<string, unknown> {
316
+ if (typeof raw === "object" && raw !== null)
317
+ return raw as Record<string, unknown>;
318
+ if (typeof raw !== "string" || raw.trim() === "") return {};
319
+ try {
320
+ const parsed = JSON.parse(raw);
321
+ return typeof parsed === "object" && parsed !== null
322
+ ? (parsed as Record<string, unknown>)
323
+ : {};
324
+ } catch {
325
+ return {};
326
+ }
327
+ }
328
+
329
+ /**
330
+ * What the model gets back from a tool.
331
+ *
332
+ * A structured result is the one worth giving it, since that is what the tool
333
+ * promised in its output schema; the text blocks are the fallback, and the
334
+ * whole result is the last resort. A tool that failed still answers here
335
+ * rather than throwing: MCP puts tool errors in the result precisely so the
336
+ * model can read them and correct itself.
337
+ */
338
+ export function resultToText(result: Record<string, unknown>): string {
339
+ if (result.structuredContent !== undefined)
340
+ return JSON.stringify(result.structuredContent);
341
+ const content = result.content;
342
+ if (Array.isArray(content)) {
343
+ const text = content
344
+ .filter((block) => (block as { type?: string }).type === "text")
345
+ .map((block) => String((block as { text?: unknown }).text ?? ""))
346
+ .join("\n");
347
+ if (text) return text;
348
+ }
349
+ return JSON.stringify(result);
350
+ }
351
+
352
+ function resultToResponse(
353
+ result: Record<string, unknown>,
354
+ ): Record<string, unknown> {
355
+ if (
356
+ result.structuredContent !== undefined &&
357
+ typeof result.structuredContent === "object"
358
+ ) {
359
+ return result.structuredContent as Record<string, unknown>;
360
+ }
361
+ return { result: resultToText(result) };
362
+ }
package/src/http.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { API_KEY_HEADER, type ResolvedConfig } from './config.js'
2
+ import {
3
+ AuthenticationError,
4
+ InvalidRequestError,
5
+ RateLimitedError,
6
+ ServiceUnavailableError,
7
+ TrustGateError,
8
+ TrustGateServerError,
9
+ } from './errors.js'
10
+
11
+ export type ErrorBody = { error?: string; message?: string }
12
+
13
+ /**
14
+ * A JSON request against the gateway's REST surface (the connections API).
15
+ *
16
+ * The gateway answers `{error, message}` on failure; this turns each `error`
17
+ * code into the type that says whose problem it is. `expect` names statuses
18
+ * the caller handles itself — the actor probe uses it to read a 409 as an
19
+ * answer rather than a failure.
20
+ */
21
+ export async function requestJSON<T>(
22
+ config: ResolvedConfig,
23
+ method: string,
24
+ path: string,
25
+ options: { body?: unknown; headers?: Record<string, string>; expect?: number[]; signal?: AbortSignal } = {}
26
+ ): Promise<{ status: number; body: T }> {
27
+ const url = `${config.baseUrl}${path}`
28
+ const controller = new AbortController()
29
+ const timeout = setTimeout(() => controller.abort(), config.timeoutMs)
30
+ const signal = options.signal ? anySignal([options.signal, controller.signal]) : controller.signal
31
+ let response: Response
32
+ try {
33
+ response = await config.fetch(url, {
34
+ method,
35
+ headers: {
36
+ [API_KEY_HEADER]: config.apiKey,
37
+ Accept: 'application/json',
38
+ ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
39
+ ...options.headers,
40
+ },
41
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
42
+ signal,
43
+ })
44
+ } catch (cause) {
45
+ throw new TrustGateError(`${method} ${path} failed to reach the gateway`, { cause })
46
+ } finally {
47
+ clearTimeout(timeout)
48
+ }
49
+
50
+ const text = await response.text()
51
+ const body = text ? safeParse(text) : undefined
52
+ if (response.ok || options.expect?.includes(response.status)) {
53
+ return { status: response.status, body: body as T }
54
+ }
55
+ throw errorForResponse(response, body as ErrorBody | undefined, text)
56
+ }
57
+
58
+ function errorForResponse(response: Response, body: ErrorBody | undefined, raw: string): TrustGateError {
59
+ const code = body?.error
60
+ const message = body?.message ?? raw ?? response.statusText
61
+ const status = response.status
62
+ switch (code) {
63
+ case 'unauthenticated':
64
+ return new AuthenticationError(message, { status, code })
65
+ case 'invalid_request':
66
+ return new InvalidRequestError(message, { status, code })
67
+ case 'unavailable':
68
+ return new ServiceUnavailableError(message, { status, code })
69
+ }
70
+ if (status === 401 || status === 403) return new AuthenticationError(message, { status, code })
71
+ if (status === 429) {
72
+ return new RateLimitedError(message, retryAfterMs(response))
73
+ }
74
+ if (status >= 500) return new TrustGateServerError(message, { status, code })
75
+ return new TrustGateError(message, { status, code })
76
+ }
77
+
78
+ function retryAfterMs(response: Response): number | undefined {
79
+ const header = response.headers.get('Retry-After')
80
+ if (!header) return undefined
81
+ const seconds = Number(header)
82
+ return Number.isFinite(seconds) ? seconds * 1000 : undefined
83
+ }
84
+
85
+ function safeParse(text: string): unknown {
86
+ try {
87
+ return JSON.parse(text)
88
+ } catch {
89
+ return undefined
90
+ }
91
+ }
92
+
93
+ /** AbortSignal.any is not on every runtime the SDK supports yet. */
94
+ function anySignal(signals: AbortSignal[]): AbortSignal {
95
+ const anyOf = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any
96
+ if (typeof anyOf === 'function') return anyOf(signals)
97
+ const controller = new AbortController()
98
+ for (const signal of signals) {
99
+ if (signal.aborted) {
100
+ controller.abort(signal.reason)
101
+ break
102
+ }
103
+ signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true })
104
+ }
105
+ return controller.signal
106
+ }
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ export { TrustGate, type ConnectOptions, type LLMEndpoint } from './client.js'
2
+ export {
3
+ whoAmI,
4
+ selectConsumer,
5
+ type KeyConsumer,
6
+ type KeyIdentity,
7
+ type KeyInfo,
8
+ type KeyUpstream,
9
+ type UpstreamAccount,
10
+ type UpstreamBlockedBy,
11
+ } from './whoami.js'
12
+ export { Agent, EndUserAgent, Toolkit, type ToolkitOptions } from './agent.js'
13
+ export { type TrustGateConfig, API_KEY_HEADER, END_USER_HEADER } from './config.js'
14
+ export { MCPTransport } from './mcp.js'
15
+ export { inlineRefs, stripInjectedNulls, toStrict, type StrictResult } from './schema.js'
16
+ export { adapterFor, resultToText, type ConversionWarning } from './formats.js'
17
+ export {
18
+ Actor,
19
+ ToolFormat,
20
+ type ConnectLink,
21
+ type Connection,
22
+ type Endpoint,
23
+ type GatewayTool,
24
+ type JSONSchema,
25
+ type ToolCall,
26
+ } from './types.js'
27
+ export {
28
+ AuthenticationError,
29
+ ConsentRequiredError,
30
+ InvalidRequestError,
31
+ MissingToolsError,
32
+ PlaneUnavailableError,
33
+ PolicyBlockedError,
34
+ RateLimitedError,
35
+ ServiceUnavailableError,
36
+ ToolNotFoundError,
37
+ TrustGateError,
38
+ TrustGateServerError,
39
+ UpstreamNotConnectedError,
40
+ type BlockedUpstream,
41
+ } from './errors.js'
package/src/mcp.ts ADDED
@@ -0,0 +1,203 @@
1
+ import { API_KEY_HEADER, type ResolvedConfig } from './config.js'
2
+ import {
3
+ AuthenticationError,
4
+ ConsentRequiredError,
5
+ InvalidRequestError,
6
+ PolicyBlockedError,
7
+ ToolNotFoundError,
8
+ TrustGateError,
9
+ TrustGateServerError,
10
+ } from './errors.js'
11
+ import type { GatewayTool, JSONSchema } from './types.js'
12
+
13
+ /** JSON-RPC codes the gateway answers with, beyond the standard four. */
14
+ const CODE_CONSENT_REQUIRED = -32003
15
+ const CODE_RESOURCE_NOT_FOUND = -32002
16
+ const CODE_POLICY_BLOCKED = -32001
17
+ const CODE_INVALID_REQUEST = -32600
18
+ const CODE_INVALID_PARAMS = -32602
19
+ const CODE_INTERNAL = -32603
20
+
21
+ type RPCError = { code: number; message: string; data?: unknown }
22
+ type RPCResponse = { id?: unknown; result?: Record<string, unknown>; error?: RPCError }
23
+
24
+ /**
25
+ * The gateway's MCP endpoint, spoken directly.
26
+ *
27
+ * Only two methods are needed to put a consumer's tools in front of a model —
28
+ * list them and call them — so this is a JSON-RPC client rather than a whole
29
+ * MCP implementation. The gateway is stateless: there is no handshake to do
30
+ * and no session to carry, so every call stands on its own.
31
+ */
32
+ export class MCPTransport {
33
+ private nextId = 1
34
+
35
+ constructor(
36
+ private readonly config: ResolvedConfig,
37
+ readonly url: string,
38
+ private readonly extraHeaders: Record<string, string> = {}
39
+ ) {}
40
+
41
+ get headers(): Record<string, string> {
42
+ return { [API_KEY_HEADER]: this.config.apiKey, ...this.extraHeaders }
43
+ }
44
+
45
+ async listTools(signal?: AbortSignal): Promise<GatewayTool[]> {
46
+ const result = await this.call('tools/list', {}, signal)
47
+ const tools = Array.isArray(result.tools) ? result.tools : []
48
+ return tools.map((tool) => {
49
+ const raw = tool as Record<string, unknown>
50
+ return {
51
+ name: String(raw.name ?? ''),
52
+ title: typeof raw.title === 'string' ? raw.title : undefined,
53
+ description: typeof raw.description === 'string' ? raw.description : undefined,
54
+ inputSchema: (raw.inputSchema as JSONSchema) ?? { type: 'object', properties: {} },
55
+ outputSchema: raw.outputSchema as JSONSchema | undefined,
56
+ }
57
+ })
58
+ }
59
+
60
+ async callTool(
61
+ name: string,
62
+ args: Record<string, unknown>,
63
+ signal?: AbortSignal
64
+ ): Promise<Record<string, unknown>> {
65
+ try {
66
+ return await this.call('tools/call', { name, arguments: args }, signal)
67
+ } catch (error) {
68
+ // The gateway reports an unknown tool as invalid params, which is
69
+ // true of the request and useless to the caller: what they need to
70
+ // know is which tool, because a toolkit can lose one under them.
71
+ if (error instanceof InvalidRequestError && error.code === String(CODE_INVALID_PARAMS)) {
72
+ throw new ToolNotFoundError(name, error.message)
73
+ }
74
+ throw error
75
+ }
76
+ }
77
+
78
+ async call(
79
+ method: string,
80
+ params: Record<string, unknown>,
81
+ signal?: AbortSignal
82
+ ): Promise<Record<string, unknown>> {
83
+ const id = this.nextId++
84
+ const controller = new AbortController()
85
+ const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs)
86
+ let response: Response
87
+ try {
88
+ response = await this.config.fetch(this.url, {
89
+ method: 'POST',
90
+ headers: {
91
+ ...this.headers,
92
+ 'Content-Type': 'application/json',
93
+ // A plain JSON answer is enough: the SDK re-lists on demand
94
+ // rather than listening for a change on the response.
95
+ Accept: 'application/json',
96
+ },
97
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
98
+ signal: signal ?? controller.signal,
99
+ })
100
+ } catch (cause) {
101
+ throw new TrustGateError(`MCP ${method} failed to reach ${this.url}`, { cause })
102
+ } finally {
103
+ clearTimeout(timeout)
104
+ }
105
+
106
+ if (response.status === 401 || response.status === 403) {
107
+ throw new AuthenticationError(
108
+ `the gateway refused this API key for ${this.url}`,
109
+ { status: response.status }
110
+ )
111
+ }
112
+ const text = await response.text()
113
+ const rpc = parseRPCResponse(text, id)
114
+ if (!rpc) {
115
+ throw new TrustGateError(
116
+ `MCP ${method} returned no JSON-RPC response (HTTP ${response.status})` +
117
+ whatItSaid(text),
118
+ { status: response.status }
119
+ )
120
+ }
121
+ if (rpc.error) throw errorForRPC(rpc.error)
122
+ return rpc.result ?? {}
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Reads the response body, which is not always JSON.
128
+ *
129
+ * When a change to the surface has to be announced, the gateway answers the
130
+ * same request as an event stream and puts the response in a frame after the
131
+ * notification. Both shapes carry the same JSON-RPC object, so both are read
132
+ * here; a frame that is not this request's answer is skipped rather than
133
+ * mistaken for it.
134
+ */
135
+ export function parseRPCResponse(text: string, id: number): RPCResponse | undefined {
136
+ const trimmed = text.trim()
137
+ if (!trimmed) return undefined
138
+ if (trimmed.startsWith('{')) {
139
+ try {
140
+ return JSON.parse(trimmed) as RPCResponse
141
+ } catch {
142
+ return undefined
143
+ }
144
+ }
145
+ for (const line of trimmed.split('\n')) {
146
+ if (!line.startsWith('data:')) continue
147
+ try {
148
+ const frame = JSON.parse(line.slice('data:'.length).trim()) as RPCResponse
149
+ if (frame.id === id) return frame
150
+ } catch {
151
+ continue
152
+ }
153
+ }
154
+ return undefined
155
+ }
156
+
157
+ function errorForRPC(error: RPCError): TrustGateError {
158
+ const data = (error.data ?? {}) as Record<string, unknown>
159
+ switch (error.code) {
160
+ case CODE_CONSENT_REQUIRED:
161
+ return new ConsentRequiredError(
162
+ String(data.provider ?? 'this provider'),
163
+ String(data.connect_url ?? ''),
164
+ String(data.cause ?? ''),
165
+ error.message
166
+ )
167
+ case CODE_POLICY_BLOCKED:
168
+ return new PolicyBlockedError(error.message, { code: String(error.code) })
169
+ case CODE_INTERNAL:
170
+ return new TrustGateServerError(error.message, { code: String(error.code) })
171
+ case CODE_INVALID_PARAMS:
172
+ case CODE_INVALID_REQUEST:
173
+ case CODE_RESOURCE_NOT_FOUND:
174
+ return new InvalidRequestError(error.message, { code: String(error.code) })
175
+ default:
176
+ return new TrustGateError(error.message, { code: String(error.code) })
177
+ }
178
+ }
179
+
180
+ /**
181
+ * The reason the endpoint gave, when it did not give it in JSON-RPC.
182
+ *
183
+ * A plain HTTP error — a missing header, a path that is no virtual MCP — says
184
+ * why in its body, and dropping that leaves a status code to guess from.
185
+ */
186
+ function whatItSaid(text: string): string {
187
+ let said = (text ?? '').split(/\s+/).filter(Boolean).join(' ')
188
+ try {
189
+ const body: unknown = JSON.parse(text)
190
+ if (body && typeof body === 'object') {
191
+ for (const key of ['error', 'message', 'detail']) {
192
+ const value = (body as Record<string, unknown>)[key]
193
+ if (typeof value === 'string' && value.trim()) {
194
+ said = value.trim()
195
+ break
196
+ }
197
+ }
198
+ }
199
+ } catch {
200
+ // Not JSON. The whitespace-collapsed body is the best there is.
201
+ }
202
+ return said ? `: ${said.slice(0, 200)}` : ''
203
+ }