typed-bridge 3.0.1 → 4.0.1

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.
@@ -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, LLMToolFormat } from '../tools';
4
+ import { Bridge, BridgeEntries, ToolMode } from '../tools';
5
5
  interface CreateBridgeOptions {
6
6
  entries?: BridgeEntries;
7
- toolsFormat?: LLMToolFormat;
7
+ toolMode?: ToolMode;
8
8
  mcp?: boolean | string;
9
9
  mcpGetContext?: MCPGetContext;
10
10
  }
@@ -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));
@@ -10,5 +10,5 @@ exports.config = {
10
10
  contextOnError: true
11
11
  },
12
12
  responseDelay: 0,
13
- maxToolOutputChars: 0
13
+ maxToolOutputChars: 100_000
14
14
  };
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, getMetaTools, handleMetaToolCall, toLLMTools, toolDescribe, toolSearch, toolUse } from './tools';
7
- export type { Bridge, BridgeEntries, BridgeEntry, LLMToolFormat, ToLLMToolsOptions, ToolCall } from './tools';
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.toolUse = exports.toolSearch = exports.toolDescribe = exports.toLLMTools = exports.handleMetaToolCall = exports.getMetaTools = exports.defineBridge = exports.mountMCP = exports.tbConfig = exports.onShutdown = exports.createMiddleware = exports.createBridge = exports.Router = exports.express = void 0;
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, "getMetaTools", { enumerable: true, get: function () { return tools_1.getMetaTools; } });
21
- Object.defineProperty(exports, "handleMetaToolCall", { enumerable: true, get: function () { return tools_1.handleMetaToolCall; } });
22
- Object.defineProperty(exports, "toLLMTools", { enumerable: true, get: function () { return tools_1.toLLMTools; } });
23
- Object.defineProperty(exports, "toolDescribe", { enumerable: true, get: function () { return tools_1.toolDescribe; } });
24
- Object.defineProperty(exports, "toolSearch", { enumerable: true, get: function () { return tools_1.toolSearch; } });
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; } });
@@ -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
- function createMCPServer(bridge, entries, headersRef, getContext) {
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]) => isExposed(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
- const result = await handler(args || {}, context);
38
- // Throws if the result exceeds tbConfig.maxToolOutputChars handled below as isError
39
- const text = (0, tools_1.enforceToolOutputLimit)(result);
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) => {
@@ -466,8 +466,13 @@ function cleanTsFile(src) {
466
466
  removeDefaultExportTransformer,
467
467
  pruneUnreachableTransformer
468
468
  ]);
469
- // Print final code
470
- const header = `/* This file is auto-generated by typed-bridge. Do not edit. */`;
469
+ // Print final code. The client is a generated artifact, so disable linting and type-checking
470
+ // on it (`eslint-disable` must lead so it also covers the `@ts-nocheck` directive below).
471
+ const header = [
472
+ '/* eslint-disable */',
473
+ '// @ts-nocheck',
474
+ '/* This file is auto-generated by typed-bridge. Do not edit. */'
475
+ ].join('\n');
471
476
  const printer = typescript_1.default.createPrinter();
472
477
  const transformedCode = header + '\n' + printer.printFile(result.transformed[0]).concat(proxySnippet());
473
478
  // Write back to the same file
@@ -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 so callers can reuse it without re-stringifying.
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>;
@@ -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.llm === false)
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 results = [];
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.llm === false)
188
+ if (!isEntryVisible(entry, surface))
160
189
  continue;
161
- if (q) {
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
- return results;
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.llm === false)
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 so callers can reuse it without re-stringifying.
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
- const entry = entries[args.name];
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": "3.0.1",
4
+ "version": "4.0.1",
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
- ### Write one function. Get a typed API, an MCP server, and LLM tools.
7
+ ### Let AI talk to your API. Natively.
8
8
 
