typed-bridge 3.0.0 → 4.0.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/dist/bridge/index.d.ts +2 -2
- package/dist/bridge/index.js +1 -23
- package/dist/config/index.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +6 -7
- package/dist/mcp/index.d.ts +2 -2
- package/dist/mcp/index.js +14 -16
- package/dist/scripts/typedBridgeCleaner.js +28 -2
- package/dist/tools/index.d.ts +69 -4
- package/dist/tools/index.js +120 -21
- package/package.json +2 -1
- package/readme.md +249 -74
package/dist/bridge/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Application, Request, Response } from 'express';
|
|
2
2
|
import { Server } from 'http';
|
|
3
3
|
import { MCPGetContext } from '../mcp';
|
|
4
|
-
import { Bridge, BridgeEntries,
|
|
4
|
+
import { Bridge, BridgeEntries, ToolMode } from '../tools';
|
|
5
5
|
interface CreateBridgeOptions {
|
|
6
6
|
entries?: BridgeEntries;
|
|
7
|
-
|
|
7
|
+
toolMode?: ToolMode;
|
|
8
8
|
mcp?: boolean | string;
|
|
9
9
|
mcpGetContext?: MCPGetContext;
|
|
10
10
|
}
|
package/dist/bridge/index.js
CHANGED
|
@@ -12,7 +12,6 @@ const path_1 = __importDefault(require("path"));
|
|
|
12
12
|
const __1 = require("..");
|
|
13
13
|
const helpers_1 = require("../helpers");
|
|
14
14
|
const mcp_1 = require("../mcp");
|
|
15
|
-
const tools_1 = require("../tools");
|
|
16
15
|
const middlewares = [];
|
|
17
16
|
const createMiddleware = (pattern, handler) => middlewares.push({ pattern, handler });
|
|
18
17
|
exports.createMiddleware = createMiddleware;
|
|
@@ -86,31 +85,10 @@ const createBridge = (bridge, port, path = '/bridge', options) => {
|
|
|
86
85
|
timestamp: new Date().toISOString()
|
|
87
86
|
});
|
|
88
87
|
});
|
|
89
|
-
// LLM tools endpoint
|
|
90
|
-
if (options?.entries) {
|
|
91
|
-
const entries = options.entries;
|
|
92
|
-
const defaultFormat = options.toolsFormat || 'openai';
|
|
93
|
-
app.get(path_1.default.join(path, 'tools'), (req, res) => {
|
|
94
|
-
const requestedFormat = req.query.format;
|
|
95
|
-
// No format provided → use the configured default
|
|
96
|
-
if (requestedFormat === undefined) {
|
|
97
|
-
res.json((0, tools_1.toLLMTools)(entries, { format: defaultFormat }));
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
// Reject unknown formats instead of silently returning an empty tool list
|
|
101
|
-
if (typeof requestedFormat !== 'string' || !(0, tools_1.isLLMToolFormat)(requestedFormat)) {
|
|
102
|
-
res.status(400).json({
|
|
103
|
-
error: `Invalid format: ${String(requestedFormat)}. Expected one of ${tools_1.LLM_TOOL_FORMATS.join(', ')}`
|
|
104
|
-
});
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
res.json((0, tools_1.toLLMTools)(entries, { format: requestedFormat }));
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
88
|
// MCP endpoint
|
|
111
89
|
if (options?.mcp && options?.entries) {
|
|
112
90
|
const mcpPath = typeof options.mcp === 'string' ? options.mcp : path_1.default.join(path, 'mcp');
|
|
113
|
-
(0, mcp_1.mountMCP)(app, bridge, options.entries, mcpPath, options.mcpGetContext);
|
|
91
|
+
(0, mcp_1.mountMCP)(app, bridge, options.entries, mcpPath, options.mcpGetContext, options.toolMode || 'on_demand');
|
|
114
92
|
}
|
|
115
93
|
app.use(path, bridgeHandler(bridge));
|
|
116
94
|
const server = app.listen(port, () => (0, helpers_1.printStartLogs)(port));
|
package/dist/config/index.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,6 @@ export { createBridge, createMiddleware, onShutdown } from './bridge';
|
|
|
3
3
|
export { config as tbConfig } from './config';
|
|
4
4
|
export { mountMCP } from './mcp';
|
|
5
5
|
export type { MCPGetContext } from './mcp';
|
|
6
|
-
export { defineBridge,
|
|
7
|
-
export type { Bridge, BridgeEntries, BridgeEntry,
|
|
6
|
+
export { defineBridge, defineEntry, getTools, handleToolCall, isToolMode, TOOL_MODES } from './tools';
|
|
7
|
+
export type { Bridge, BridgeEntries, BridgeEntry, GetToolsOptions, HandleToolCallOptions, LLMToolFormat, ToolCall, ToolMode, ToolSurface } from './tools';
|
|
8
8
|
export { z } from 'zod';
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.z = exports.
|
|
6
|
+
exports.z = exports.TOOL_MODES = exports.isToolMode = exports.handleToolCall = exports.getTools = exports.defineEntry = exports.defineBridge = exports.mountMCP = exports.tbConfig = exports.onShutdown = exports.createMiddleware = exports.createBridge = exports.Router = exports.express = void 0;
|
|
7
7
|
var express_1 = require("express");
|
|
8
8
|
Object.defineProperty(exports, "express", { enumerable: true, get: function () { return __importDefault(express_1).default; } });
|
|
9
9
|
Object.defineProperty(exports, "Router", { enumerable: true, get: function () { return express_1.Router; } });
|
|
@@ -17,11 +17,10 @@ var mcp_1 = require("./mcp");
|
|
|
17
17
|
Object.defineProperty(exports, "mountMCP", { enumerable: true, get: function () { return mcp_1.mountMCP; } });
|
|
18
18
|
var tools_1 = require("./tools");
|
|
19
19
|
Object.defineProperty(exports, "defineBridge", { enumerable: true, get: function () { return tools_1.defineBridge; } });
|
|
20
|
-
Object.defineProperty(exports, "
|
|
21
|
-
Object.defineProperty(exports, "
|
|
22
|
-
Object.defineProperty(exports, "
|
|
23
|
-
Object.defineProperty(exports, "
|
|
24
|
-
Object.defineProperty(exports, "
|
|
25
|
-
Object.defineProperty(exports, "toolUse", { enumerable: true, get: function () { return tools_1.toolUse; } });
|
|
20
|
+
Object.defineProperty(exports, "defineEntry", { enumerable: true, get: function () { return tools_1.defineEntry; } });
|
|
21
|
+
Object.defineProperty(exports, "getTools", { enumerable: true, get: function () { return tools_1.getTools; } });
|
|
22
|
+
Object.defineProperty(exports, "handleToolCall", { enumerable: true, get: function () { return tools_1.handleToolCall; } });
|
|
23
|
+
Object.defineProperty(exports, "isToolMode", { enumerable: true, get: function () { return tools_1.isToolMode; } });
|
|
24
|
+
Object.defineProperty(exports, "TOOL_MODES", { enumerable: true, get: function () { return tools_1.TOOL_MODES; } });
|
|
26
25
|
var zod_1 = require("zod");
|
|
27
26
|
Object.defineProperty(exports, "z", { enumerable: true, get: function () { return zod_1.z; } });
|
package/dist/mcp/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { IncomingHttpHeaders } from 'node:http';
|
|
2
2
|
import { Application } from 'express';
|
|
3
|
-
import { Bridge, BridgeEntries } from '../tools';
|
|
3
|
+
import { Bridge, BridgeEntries, ToolMode } from '../tools';
|
|
4
4
|
export type MCPGetContext = (headers: IncomingHttpHeaders) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
5
|
-
export declare function mountMCP(app: Application, bridge: Bridge, entries: BridgeEntries, path?: string, getContext?: MCPGetContext): void;
|
|
5
|
+
export declare function mountMCP(app: Application, bridge: Bridge, entries: BridgeEntries, path?: string, getContext?: MCPGetContext, toolMode?: ToolMode): void;
|
package/dist/mcp/index.js
CHANGED
|
@@ -9,13 +9,17 @@ const tools_1 = require("../tools");
|
|
|
9
9
|
const SESSION_TTL_MS = 30 * 60 * 1000;
|
|
10
10
|
const SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
11
11
|
const MAX_SESSIONS = 1000;
|
|
12
|
-
|
|
12
|
+
// The 3 meta-tools, shaped for MCP's { name, description, inputSchema } listing.
|
|
13
|
+
const metaToolsForMCP = () => (0, tools_1.getMetaTools)({ format: 'json-schema' }).map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.parameters }));
|
|
14
|
+
function createMCPServer(bridge, entries, headersRef, getContext, toolMode = 'on_demand') {
|
|
13
15
|
const server = new index_js_1.Server({ name: 'typed-bridge', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
14
|
-
// An entry is exposed to MCP unless it explicitly opts out with `mcp: false`
|
|
15
|
-
const isExposed = (entry) => !!entry && entry.mcp !== false;
|
|
16
16
|
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
|
|
17
|
+
// on_demand: hand the model the 3 meta-tools and let it discover the rest
|
|
18
|
+
if (toolMode === 'on_demand')
|
|
19
|
+
return { tools: metaToolsForMCP() };
|
|
20
|
+
// attach_all: one tool per entry visible to MCP (respects `mcp: false`)
|
|
17
21
|
const tools = Object.entries(entries)
|
|
18
|
-
.filter(([, entry]) =>
|
|
22
|
+
.filter(([, entry]) => (0, tools_1.isEntryVisible)(entry, 'mcp'))
|
|
19
23
|
.map(([name, entry]) => ({
|
|
20
24
|
name,
|
|
21
25
|
description: entry.description,
|
|
@@ -26,18 +30,12 @@ function createMCPServer(bridge, entries, headersRef, getContext) {
|
|
|
26
30
|
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
27
31
|
const { name, arguments: args } = request.params;
|
|
28
32
|
try {
|
|
29
|
-
// Block execution of hidden tools, not just their listing
|
|
30
|
-
if (!isExposed(entries[name]))
|
|
31
|
-
throw new Error(`Tool not found: ${name}`);
|
|
32
|
-
const handler = bridge[name];
|
|
33
|
-
if (!handler)
|
|
34
|
-
throw new Error(`Tool not found: ${name}`);
|
|
35
33
|
// Derive per-request context from forwarded headers
|
|
36
34
|
const context = getContext ? await getContext(headersRef.current) : undefined;
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
const
|
|
40
|
-
return { content: [{ type: 'text', text }] };
|
|
35
|
+
// Mode-agnostic dispatch: meta-tool names run discovery, anything else runs the
|
|
36
|
+
// entry directly. Visibility (`mcp: false`) and the output limit are enforced inside.
|
|
37
|
+
const result = await (0, tools_1.handleToolCall)(bridge, entries, { name, arguments: args || {} }, { context, surface: 'mcp' });
|
|
38
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) ?? '' }] };
|
|
41
39
|
}
|
|
42
40
|
catch (error) {
|
|
43
41
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -46,7 +44,7 @@ function createMCPServer(bridge, entries, headersRef, getContext) {
|
|
|
46
44
|
});
|
|
47
45
|
return server;
|
|
48
46
|
}
|
|
49
|
-
function mountMCP(app, bridge, entries, path = '/mcp', getContext) {
|
|
47
|
+
function mountMCP(app, bridge, entries, path = '/mcp', getContext, toolMode = 'on_demand') {
|
|
50
48
|
const sessions = new Map();
|
|
51
49
|
// Evict sessions idle for longer than SESSION_TTL_MS
|
|
52
50
|
const sweepInterval = setInterval(() => {
|
|
@@ -70,7 +68,7 @@ function mountMCP(app, bridge, entries, path = '/mcp', getContext) {
|
|
|
70
68
|
}
|
|
71
69
|
else if (!sessionId && req.method === 'POST') {
|
|
72
70
|
const headersRef = { current: req.headers };
|
|
73
|
-
const server = createMCPServer(bridge, entries, headersRef, getContext);
|
|
71
|
+
const server = createMCPServer(bridge, entries, headersRef, getContext, toolMode);
|
|
74
72
|
transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
|
|
75
73
|
sessionIdGenerator: () => (0, node_crypto_1.randomUUID)(),
|
|
76
74
|
onsessioninitialized: (id) => {
|
|
@@ -223,6 +223,31 @@ const removeSecondParamTransformer = context => {
|
|
|
223
223
|
};
|
|
224
224
|
/**
|
|
225
225
|
* Transformer #3:
|
|
226
|
+
* Make any parameter typed as `undefined` optional so callers can omit it.
|
|
227
|
+
* e.g. `(_args: undefined) => Promise<...>` → `(_args?: undefined) => Promise<...>`
|
|
228
|
+
*/
|
|
229
|
+
const optionalUndefinedParamTransformer = context => {
|
|
230
|
+
return sourceFile => {
|
|
231
|
+
function visitor(node) {
|
|
232
|
+
if (typescript_1.default.isFunctionTypeNode(node)) {
|
|
233
|
+
const params = node.parameters.map(param => {
|
|
234
|
+
if (param.type &&
|
|
235
|
+
typescript_1.default.isToken(param.type) &&
|
|
236
|
+
param.type.kind === typescript_1.default.SyntaxKind.UndefinedKeyword &&
|
|
237
|
+
!param.questionToken) {
|
|
238
|
+
return typescript_1.default.factory.updateParameterDeclaration(param, param.modifiers, param.dotDotDotToken, param.name, typescript_1.default.factory.createToken(typescript_1.default.SyntaxKind.QuestionToken), param.type, param.initializer);
|
|
239
|
+
}
|
|
240
|
+
return param;
|
|
241
|
+
});
|
|
242
|
+
return typescript_1.default.factory.updateFunctionTypeNode(node, node.typeParameters, typescript_1.default.factory.createNodeArray(params), node.type);
|
|
243
|
+
}
|
|
244
|
+
return typescript_1.default.visitEachChild(node, visitor, context);
|
|
245
|
+
}
|
|
246
|
+
return typescript_1.default.visitEachChild(sourceFile, visitor, context);
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
/**
|
|
250
|
+
* Transformer #4:
|
|
226
251
|
* Resolve ExtractHandlers<T> in the _default declaration by unwrapping
|
|
227
252
|
* { handler: fn, ... } entries to just the function type.
|
|
228
253
|
* Also removes helper type definitions and the entries declaration that
|
|
@@ -316,7 +341,7 @@ const unwrapBridgeEntryTransformer = _context => {
|
|
|
316
341
|
};
|
|
317
342
|
};
|
|
318
343
|
/**
|
|
319
|
-
* Transformer #
|
|
344
|
+
* Transformer #5:
|
|
320
345
|
* Remove the rollup default export (`export { X as default }` for any X, or
|
|
321
346
|
* `export default X`). The proxy snippet provides its own default export.
|
|
322
347
|
*/
|
|
@@ -343,7 +368,7 @@ const removeDefaultExportTransformer = _context => {
|
|
|
343
368
|
};
|
|
344
369
|
};
|
|
345
370
|
/**
|
|
346
|
-
* Transformer #
|
|
371
|
+
* Transformer #6:
|
|
347
372
|
* Drop every top-level statement that is not reachable from the `TypedBridge`
|
|
348
373
|
* alias. When typed-bridge is an external dependency, rollup leaves behind stray
|
|
349
374
|
* imports (e.g. `import * as ... from 'typed-bridge/dist/tools'`) and inlined
|
|
@@ -437,6 +462,7 @@ function cleanTsFile(src) {
|
|
|
437
462
|
unwrapBridgeEntryTransformer,
|
|
438
463
|
resolveZodTypesTransformer,
|
|
439
464
|
removeSecondParamTransformer,
|
|
465
|
+
optionalUndefinedParamTransformer,
|
|
440
466
|
removeDefaultExportTransformer,
|
|
441
467
|
pruneUnreachableTransformer
|
|
442
468
|
]);
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -17,15 +17,66 @@ export type Bridge = Record<string, (...args: any[]) => Promise<any>>;
|
|
|
17
17
|
export declare const LLM_TOOL_FORMATS: readonly ["openai", "anthropic", "json-schema"];
|
|
18
18
|
export type LLMToolFormat = (typeof LLM_TOOL_FORMATS)[number];
|
|
19
19
|
export declare function isLLMToolFormat(value: string): value is LLMToolFormat;
|
|
20
|
+
export declare const TOOL_MODES: readonly ["attach_all", "on_demand"];
|
|
21
|
+
export type ToolMode = (typeof TOOL_MODES)[number];
|
|
22
|
+
export declare function isToolMode(value: string): value is ToolMode;
|
|
23
|
+
export type ToolSurface = 'llm' | 'mcp';
|
|
24
|
+
export declare function isEntryVisible(entry: BridgeEntry | undefined, surface: ToolSurface): boolean;
|
|
20
25
|
export interface ToLLMToolsOptions {
|
|
21
26
|
format?: LLMToolFormat;
|
|
22
27
|
includeResponse?: boolean;
|
|
28
|
+
surface?: ToolSurface;
|
|
29
|
+
}
|
|
30
|
+
export interface GetToolsOptions {
|
|
31
|
+
toolMode?: ToolMode;
|
|
32
|
+
format?: LLMToolFormat;
|
|
33
|
+
surface?: ToolSurface;
|
|
34
|
+
includeResponse?: boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface HandleToolCallOptions {
|
|
37
|
+
context?: unknown;
|
|
38
|
+
surface?: ToolSurface;
|
|
23
39
|
}
|
|
24
40
|
export interface ToolCall {
|
|
25
41
|
name: string;
|
|
26
42
|
arguments: Record<string, unknown>;
|
|
27
43
|
}
|
|
28
44
|
export declare function defineBridge<T extends BridgeEntries>(entries: T): ExtractHandlers<T>;
|
|
45
|
+
/**
|
|
46
|
+
* Bundle an entry's schema and handler into a single, self-contained object.
|
|
47
|
+
* Purely a typing helper — it returns its input unchanged at runtime — but it
|
|
48
|
+
* infers the handler's argument type from `args` and checks the return against
|
|
49
|
+
* `res`, so you never write `z.infer<typeof ...>`. `context` stays `any` so you
|
|
50
|
+
* can annotate it with your own named context type inline.
|
|
51
|
+
*/
|
|
52
|
+
export declare function defineEntry<A extends z.ZodType, R extends z.ZodType>(entry: {
|
|
53
|
+
description?: string;
|
|
54
|
+
args: A;
|
|
55
|
+
res: R;
|
|
56
|
+
mcp?: boolean;
|
|
57
|
+
llm?: boolean;
|
|
58
|
+
handler: (args: z.infer<A>, context: any) => Promise<z.infer<R>>;
|
|
59
|
+
}): {
|
|
60
|
+
description?: string;
|
|
61
|
+
args: A;
|
|
62
|
+
res: R;
|
|
63
|
+
mcp?: boolean;
|
|
64
|
+
llm?: boolean;
|
|
65
|
+
handler: (args: z.infer<A>, context: any) => Promise<z.infer<R>>;
|
|
66
|
+
};
|
|
67
|
+
export declare function defineEntry<R extends z.ZodType>(entry: {
|
|
68
|
+
description?: string;
|
|
69
|
+
res: R;
|
|
70
|
+
mcp?: boolean;
|
|
71
|
+
llm?: boolean;
|
|
72
|
+
handler: (args: undefined, context: any) => Promise<z.infer<R>>;
|
|
73
|
+
}): {
|
|
74
|
+
description?: string;
|
|
75
|
+
res: R;
|
|
76
|
+
mcp?: boolean;
|
|
77
|
+
llm?: boolean;
|
|
78
|
+
handler: (args: undefined, context: any) => Promise<z.infer<R>>;
|
|
79
|
+
};
|
|
29
80
|
export declare function toToolInputSchema(args?: z.ZodType): any;
|
|
30
81
|
export declare function schemaToJSONSchema(schema: z.ZodType): any;
|
|
31
82
|
export declare function toLLMTools(entries: BridgeEntries, options?: ToLLMToolsOptions): unknown[];
|
|
@@ -106,11 +157,11 @@ export declare function getMetaTools(options?: {
|
|
|
106
157
|
required: string[];
|
|
107
158
|
};
|
|
108
159
|
}[];
|
|
109
|
-
export declare function toolSearch(entries: BridgeEntries, query?: string): {
|
|
160
|
+
export declare function toolSearch(entries: BridgeEntries, query?: string, surface?: ToolSurface): {
|
|
110
161
|
name: string;
|
|
111
162
|
description?: string;
|
|
112
163
|
}[];
|
|
113
|
-
export declare function toolDescribe(entries: BridgeEntries, name: string): {
|
|
164
|
+
export declare function toolDescribe(entries: BridgeEntries, name: string, surface?: ToolSurface): {
|
|
114
165
|
name: string;
|
|
115
166
|
description: string | undefined;
|
|
116
167
|
args: any;
|
|
@@ -119,8 +170,22 @@ export declare function toolDescribe(entries: BridgeEntries, name: string): {
|
|
|
119
170
|
/**
|
|
120
171
|
* Serialize a tool result and enforce `tbConfig.maxToolOutputChars`. Oversized results
|
|
121
172
|
* throw (instead of truncating to invalid JSON) so the model is prompted to narrow its
|
|
122
|
-
* query. Returns the serialized JSON
|
|
173
|
+
* query. Returns the serialized JSON (callers may discard it; it's used only to measure).
|
|
123
174
|
*/
|
|
124
175
|
export declare function enforceToolOutputLimit(result: unknown): string;
|
|
125
176
|
export declare function toolUse(bridge: Bridge, name: string, args: unknown, context?: unknown): Promise<any>;
|
|
126
|
-
export declare function handleMetaToolCall(bridge: Bridge, entries: BridgeEntries, toolCall: ToolCall, context?: unknown): Promise<unknown>;
|
|
177
|
+
export declare function handleMetaToolCall(bridge: Bridge, entries: BridgeEntries, toolCall: ToolCall, context?: unknown, surface?: ToolSurface): Promise<unknown>;
|
|
178
|
+
export declare const META_TOOL_NAMES: readonly ["tool_search", "tool_describe", "tool_use"];
|
|
179
|
+
export declare function isMetaToolName(name: string): boolean;
|
|
180
|
+
/**
|
|
181
|
+
* Build the tool list for a model, honoring the chosen mode:
|
|
182
|
+
* - 'attach_all' → one tool per visible entry (current `toLLMTools` behavior)
|
|
183
|
+
* - 'on_demand' → the 3 meta-tools (`tool_search`, `tool_describe`, `tool_use`)
|
|
184
|
+
*/
|
|
185
|
+
export declare function getTools(entries: BridgeEntries, options?: GetToolsOptions): unknown[];
|
|
186
|
+
/**
|
|
187
|
+
* Execute whatever the model called, in either mode. Dispatches on the tool name:
|
|
188
|
+
* a meta-tool name runs the discovery flow, anything else runs that entry directly.
|
|
189
|
+
* Identical handler execution, validation, and output-limit enforcement either way.
|
|
190
|
+
*/
|
|
191
|
+
export declare function handleToolCall(bridge: Bridge, entries: BridgeEntries, toolCall: ToolCall, options?: HandleToolCallOptions): Promise<unknown>;
|
package/dist/tools/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.LLM_TOOL_FORMATS = void 0;
|
|
3
|
+
exports.META_TOOL_NAMES = exports.TOOL_MODES = exports.LLM_TOOL_FORMATS = void 0;
|
|
4
4
|
exports.isLLMToolFormat = isLLMToolFormat;
|
|
5
|
+
exports.isToolMode = isToolMode;
|
|
6
|
+
exports.isEntryVisible = isEntryVisible;
|
|
5
7
|
exports.defineBridge = defineBridge;
|
|
8
|
+
exports.defineEntry = defineEntry;
|
|
6
9
|
exports.toToolInputSchema = toToolInputSchema;
|
|
7
10
|
exports.schemaToJSONSchema = schemaToJSONSchema;
|
|
8
11
|
exports.toLLMTools = toLLMTools;
|
|
@@ -12,15 +15,35 @@ exports.toolDescribe = toolDescribe;
|
|
|
12
15
|
exports.enforceToolOutputLimit = enforceToolOutputLimit;
|
|
13
16
|
exports.toolUse = toolUse;
|
|
14
17
|
exports.handleMetaToolCall = handleMetaToolCall;
|
|
18
|
+
exports.isMetaToolName = isMetaToolName;
|
|
19
|
+
exports.getTools = getTools;
|
|
20
|
+
exports.handleToolCall = handleToolCall;
|
|
15
21
|
const zod_1 = require("zod");
|
|
16
22
|
const config_1 = require("../config");
|
|
17
23
|
exports.LLM_TOOL_FORMATS = ['openai', 'anthropic', 'json-schema'];
|
|
18
24
|
function isLLMToolFormat(value) {
|
|
19
25
|
return exports.LLM_TOOL_FORMATS.includes(value);
|
|
20
26
|
}
|
|
27
|
+
// How tools are presented to an AI surface:
|
|
28
|
+
// - 'attach_all' → every eligible entry is attached as its own tool
|
|
29
|
+
// - 'on_demand' → the model gets the 3 meta-tools and discovers the rest
|
|
30
|
+
exports.TOOL_MODES = ['attach_all', 'on_demand'];
|
|
31
|
+
function isToolMode(value) {
|
|
32
|
+
return exports.TOOL_MODES.includes(value);
|
|
33
|
+
}
|
|
34
|
+
// An entry is exposed to a surface unless it explicitly opts out via its flag.
|
|
35
|
+
function isEntryVisible(entry, surface) {
|
|
36
|
+
if (!entry)
|
|
37
|
+
return false;
|
|
38
|
+
return surface === 'mcp' ? entry.mcp !== false : entry.llm !== false;
|
|
39
|
+
}
|
|
21
40
|
function defineBridge(entries) {
|
|
22
41
|
const bridge = {};
|
|
23
42
|
for (const [key, entry] of Object.entries(entries)) {
|
|
43
|
+
// Reserved: these names drive the on_demand discovery flow. An entry using one
|
|
44
|
+
// would be shadowed by the meta-tool in handleToolCall and never reachable by name.
|
|
45
|
+
if (isMetaToolName(key))
|
|
46
|
+
throw new Error(`Entry name "${key}" is reserved for a meta-tool — rename the entry.`);
|
|
24
47
|
bridge[key] = async (...handlerArgs) => {
|
|
25
48
|
if (entry.args)
|
|
26
49
|
handlerArgs[0] = entry.args.parse(handlerArgs[0]);
|
|
@@ -29,6 +52,12 @@ function defineBridge(entries) {
|
|
|
29
52
|
}
|
|
30
53
|
return bridge;
|
|
31
54
|
}
|
|
55
|
+
// `any` is required here: an overload implementation signature must be `any`, and the
|
|
56
|
+
// handler's `context: any` is what lets callers annotate it inline (e.g. `_ctx: context.user`) —
|
|
57
|
+
// a narrower declared type like `unknown` would reject that annotation.
|
|
58
|
+
function defineEntry(entry) {
|
|
59
|
+
return entry;
|
|
60
|
+
}
|
|
32
61
|
const NO_ARGS_SCHEMA = { type: 'object', properties: {}, additionalProperties: false };
|
|
33
62
|
function toToolInputSchema(args) {
|
|
34
63
|
if (!args)
|
|
@@ -56,12 +85,13 @@ function schemaToJSONSchema(schema) {
|
|
|
56
85
|
function toLLMTools(entries, options) {
|
|
57
86
|
const format = options?.format || 'openai';
|
|
58
87
|
const includeResponse = options?.includeResponse || false;
|
|
88
|
+
const surface = options?.surface || 'llm';
|
|
59
89
|
// Guard against invalid formats slipping in via casts (e.g. from query params)
|
|
60
90
|
if (!isLLMToolFormat(format))
|
|
61
91
|
throw new Error(`Invalid LLM tool format: ${format}. Expected one of ${exports.LLM_TOOL_FORMATS.join(', ')}`);
|
|
62
92
|
const tools = [];
|
|
63
93
|
for (const [name, entry] of Object.entries(entries)) {
|
|
64
|
-
if (entry
|
|
94
|
+
if (!isEntryVisible(entry, surface))
|
|
65
95
|
continue;
|
|
66
96
|
const description = entry.description;
|
|
67
97
|
const parameters = toToolInputSchema(entry.args);
|
|
@@ -152,24 +182,49 @@ function getMetaTools(options) {
|
|
|
152
182
|
return tools;
|
|
153
183
|
}
|
|
154
184
|
}
|
|
155
|
-
function toolSearch(entries, query) {
|
|
156
|
-
const
|
|
157
|
-
const q = query?.toLowerCase();
|
|
185
|
+
function toolSearch(entries, query, surface = 'llm') {
|
|
186
|
+
const visible = [];
|
|
158
187
|
for (const [name, entry] of Object.entries(entries)) {
|
|
159
|
-
if (entry
|
|
188
|
+
if (!isEntryVisible(entry, surface))
|
|
160
189
|
continue;
|
|
161
|
-
|
|
162
|
-
const haystack = `${name} ${entry.description || ''}`.toLowerCase();
|
|
163
|
-
if (!haystack.includes(q))
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
results.push({ name, description: entry.description });
|
|
190
|
+
visible.push({ name, description: entry.description });
|
|
167
191
|
}
|
|
168
|
-
|
|
192
|
+
const q = query?.trim().toLowerCase();
|
|
193
|
+
if (!q)
|
|
194
|
+
return visible;
|
|
195
|
+
// Tokenize the query so natural, multi-word searches still match. A plain substring
|
|
196
|
+
// match fails on queries like "views analytics last 3 days" because the whole phrase is
|
|
197
|
+
// never a contiguous substring of any tool. Instead we score each tool by how many query
|
|
198
|
+
// tokens appear in its name/description, with a big boost for a full-phrase hit, and
|
|
199
|
+
// return only matching tools ranked best-first.
|
|
200
|
+
//
|
|
201
|
+
// Single-character tokens (stray punctuation splits, lone digits like "3") are dropped:
|
|
202
|
+
// they match almost everything and only add noise to results the model has to sift through.
|
|
203
|
+
const tokens = q.split(/[^a-z0-9]+/).filter(token => token.length > 1);
|
|
204
|
+
return visible
|
|
205
|
+
.map(tool => {
|
|
206
|
+
const name = tool.name.toLowerCase();
|
|
207
|
+
const description = (tool.description || '').toLowerCase();
|
|
208
|
+
const haystack = `${name} ${description}`;
|
|
209
|
+
// A full-phrase hit dominates; name hits outweigh description hits so the most
|
|
210
|
+
// on-point tools rank above ones that only mention a token in passing.
|
|
211
|
+
const phraseScore = haystack.includes(q) ? 1000 : 0;
|
|
212
|
+
const tokenScore = tokens.reduce((score, token) => {
|
|
213
|
+
if (name.includes(token))
|
|
214
|
+
return score + 3;
|
|
215
|
+
if (description.includes(token))
|
|
216
|
+
return score + 1;
|
|
217
|
+
return score;
|
|
218
|
+
}, 0);
|
|
219
|
+
return { tool, score: phraseScore + tokenScore };
|
|
220
|
+
})
|
|
221
|
+
.filter(scored => scored.score > 0)
|
|
222
|
+
.sort((a, b) => b.score - a.score || a.tool.name.localeCompare(b.tool.name))
|
|
223
|
+
.map(scored => scored.tool);
|
|
169
224
|
}
|
|
170
|
-
function toolDescribe(entries, name) {
|
|
225
|
+
function toolDescribe(entries, name, surface = 'llm') {
|
|
171
226
|
const entry = entries[name];
|
|
172
|
-
if (!entry || entry
|
|
227
|
+
if (!entry || !isEntryVisible(entry, surface))
|
|
173
228
|
throw new Error(`Tool not found: ${name}`);
|
|
174
229
|
return {
|
|
175
230
|
name,
|
|
@@ -181,7 +236,7 @@ function toolDescribe(entries, name) {
|
|
|
181
236
|
/**
|
|
182
237
|
* Serialize a tool result and enforce `tbConfig.maxToolOutputChars`. Oversized results
|
|
183
238
|
* throw (instead of truncating to invalid JSON) so the model is prompted to narrow its
|
|
184
|
-
* query. Returns the serialized JSON
|
|
239
|
+
* query. Returns the serialized JSON (callers may discard it; it's used only to measure).
|
|
185
240
|
*/
|
|
186
241
|
function enforceToolOutputLimit(result) {
|
|
187
242
|
const serialized = JSON.stringify(result) ?? '';
|
|
@@ -191,6 +246,9 @@ function enforceToolOutputLimit(result) {
|
|
|
191
246
|
}
|
|
192
247
|
return serialized;
|
|
193
248
|
}
|
|
249
|
+
// The single execution boundary: runs the handler and enforces tbConfig.maxToolOutputChars
|
|
250
|
+
// on its result. Every path that actually executes an entry (tool_use, direct call) goes
|
|
251
|
+
// through here, so output is capped exactly once. Discovery (search/describe) never does.
|
|
194
252
|
async function toolUse(bridge, name, args, context) {
|
|
195
253
|
const handler = bridge[name];
|
|
196
254
|
if (!handler)
|
|
@@ -199,16 +257,15 @@ async function toolUse(bridge, name, args, context) {
|
|
|
199
257
|
enforceToolOutputLimit(result);
|
|
200
258
|
return result;
|
|
201
259
|
}
|
|
202
|
-
async function handleMetaToolCall(bridge, entries, toolCall, context) {
|
|
260
|
+
async function handleMetaToolCall(bridge, entries, toolCall, context, surface = 'llm') {
|
|
203
261
|
switch (toolCall.name) {
|
|
204
262
|
case 'tool_search':
|
|
205
|
-
return toolSearch(entries, toolCall.arguments?.query);
|
|
263
|
+
return toolSearch(entries, toolCall.arguments?.query, surface);
|
|
206
264
|
case 'tool_describe':
|
|
207
|
-
return toolDescribe(entries, toolCall.arguments?.name);
|
|
265
|
+
return toolDescribe(entries, toolCall.arguments?.name, surface);
|
|
208
266
|
case 'tool_use': {
|
|
209
267
|
const args = toolCall.arguments;
|
|
210
|
-
|
|
211
|
-
if (!entry || entry.llm === false)
|
|
268
|
+
if (!isEntryVisible(entries[args.name], surface))
|
|
212
269
|
throw new Error(`Tool not found: ${args.name}`);
|
|
213
270
|
return toolUse(bridge, args.name, args.arguments || {}, context);
|
|
214
271
|
}
|
|
@@ -216,3 +273,45 @@ async function handleMetaToolCall(bridge, entries, toolCall, context) {
|
|
|
216
273
|
throw new Error(`Unknown meta-tool: ${toolCall.name}. Expected "tool_search", "tool_describe", or "tool_use".`);
|
|
217
274
|
}
|
|
218
275
|
}
|
|
276
|
+
// The three meta-tool names are reserved; a tool call by any of these runs the
|
|
277
|
+
// discovery flow, anything else is treated as a direct entry call.
|
|
278
|
+
exports.META_TOOL_NAMES = ['tool_search', 'tool_describe', 'tool_use'];
|
|
279
|
+
function isMetaToolName(name) {
|
|
280
|
+
return exports.META_TOOL_NAMES.includes(name);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Build the tool list for a model, honoring the chosen mode:
|
|
284
|
+
* - 'attach_all' → one tool per visible entry (current `toLLMTools` behavior)
|
|
285
|
+
* - 'on_demand' → the 3 meta-tools (`tool_search`, `tool_describe`, `tool_use`)
|
|
286
|
+
*/
|
|
287
|
+
function getTools(entries, options) {
|
|
288
|
+
const toolMode = options?.toolMode || 'on_demand';
|
|
289
|
+
const format = options?.format || 'openai';
|
|
290
|
+
if (toolMode === 'on_demand')
|
|
291
|
+
return getMetaTools({ format });
|
|
292
|
+
return toLLMTools(entries, {
|
|
293
|
+
format,
|
|
294
|
+
surface: options?.surface || 'llm',
|
|
295
|
+
includeResponse: options?.includeResponse
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Execute whatever the model called, in either mode. Dispatches on the tool name:
|
|
300
|
+
* a meta-tool name runs the discovery flow, anything else runs that entry directly.
|
|
301
|
+
* Identical handler execution, validation, and output-limit enforcement either way.
|
|
302
|
+
*/
|
|
303
|
+
async function handleToolCall(bridge, entries, toolCall, options) {
|
|
304
|
+
const surface = options?.surface || 'llm';
|
|
305
|
+
const context = options?.context;
|
|
306
|
+
if (isMetaToolName(toolCall.name)) {
|
|
307
|
+
return handleMetaToolCall(bridge, entries, toolCall, context, surface);
|
|
308
|
+
}
|
|
309
|
+
// Direct entry call (attach_all mode): respect per-surface visibility.
|
|
310
|
+
if (!isEntryVisible(entries[toolCall.name], surface))
|
|
311
|
+
throw new Error(`Tool not found: ${toolCall.name}`);
|
|
312
|
+
// `toolUse` enforces tbConfig.maxToolOutputChars on the executed result — the only
|
|
313
|
+
// place output is unbounded. Discovery results (tool_search / tool_describe) are
|
|
314
|
+
// deliberately left uncapped so a blanket search can never deadlock the model's
|
|
315
|
+
// own recovery path.
|
|
316
|
+
return toolUse(bridge, toolCall.name, toolCall.arguments || {}, context);
|
|
317
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typed-bridge",
|
|
3
3
|
"description": "Type-safe server functions for TypeScript that also expose your backend as an MCP server and LLM tools for AI agents",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "4.0.0",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"author": "neilveil",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"test:server": "tsx --watch src/demo/index.ts",
|
|
14
14
|
"test:cli": "tsx src/scripts/cli.ts gen-typed-bridge-client --src ./src/demo/bridge/index.ts --dest ./test/bridge.ts",
|
|
15
15
|
"test:bridge": "tsx test/index.ts",
|
|
16
|
+
"test:mcp": "tsx test/mcp.ts",
|
|
16
17
|
"test:llm": "tsx test/llm.ts",
|
|
17
18
|
"lint": "eslint",
|
|
18
19
|
"prepublishOnly": "npm run dist"
|
package/readme.md
CHANGED
|
@@ -4,29 +4,31 @@
|
|
|
4
4
|
|
|
5
5
|
# Typed Bridge
|
|
6
6
|
|
|
7
|
-
###
|
|
7
|
+
### Let AI talk to your API. Natively.
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/typed-bridge)
|
|
10
10
|
[](https://www.npmjs.com/package/typed-bridge)
|
|
11
11
|
[](https://github.com/neilveil/typed-bridge/blob/main/license.txt)
|
|
12
12
|
|
|
13
|
-
**
|
|
13
|
+
**Write one TypeScript function. Get a typed client, OpenAI & Anthropic tools, and an MCP server — from a single Zod schema.**
|
|
14
14
|
|
|
15
15
|
</div>
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
-
##
|
|
19
|
+
## Stop maintaining two backends
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
Most backends now ship twice. Once for your app — then again for AI: tool schemas, an MCP server, agent integrations, all kept in sync by hand for months.
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
2. An **MCP server** so Cursor, Claude Desktop, and Windsurf can call your backend directly.
|
|
25
|
-
3. **LLM tool definitions** so OpenAI and Anthropic can use your backend as tools.
|
|
23
|
+
Typed Bridge kills the duplication. Write a TypeScript function once, describe it with Zod, and instantly get:
|
|
26
24
|
|
|
27
|
-
|
|
25
|
+
- A **fully typed API client** for your frontend — React, Vue, Angular, React Native, anything.
|
|
26
|
+
- **OpenAI & Anthropic tools** so your agents can call your backend.
|
|
27
|
+
- An **MCP server** for Cursor, Claude, and Windsurf.
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
And auth is not an afterthought: **agents inherit the exact same auth context and permissions as your users** — an agent can never reach something the signed-in user can't. One function. One schema. Every surface, secured the same way.
|
|
30
|
+
|
|
31
|
+
> Your backend just became AI-native.
|
|
30
32
|
|
|
31
33
|
---
|
|
32
34
|
|
|
@@ -38,12 +40,23 @@ flowchart LR
|
|
|
38
40
|
B --> C[Typed client for your frontend]
|
|
39
41
|
B --> D[MCP server for AI tools]
|
|
40
42
|
B --> E[LLM tool definitions]
|
|
43
|
+
C --> H[React, Vue, Angular, React Native]
|
|
41
44
|
D --> F[Cursor, Claude, Windsurf]
|
|
42
45
|
E --> G[OpenAI, Anthropic, your agents]
|
|
43
46
|
```
|
|
44
47
|
|
|
45
48
|
You describe a function once with a Zod schema. Typed Bridge derives the client types, the MCP tool schema, and the LLM tool schema from that single source. They can never fall out of sync, because there is only one truth.
|
|
46
49
|
|
|
50
|
+
### What that unlocks
|
|
51
|
+
|
|
52
|
+
Point Claude or Cursor at your backend and it works across your real functions, with your real auth:
|
|
53
|
+
|
|
54
|
+
> **You:** "Email the invoice for order #1234 to the customer."
|
|
55
|
+
>
|
|
56
|
+
> The agent searches your tools, finds `order.fetch`, `invoice.create`, and `email.send`, reads their schemas, and calls them in turn — running as the signed-in user, blocked from anything that user can't touch.
|
|
57
|
+
|
|
58
|
+
No glue code. No tool schemas written by hand. No second backend. Just your functions.
|
|
59
|
+
|
|
47
60
|
---
|
|
48
61
|
|
|
49
62
|
## Quick start
|
|
@@ -54,50 +67,60 @@ You describe a function once with a Zod schema. Typed Bridge derives the client
|
|
|
54
67
|
npm i typed-bridge
|
|
55
68
|
```
|
|
56
69
|
|
|
57
|
-
### 2.
|
|
70
|
+
### 2. Define your contexts (optional)
|
|
58
71
|
|
|
59
|
-
`bridge/
|
|
72
|
+
Name the shapes your middleware injects, so handlers can declare what they expect. `bridge/context.ts`:
|
|
60
73
|
|
|
61
74
|
```ts
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
export const fetch = async (args: z.infer<typeof types.fetch.args>) => {
|
|
66
|
-
return db.users.findById(args.id)
|
|
67
|
-
}
|
|
75
|
+
export type user = { id: number }
|
|
76
|
+
export type admin = { id: number; role: 'admin' }
|
|
77
|
+
export type guest = { requestedAt: number }
|
|
68
78
|
```
|
|
69
79
|
|
|
70
|
-
### 3.
|
|
80
|
+
### 3. Define an entry
|
|
71
81
|
|
|
72
|
-
`bridge/user/
|
|
82
|
+
One self-contained object per handler — schema and logic together. `bridge/user/index.ts`:
|
|
73
83
|
|
|
74
84
|
```ts
|
|
75
|
-
import { z } from 'typed-bridge'
|
|
85
|
+
import { z, defineEntry } from 'typed-bridge'
|
|
86
|
+
import * as context from '../context'
|
|
76
87
|
|
|
77
|
-
export const fetch = {
|
|
88
|
+
export const fetch = defineEntry({
|
|
78
89
|
description: 'Fetch a user by ID',
|
|
79
|
-
args: z.object({
|
|
80
|
-
|
|
81
|
-
}
|
|
90
|
+
args: z.object({
|
|
91
|
+
id: z.number().min(1).describe('Unique user identifier')
|
|
92
|
+
}),
|
|
93
|
+
res: z.object({
|
|
94
|
+
id: z.number().describe('User ID'),
|
|
95
|
+
name: z.string().describe('Full name'),
|
|
96
|
+
email: z.string().describe('Primary email address')
|
|
97
|
+
}),
|
|
98
|
+
handler: async (args, ctx: context.user) => {
|
|
99
|
+
return db.users.findById(args.id) // `args` is inferred as { id: number }
|
|
100
|
+
}
|
|
101
|
+
})
|
|
82
102
|
```
|
|
83
103
|
|
|
84
|
-
|
|
104
|
+
`defineEntry` infers the handler's argument type from `args` and checks its return against `res` — no `z.infer<typeof ...>`, no manual `.parse()`, ever.
|
|
105
|
+
|
|
106
|
+
`.describe()` your `res` fields too, not just `args`. In `on_demand` mode the model reads the response schema via `tool_describe` before it calls, so those descriptions help it use the output correctly.
|
|
107
|
+
|
|
108
|
+
### 4. Register your entries
|
|
85
109
|
|
|
86
|
-
`bridge/index.ts`:
|
|
110
|
+
Map each route name straight to its entry. `bridge/index.ts`:
|
|
87
111
|
|
|
88
112
|
```ts
|
|
89
113
|
import { defineBridge } from 'typed-bridge'
|
|
90
114
|
import * as user from './user'
|
|
91
|
-
import * as userTypes from './user/types'
|
|
92
115
|
|
|
93
116
|
export const entries = {
|
|
94
|
-
'user.fetch':
|
|
117
|
+
'user.fetch': user.fetch
|
|
95
118
|
}
|
|
96
119
|
|
|
97
120
|
export default defineBridge(entries)
|
|
98
121
|
```
|
|
99
122
|
|
|
100
|
-
`
|
|
123
|
+
A clean 1:1 mapping — `user.fetch` → `fetch`. No spreads, no schema copying. (Need to keep a handler off MCP or LLM? Add `mcp: false` / `llm: false` to its entry — covered in [Superpower 2](#choose-what-each-surface-can-touch).)
|
|
101
124
|
|
|
102
125
|
### 5. Boot the server, AI included
|
|
103
126
|
|
|
@@ -108,7 +131,7 @@ import bridge, { entries } from './bridge'
|
|
|
108
131
|
createBridge(bridge, 8080, '/bridge', { entries, mcp: true })
|
|
109
132
|
```
|
|
110
133
|
|
|
111
|
-
That is it. You now have a typed HTTP API
|
|
134
|
+
That is it. You now have a typed HTTP API and an MCP endpoint at `/bridge/mcp`. From one function.
|
|
112
135
|
|
|
113
136
|
---
|
|
114
137
|
|
|
@@ -130,6 +153,32 @@ typedBridgeConfig.host = 'http://localhost:8080/bridge'
|
|
|
130
153
|
const user = await bridge['user.fetch']({ id: 1 })
|
|
131
154
|
```
|
|
132
155
|
|
|
156
|
+
### Sending headers (auth and more)
|
|
157
|
+
|
|
158
|
+
Every request sends `typedBridgeConfig.headers`. Set them once after login, or update a single header any time — the change applies to all subsequent calls:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
// Set the full header set (e.g. right after sign-in)
|
|
162
|
+
typedBridgeConfig.headers = {
|
|
163
|
+
'Content-Type': 'application/json',
|
|
164
|
+
Authorization: `Bearer ${token}`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Or add / change one header on the fly
|
|
168
|
+
typedBridgeConfig.headers['X-Tenant'] = 'acme'
|
|
169
|
+
|
|
170
|
+
// Clear it on logout
|
|
171
|
+
delete typedBridgeConfig.headers['Authorization']
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
These headers are exactly what your `createMiddleware` and `mcpGetContext` read on the server, so the same token drives auth across HTTP and AI surfaces. You can also tap every response with `typedBridgeConfig.onResponse`:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
typedBridgeConfig.onResponse = res => {
|
|
178
|
+
if (res.status === 401) redirectToLogin()
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
133
182
|
Full autocomplete, full type safety, from server to screen. Your frontend never imports Zod and never sees your backend code. It is one generated file you can drop into React, Vue, Angular, React Native, or anything else.
|
|
134
183
|
|
|
135
184
|
---
|
|
@@ -149,13 +198,14 @@ Point any MCP client at it:
|
|
|
149
198
|
"mcpServers": {
|
|
150
199
|
"my-backend": {
|
|
151
200
|
"url": "http://localhost:8080/bridge/mcp",
|
|
152
|
-
"headers": { "Authorization": "Bearer
|
|
153
|
-
"env": { "MCP_API_KEY": "your-api-key" }
|
|
201
|
+
"headers": { "Authorization": "Bearer your-api-key" }
|
|
154
202
|
}
|
|
155
203
|
}
|
|
156
204
|
}
|
|
157
205
|
```
|
|
158
206
|
|
|
207
|
+
Typed Bridge is a **remote (HTTP) MCP server**, so the client just needs the `url` and any auth `headers` — those headers reach your `mcpGetContext`. (The `env` block you may have seen elsewhere is only for **stdio** servers the client launches as a local process; there is no subprocess here, so it does nothing.)
|
|
208
|
+
|
|
159
209
|
### Auth that actually works
|
|
160
210
|
|
|
161
211
|
MCP requests skip your normal middleware, so you derive context straight from headers:
|
|
@@ -173,72 +223,115 @@ createBridge(bridge, 8080, '/bridge', {
|
|
|
173
223
|
|
|
174
224
|
The returned context lands in every handler as the second argument, exactly like middleware context. Same security model for humans and agents.
|
|
175
225
|
|
|
226
|
+
### Control how many tools the client sees
|
|
227
|
+
|
|
228
|
+
A `toolMode` option, set once on `createBridge`, decides how the server presents tools (defaults to `on_demand`):
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
createBridge(bridge, 8080, '/bridge', { entries, mcp: true, toolMode: 'attach_all' })
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
In `on_demand` (the default) the server lists just `tool_search`, `tool_describe`, and `tool_use`, and the client discovers the rest as it needs them — ideal for a large API behind a single MCP connection. In `attach_all` it lists every exposed entry as its own tool. Same two modes power direct LLM tool calling too — see [Superpower 3](#superpower-3-llm-tool-calling).
|
|
235
|
+
|
|
236
|
+
`toolMode` controls **what's listed**, not what's reachable. It's a discovery strategy, not a sandbox: any entry visible to a surface stays callable (via `tool_use` or by its own name), so the two modes always execute identically. The hard boundary is the `mcp` / `llm` visibility flags — a hidden entry is never listed, discoverable, or callable.
|
|
237
|
+
|
|
176
238
|
### Choose what each surface can touch
|
|
177
239
|
|
|
178
|
-
MCP and LLM tools are independent surfaces. Every entry is exposed to both by default, and two flags
|
|
240
|
+
MCP and LLM tools are independent surfaces. Every entry is exposed to both by default, and two flags — set right on the entry via `defineEntry` — hide a handler from either one while it stays fully callable over HTTP:
|
|
179
241
|
|
|
180
242
|
- `mcp: false` keeps a handler off the MCP server.
|
|
181
|
-
- `llm: false` keeps it out of `
|
|
243
|
+
- `llm: false` keeps it out of `getTools` and tool search.
|
|
182
244
|
|
|
183
245
|
```ts
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
246
|
+
// Your LLM app can call it, external MCP clients cannot
|
|
247
|
+
export const remove = defineEntry({
|
|
248
|
+
description: 'Remove a user',
|
|
249
|
+
args: z.object({ id: z.number().min(1) }),
|
|
250
|
+
res: z.object({ ok: z.boolean() }),
|
|
251
|
+
mcp: false,
|
|
252
|
+
handler: async (args, ctx: context.admin) => { ... }
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
// HTTP and MCP only, never an LLM tool
|
|
256
|
+
export const sync = defineEntry({
|
|
257
|
+
description: 'Sync admin records',
|
|
258
|
+
args: z.object({}),
|
|
259
|
+
res: z.object({ synced: z.number() }),
|
|
260
|
+
llm: false,
|
|
261
|
+
handler: async (args, ctx: context.admin) => { ... }
|
|
262
|
+
})
|
|
189
263
|
```
|
|
190
264
|
|
|
191
|
-
|
|
265
|
+
Each flag governs its surface in **both modes** — under `attach_all` and `on_demand` alike, a hidden entry is neither attached directly nor discoverable through `tool_search`, and it is rejected if called by name. A model cannot reach it even by guessing.
|
|
192
266
|
|
|
193
267
|
---
|
|
194
268
|
|
|
195
269
|
## Superpower 3: LLM tool calling
|
|
196
270
|
|
|
197
|
-
Skip MCP and talk to models directly. Typed Bridge speaks OpenAI, Anthropic, and raw JSON Schema.
|
|
271
|
+
Skip MCP and talk to models directly. Typed Bridge speaks OpenAI, Anthropic, and raw JSON Schema — and one option decides **how** your tools reach the model.
|
|
272
|
+
|
|
273
|
+
### Two modes, set with `toolMode`
|
|
198
274
|
|
|
199
|
-
|
|
275
|
+
- **`on_demand`** (default) — the model gets three meta-tools and discovers the rest as it needs them.
|
|
276
|
+
- **`attach_all`** — every eligible entry is attached as its own tool.
|
|
277
|
+
|
|
278
|
+
You choose per call, right where you build the tool list. No global state:
|
|
200
279
|
|
|
201
280
|
```ts
|
|
202
|
-
import {
|
|
281
|
+
import { getTools, handleToolCall } from 'typed-bridge'
|
|
203
282
|
|
|
204
|
-
|
|
205
|
-
|
|
283
|
+
// Build the tool list (openai | anthropic | json-schema). Omit toolMode to use the default, on_demand.
|
|
284
|
+
const tools = getTools(entries, { toolMode: 'on_demand', format: 'openai' })
|
|
285
|
+
|
|
286
|
+
// Execute whatever the model called — same call for both modes
|
|
287
|
+
const result = await handleToolCall(bridge, entries, toolCall, { context })
|
|
206
288
|
```
|
|
207
289
|
|
|
208
|
-
|
|
290
|
+
Because you pass `toolMode` explicitly, a single process can run one assistant with `attach_all` and another with `on_demand`. `handleToolCall` needs no mode at all — it dispatches on the tool name (a meta-tool name runs the discovery flow, anything else runs that entry directly), so your loop is written once and never changes when you switch modes.
|
|
209
291
|
|
|
210
|
-
###
|
|
292
|
+
### `on_demand` — three tools, discovered as needed (default)
|
|
211
293
|
|
|
212
|
-
|
|
294
|
+
Most APIs have more endpoints than a model should see at once, so this is the default. Do not flood the context window — give the model three tools instead of two hundred:
|
|
213
295
|
|
|
214
296
|
```ts
|
|
215
|
-
|
|
297
|
+
const tools = getTools(entries, { toolMode: 'on_demand', format: 'openai' })
|
|
298
|
+
// → tool_search, tool_describe, tool_use
|
|
216
299
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
// The model discovers tools, inspects their schema, then calls them:
|
|
300
|
+
// The model discovers, inspects, then calls:
|
|
220
301
|
// 1. tool_search({ query: "user" }) → [{ name: "user.fetch", description: "..." }, ...]
|
|
221
302
|
// 2. tool_describe({ name: "user.fetch" }) → { name, description, args, response }
|
|
222
303
|
// 3. tool_use({ name: "user.fetch", arguments: { id: 1 } }) → { id: 1, name: "Alice" }
|
|
223
|
-
|
|
224
|
-
const result = await handleMetaToolCall(bridge, entries, {
|
|
225
|
-
name: 'tool_use',
|
|
226
|
-
arguments: { name: 'user.fetch', arguments: { id: 1 } }
|
|
227
|
-
})
|
|
228
304
|
```
|
|
229
305
|
|
|
230
306
|
The model calls `tool_search` to discover what exists (names and descriptions only), `tool_describe` to get the full schema for the tool it needs, then `tool_use` to run it. Your token bill stays flat as your API grows.
|
|
231
307
|
|
|
232
|
-
###
|
|
308
|
+
### `attach_all` — every tool, up front
|
|
233
309
|
|
|
234
|
-
```
|
|
235
|
-
|
|
310
|
+
```ts
|
|
311
|
+
const tools = getTools(entries, { toolMode: 'attach_all', format: 'openai' })
|
|
312
|
+
// → one tool per entry: user.fetch, product.create, ...
|
|
313
|
+
// Pass `tools` straight into openai.chat.completions.create()
|
|
236
314
|
```
|
|
237
315
|
|
|
316
|
+
Best when you have a handful of endpoints and want the model to see them all immediately.
|
|
317
|
+
|
|
318
|
+
> Two functions cover every case: `getTools` to build the tool list and `handleToolCall` to run whatever the model picks — in either mode.
|
|
319
|
+
|
|
238
320
|
---
|
|
239
321
|
|
|
240
322
|
## Why Typed Bridge
|
|
241
323
|
|
|
324
|
+
You could wire all of this by hand. Here is what you skip.
|
|
325
|
+
|
|
326
|
+
### vs hand-rolled REST + Express
|
|
327
|
+
|
|
328
|
+
| | **Typed Bridge** | **Hand-rolled REST** |
|
|
329
|
+
| ------------------- | ----------------------------------------- | --------------------------------------------- |
|
|
330
|
+
| Endpoints | Plain functions, auto-routed | Routes, controllers, and handlers wired by hand |
|
|
331
|
+
| Request validation | Built in with Zod, automatic | Added and maintained per route |
|
|
332
|
+
| Client + types | One generated file, always in sync | Hand-written fetch calls and types, or none |
|
|
333
|
+
| AI tooling | MCP and LLM tools included | Build the whole AI layer yourself |
|
|
334
|
+
|
|
242
335
|
### vs writing AI tools by hand
|
|
243
336
|
|
|
244
337
|
| | **Typed Bridge** | **DIY tool calling** |
|
|
@@ -257,6 +350,8 @@ GET /bridge/tools?format=openai
|
|
|
257
350
|
| Frontend framework | Any | React first, adapters for others |
|
|
258
351
|
| AI tooling | Built in (MCP and LLM) | Not included |
|
|
259
352
|
|
|
353
|
+
tRPC has a deeper React/inference ecosystem and richer client features. Reach for Typed Bridge when you also want AI surfaces and a framework-agnostic, standalone client.
|
|
354
|
+
|
|
260
355
|
### vs GraphQL
|
|
261
356
|
|
|
262
357
|
| | **Typed Bridge** | **GraphQL** |
|
|
@@ -266,6 +361,8 @@ GET /bridge/tools?format=openai
|
|
|
266
361
|
| Learning curve | Minimal, plain TypeScript | SDL, resolvers, fragments, queries |
|
|
267
362
|
| AI tooling | Built in | Roll your own |
|
|
268
363
|
|
|
364
|
+
GraphQL is unmatched when clients need to shape their own queries across a complex graph. Typed Bridge is the simpler choice when you want typed RPC plus native AI access, not a query language.
|
|
365
|
+
|
|
269
366
|
---
|
|
270
367
|
|
|
271
368
|
## Middleware when you need it
|
|
@@ -280,11 +377,71 @@ createMiddleware('user.*', async (req, res) => {
|
|
|
280
377
|
res.status(401).send('Unauthorized')
|
|
281
378
|
return { next: false }
|
|
282
379
|
}
|
|
283
|
-
return { context: {
|
|
380
|
+
return { context: { id: 1 } } // shape matches `context.user` from bridge/context.ts
|
|
381
|
+
})
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
A middleware returns one of:
|
|
385
|
+
|
|
386
|
+
- `{ context }` — merge these values into the context and continue.
|
|
387
|
+
- `{ next: false }` — stop here. You must have sent a response (`res.status(...).send(...)`); the handler never runs.
|
|
388
|
+
- nothing — continue with no context change.
|
|
389
|
+
|
|
390
|
+
### Middlewares stack, broad to specific
|
|
391
|
+
|
|
392
|
+
Every middleware whose pattern matches the route runs, ordered from least to most specific (a literal segment counts as more specific than `*`). Each one's context is merged on top of the previous, so the handler receives the combined result:
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
createMiddleware('*', async () => {
|
|
396
|
+
return { context: { requestedAt: Date.now() } } // runs first, for every route
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
createMiddleware('user.*', async (req, res) => {
|
|
400
|
+
const token = req.headers.authorization
|
|
401
|
+
if (!token?.startsWith('Bearer ')) {
|
|
402
|
+
res.status(401).send('Unauthorized')
|
|
403
|
+
return { next: false }
|
|
404
|
+
}
|
|
405
|
+
return { context: { userId: Number(token.split(' ')[1]) } }
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
createMiddleware('user.remove', async (req, res) => {
|
|
409
|
+
if (req.headers['x-admin'] !== 'true') {
|
|
410
|
+
res.status(403).send('Admin access required')
|
|
411
|
+
return { next: false }
|
|
412
|
+
}
|
|
413
|
+
return { context: { isAdmin: true } }
|
|
284
414
|
})
|
|
285
415
|
```
|
|
286
416
|
|
|
287
|
-
|
|
417
|
+
A call to `user.remove` runs all three in order — `*` → `user.*` → `user.remove` — and the handler's `ctx` is the merged `{ requestedAt, userId, isAdmin }`. A call to `user.fetch` runs only the first two and gets `{ requestedAt, userId }`. If any middleware returns `{ next: false }`, the chain stops immediately and the rest never run.
|
|
418
|
+
|
|
419
|
+
### Reusing logic across middlewares
|
|
420
|
+
|
|
421
|
+
The pattern stacking above is automatic. When you instead want one middleware to *build on* another's logic, extract a plain function and call it — full control over order and short-circuiting:
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
const auth = async (req: Request, res: Response) => {
|
|
425
|
+
const user = verifyToken(req.headers['x-auth-token'] || '')
|
|
426
|
+
if (!user) {
|
|
427
|
+
res.status(401).send('Unauthorized')
|
|
428
|
+
return { next: false as const }
|
|
429
|
+
}
|
|
430
|
+
return { next: true as const, context: user }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
createMiddleware('admin.*', async (req, res) => {
|
|
434
|
+
const authRes = await auth(req, res)
|
|
435
|
+
if (authRes.next === false) return authRes // 401 already sent
|
|
436
|
+
|
|
437
|
+
if (!authRes.context.role.includes('admin')) {
|
|
438
|
+
res.status(403).send('Forbidden')
|
|
439
|
+
return { next: false }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return authRes // passes the authenticated user through as context
|
|
443
|
+
})
|
|
444
|
+
```
|
|
288
445
|
|
|
289
446
|
---
|
|
290
447
|
|
|
@@ -296,34 +453,52 @@ import { tbConfig } from 'typed-bridge'
|
|
|
296
453
|
tbConfig.logs.request = true
|
|
297
454
|
tbConfig.logs.response = true
|
|
298
455
|
tbConfig.logs.error = true
|
|
456
|
+
tbConfig.logs.argsOnError = true // Include handler args in error logs
|
|
457
|
+
tbConfig.logs.contextOnError = true // Include resolved context in error logs
|
|
299
458
|
tbConfig.responseDelay = 0 // Artificial delay in ms for testing loading states
|
|
300
|
-
tbConfig.maxToolOutputChars =
|
|
459
|
+
tbConfig.maxToolOutputChars = 100_000 // Cap MCP/LLM tool results (chars of JSON); default 100_000, set 0 to disable
|
|
301
460
|
```
|
|
302
461
|
|
|
462
|
+
> Tool exposure (`attach_all` vs `on_demand`) is **not** global — you pass `toolMode` explicitly to `getTools` for your own loop, and to `createBridge` for the MCP server.
|
|
463
|
+
|
|
303
464
|
`createBridge` also returns the underlying Express `app` and `server`, so you can add routes, serve static files, or attach any Express middleware.
|
|
304
465
|
|
|
305
466
|
### Guarding tool output size
|
|
306
467
|
|
|
307
|
-
The same function can serve a data-heavy response over HTTP and as an AI tool. A frontend handles a large payload fine, but feeding it to a model wastes tokens or overflows the context window.
|
|
468
|
+
The same function can serve a data-heavy response over HTTP and as an AI tool. A frontend handles a large payload fine, but feeding it to a model wastes tokens or overflows the context window. So by default Typed Bridge caps tool results at **100,000 characters** on the **MCP and LLM tool surfaces only** (HTTP is never limited). Oversized results are **rejected, not truncated** — the caller gets an error telling it to narrow the query, so the model never receives invalid JSON:
|
|
308
469
|
|
|
309
470
|
```ts
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
// A tool returning more than that responds with:
|
|
471
|
+
// A tool result over the cap responds with:
|
|
313
472
|
// "Result too large (182431 chars, limit 100000). Narrow the query with filters or pagination."
|
|
473
|
+
|
|
474
|
+
tbConfig.maxToolOutputChars = 250_000 // raise it
|
|
475
|
+
tbConfig.maxToolOutputChars = 0 // or disable the cap entirely
|
|
314
476
|
```
|
|
315
477
|
|
|
316
478
|
---
|
|
317
479
|
|
|
318
480
|
## Adding a new route
|
|
319
481
|
|
|
320
|
-
1.
|
|
321
|
-
2.
|
|
322
|
-
3.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
482
|
+
1. Define the entry with `defineEntry` in `bridge/<module>/index.ts` (description, args, res, handler, plus any `mcp`/`llm` flags).
|
|
483
|
+
2. Register it in `bridge/index.ts` with a direct mapping: `export const entries = { 'module.action': module.action }` then `export default defineBridge(entries)`.
|
|
484
|
+
3. Add middleware if needed and import it in your server entry.
|
|
485
|
+
4. Regenerate the client.
|
|
486
|
+
|
|
487
|
+
---
|
|
488
|
+
|
|
489
|
+
## Upgrading from v3
|
|
490
|
+
|
|
491
|
+
v4 unifies the LLM and MCP tool paths behind two functions and a single `toolMode` option. The MCP server itself is unchanged — `mcp: true` still mounts `/bridge/mcp` — but `toolMode` now defaults to `on_demand`.
|
|
492
|
+
|
|
493
|
+
| v3 | v4 |
|
|
494
|
+
| --------------------------------------------------- | ------------------------------------------------------------ |
|
|
495
|
+
| `toolsFormat` option / `GET /bridge/tools` endpoint | Build the list in code: `getTools(entries, { toolMode, format })` |
|
|
496
|
+
| `toLLMTools(entries, { format })` | `getTools(entries, { toolMode: 'attach_all', format })` |
|
|
497
|
+
| `getMetaTools({ format })` | `getTools(entries, { toolMode: 'on_demand', format })` |
|
|
498
|
+
| `handleMetaToolCall(...)` / direct `toolUse(...)` | `handleToolCall(bridge, entries, toolCall, { context, surface })` |
|
|
499
|
+
| `{ handler, ...types.fetch }` + separate `types.ts` | One `defineEntry({ ... })` object, mapped 1:1 in `entries` |
|
|
500
|
+
|
|
501
|
+
`handleToolCall` is mode-agnostic: it dispatches on the tool name, so the same loop runs whether you chose `attach_all` or `on_demand`.
|
|
327
502
|
|
|
328
503
|
---
|
|
329
504
|
|