@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.
- package/LICENSE +201 -0
- package/README.md +132 -0
- package/dist/agent.d.ts +156 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +216 -0
- package/dist/agent.js.map +1 -0
- package/dist/client.d.ts +88 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +152 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +44 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +40 -0
- package/dist/config.js.map +1 -0
- package/dist/connections.d.ts +13 -0
- package/dist/connections.d.ts.map +1 -0
- package/dist/connections.js +46 -0
- package/dist/connections.js.map +1 -0
- package/dist/errors.d.ts +91 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +144 -0
- package/dist/errors.js.map +1 -0
- package/dist/formats.d.ts +44 -0
- package/dist/formats.d.ts.map +1 -0
- package/dist/formats.js +299 -0
- package/dist/formats.js.map +1 -0
- package/dist/http.d.ts +23 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +94 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp.d.ts +43 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +175 -0
- package/dist/mcp.js.map +1 -0
- package/dist/schema.d.ts +52 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +168 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +52 -0
- package/dist/types.js.map +1 -0
- package/dist/whoami.d.ts +73 -0
- package/dist/whoami.d.ts.map +1 -0
- package/dist/whoami.js +79 -0
- package/dist/whoami.js.map +1 -0
- package/package.json +29 -0
- package/src/agent.ts +267 -0
- package/src/client.ts +203 -0
- package/src/config.ts +78 -0
- package/src/connections.ts +90 -0
- package/src/errors.ts +154 -0
- package/src/formats.ts +362 -0
- package/src/http.ts +106 -0
- package/src/index.ts +41 -0
- package/src/mcp.ts +203 -0
- package/src/schema.ts +193 -0
- package/src/types.ts +98 -0
- 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
|
+
}
|