9
9
  [![Downloads](https://img.shields.io/npm/dm/typed-bridge.svg)](https://www.npmjs.com/package/typed-bridge)
10
10
  [![Version](https://img.shields.io/npm/v/typed-bridge.svg)](https://www.npmjs.com/package/typed-bridge)
11
11
  [![License](https://img.shields.io/npm/l/typed-bridge.svg)](https://github.com/neilveil/typed-bridge/blob/main/license.txt)
12
12
 
13
- **Type-safe RPC for humans. Native tools for AI. Zero glue code.**
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
- ## Your backend just became AI-native
19
+ ## Stop maintaining two backends
20
20
 
21
- You already write plain TypeScript functions on your server. Typed Bridge takes those exact functions and hands you three things at once:
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
- 1. A **fully typed client** your frontend calls like local functions.
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
- Same function. Same validation. No second codebase for AI. No hand written tool schemas. No drift.
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
- > Every function you ship is instantly something an agent can call. That is the whole pitch.
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. Write a normal function
70
+ ### 2. Define your contexts (optional)
58
71
 
59
- `bridge/user/index.ts`:
72
+ Name the shapes your middleware injects, so handlers can declare what they expect. `bridge/context.ts`:
60
73
 
61
74
  ```ts
62
- import { z } from 'typed-bridge'
63
- import * as types from './types'
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. Describe it once
80
+ ### 3. Define an entry
71
81
 
72
- `bridge/user/types.ts`:
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({ id: z.number().min(1).describe('Unique user identifier') }),
80
- res: z.object({ id: z.number(), name: z.string(), email: z.string() })
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
- ### 4. Wire it up with `defineBridge`
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': { handler: user.fetch, ...userTypes.fetch }
117
+ 'user.fetch': user.fetch
95
118
  }
96
119
 
97
120
  export default defineBridge(entries)
98
121
  ```
99
122
 
100
- `defineBridge` auto-validates incoming args against your Zod schema. No manual `.parse()` in handlers, ever.
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, an MCP endpoint at `/bridge/mcp`, and an LLM tools endpoint at `/bridge/tools`. From one function.
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 ${MCP_API_KEY}" },
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 let you hide a handler from either one while it stays fully callable over HTTP:
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 `toLLMTools`, tool search, and the `/bridge/tools` endpoint.
243
+ - `llm: false` keeps it out of `getTools` and tool search.
182
244
 
183
245
  ```ts
184
- export const entries = {
185
- 'user.fetch': { handler: user.fetch, ...userTypes.fetch },
186
- 'user.remove': { handler: user.remove, ...userTypes.remove, mcp: false }, // your LLM app can call it, external MCP clients cannot
187
- 'admin.sync': { handler: admin.sync, ...adminTypes.sync, llm: false } // HTTP and MCP only, never an LLM tool
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
- Hidden tools are dropped from discovery and rejected if called by name, so a model cannot reach them even by guessing.
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
- ### Hand every tool to the model
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 { toLLMTools } from 'typed-bridge'
281
+ import { getTools, handleToolCall } from 'typed-bridge'
203
282
 
204
- const tools = toLLMTools(entries, { format: 'openai' })
205
- // Pass `tools` straight into openai.chat.completions.create()
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
- Formats: `openai`, `anthropic`, `json-schema`.
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
- ### Have a giant API? Use meta-tools.
292
+ ### `on_demand` three tools, discovered as needed (default)
211
293
 
212
- For hundreds of endpoints, do not flood the context window. Give the model three tools instead of two hundred:
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
- import { getMetaTools, handleMetaToolCall } from 'typed-bridge'
297
+ const tools = getTools(entries, { toolMode: 'on_demand', format: 'openai' })
298
+ // → tool_search, tool_describe, tool_use
216
299
 
217
- const tools = getMetaTools({ format: 'openai' })
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
- ### Or just hit the REST endpoint
308
+ ### `attach_all` every tool, up front
233
309
 
234
- ```
235
- GET /bridge/tools?format=openai
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: { userId: 1 } }
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
- Broader patterns run first (`*`, then `user.*`, then `user.fetch`). Returned context is merged and passed to the handler.
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 = 0 // Cap MCP/LLM tool results (chars of JSON); 0 = unlimited
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. Set `tbConfig.maxToolOutputChars` to cap the serialized result 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:
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
- tbConfig.maxToolOutputChars = 100_000
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. Create the handler in `bridge/<module>/index.ts`.
321
- 2. Add its Zod schema in `<module>/types.ts`.
322
- 3. Register it in `bridge/index.ts`:
323
- - Flat map for a plain typed API: `export default { 'module.action': module.action }`
324
- - Entry based for AI features: `export const entries = { 'module.action': { handler: module.action, ...moduleTypes.action } }` then `export default defineBridge(entries)`
325
- 4. Add middleware if needed and import it in your server entry.
326
- 5. Regenerate the client.
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