typed-bridge 4.0.1 → 4.0.4

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,21 +1,11 @@
1
- import { Application, Request, Response } from 'express';
1
+ import { Application } from 'express';
2
2
  import { Server } from 'http';
3
- import { MCPGetContext } from '../mcp';
4
3
  import { Bridge, BridgeEntries, ToolMode } from '../tools';
5
4
  interface CreateBridgeOptions {
6
5
  entries?: BridgeEntries;
7
6
  toolMode?: ToolMode;
8
7
  mcp?: boolean | string;
9
- mcpGetContext?: MCPGetContext;
10
8
  }
11
- type Middleware = {
12
- pattern: string;
13
- handler: (req: Request, res: Response) => Promise<{
14
- next?: boolean;
15
- context?: any;
16
- } | void>;
17
- };
18
- export declare const createMiddleware: (pattern: string, handler: Middleware["handler"]) => number;
19
9
  export declare const onShutdown: (fn: () => void) => () => void;
20
10
  export declare const createBridge: (bridge: Bridge, port: number, path?: string, options?: CreateBridgeOptions) => {
21
11
  app: Application;
@@ -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.createBridge = exports.onShutdown = exports.createMiddleware = void 0;
6
+ exports.createBridge = exports.onShutdown = void 0;
7
7
  const chalk_1 = __importDefault(require("chalk"));
8
8
  const compression_1 = __importDefault(require("compression"));
9
9
  const cors_1 = __importDefault(require("cors"));
@@ -11,10 +11,8 @@ const express_1 = __importDefault(require("express"));
11
11
  const path_1 = __importDefault(require("path"));
12
12
  const __1 = require("..");
13
13
  const helpers_1 = require("../helpers");
14
+ const middleware_1 = require("../middleware");
14
15
  const mcp_1 = require("../mcp");
15
- const middlewares = [];
16
- const createMiddleware = (pattern, handler) => middlewares.push({ pattern, handler });
17
- exports.createMiddleware = createMiddleware;
18
16
  let shutdownCallback = () => { };
19
17
  const onShutdown = (fn) => (shutdownCallback = fn);
20
18
  exports.onShutdown = onShutdown;
@@ -88,7 +86,7 @@ const createBridge = (bridge, port, path = '/bridge', options) => {
88
86
  // MCP endpoint
89
87
  if (options?.mcp && options?.entries) {
90
88
  const mcpPath = typeof options.mcp === 'string' ? options.mcp : path_1.default.join(path, 'mcp');
91
- (0, mcp_1.mountMCP)(app, bridge, options.entries, mcpPath, options.mcpGetContext, options.toolMode || 'on_demand');
89
+ (0, mcp_1.mountMCP)(app, bridge, options.entries, mcpPath, options.toolMode || 'on_demand');
92
90
  }
93
91
  app.use(path, bridgeHandler(bridge));
94
92
  const server = app.listen(port, () => (0, helpers_1.printStartLogs)(port));
@@ -123,17 +121,10 @@ const bridgeHandler = (bridge) => async (req, res) => {
123
121
  console.error(error);
124
122
  return res.status(404).json({ error });
125
123
  }
126
- context = {};
127
- const matchingMiddlewares = middlewares
128
- .filter(m => (0, helpers_1.matchesPattern)(path, m.pattern))
129
- .sort((a, b) => (0, helpers_1.getPatternSpecificity)(a.pattern) - (0, helpers_1.getPatternSpecificity)(b.pattern));
130
- for (const middleware of matchingMiddlewares) {
131
- const result = await middleware.handler(req, res);
132
- if (result?.next === false)
133
- return;
134
- if (result?.context)
135
- context = { ...context, ...result.context };
136
- }
124
+ const { blocked, context: middlewareContext } = await (0, middleware_1.runMiddlewares)(path, req, res);
125
+ if (blocked)
126
+ return;
127
+ context = middlewareContext;
137
128
  res.json((await serverFunction(args, context)) || {});
138
129
  }
139
130
  catch (error) {
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export { Application, default as express, Express, NextFunction, Request, Response, Router } from 'express';
2
- export { createBridge, createMiddleware, onShutdown } from './bridge';
2
+ export { createBridge, onShutdown } from './bridge';
3
3
  export { config as tbConfig } from './config';
4
+ export { createMiddleware } from './middleware';
5
+ export type { Middleware } from './middleware';
4
6
  export { mountMCP } from './mcp';
5
- export type { MCPGetContext } from './mcp';
6
7
  export { defineBridge, defineEntry, getTools, handleToolCall, isToolMode, TOOL_MODES } from './tools';
7
8
  export type { Bridge, BridgeEntries, BridgeEntry, GetToolsOptions, HandleToolCallOptions, LLMToolFormat, ToolCall, ToolMode, ToolSurface } from './tools';
8
9
  export { z } from 'zod';
package/dist/index.js CHANGED
@@ -3,16 +3,17 @@ 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.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;
6
+ exports.z = exports.TOOL_MODES = exports.isToolMode = exports.handleToolCall = exports.getTools = exports.defineEntry = exports.defineBridge = exports.mountMCP = exports.createMiddleware = exports.tbConfig = exports.onShutdown = 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; } });
10
10
  var bridge_1 = require("./bridge");
11
11
  Object.defineProperty(exports, "createBridge", { enumerable: true, get: function () { return bridge_1.createBridge; } });
12
- Object.defineProperty(exports, "createMiddleware", { enumerable: true, get: function () { return bridge_1.createMiddleware; } });
13
12
  Object.defineProperty(exports, "onShutdown", { enumerable: true, get: function () { return bridge_1.onShutdown; } });
14
13
  var config_1 = require("./config");
15
14
  Object.defineProperty(exports, "tbConfig", { enumerable: true, get: function () { return config_1.config; } });
15
+ var middleware_1 = require("./middleware");
16
+ Object.defineProperty(exports, "createMiddleware", { enumerable: true, get: function () { return middleware_1.createMiddleware; } });
16
17
  var mcp_1 = require("./mcp");
17
18
  Object.defineProperty(exports, "mountMCP", { enumerable: true, get: function () { return mcp_1.mountMCP; } });
18
19
  var tools_1 = require("./tools");
@@ -1,5 +1,3 @@
1
- import { IncomingHttpHeaders } from 'node:http';
2
1
  import { Application } from 'express';
3
2
  import { Bridge, BridgeEntries, ToolMode } from '../tools';
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, toolMode?: ToolMode): void;
3
+ export declare function mountMCP(app: Application, bridge: Bridge, entries: BridgeEntries, path?: string, toolMode?: ToolMode): void;
package/dist/mcp/index.js CHANGED
@@ -11,7 +11,7 @@ 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
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') {
14
+ function createMCPServer(bridge, entries, headersRef, toolMode = 'on_demand') {
15
15
  const server = new index_js_1.Server({ name: 'typed-bridge', version: '1.0.0' }, { capabilities: { tools: {} } });
16
16
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
17
17
  // on_demand: hand the model the 3 meta-tools and let it discover the rest
@@ -30,21 +30,23 @@ function createMCPServer(bridge, entries, headersRef, getContext, toolMode = 'on
30
30
  server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
31
31
  const { name, arguments: args } = request.params;
32
32
  try {
33
- // Derive per-request context from forwarded headers
34
- const context = getContext ? await getContext(headersRef.current) : undefined;
35
33
  // 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' });
34
+ // entry directly. The client's forwarded headers drive the middleware chain, which
35
+ // builds the handler context. Visibility (`mcp: false`) and the output limit are
36
+ // enforced inside.
37
+ const result = await (0, tools_1.handleToolCall)(bridge, entries, { name, arguments: args || {} }, { surface: 'mcp', headers: headersRef.current });
38
38
  return { content: [{ type: 'text', text: JSON.stringify(result) ?? '' }] };
39
39
  }
40
40
  catch (error) {
41
+ // Surface the error message as JSON so the model knows *why* a call was denied
42
+ // (e.g. an auth middleware's message) instead of seeing an opaque stop.
41
43
  const message = error instanceof Error ? error.message : String(error);
42
- return { content: [{ type: 'text', text: message }], isError: true };
44
+ return { content: [{ type: 'text', text: JSON.stringify({ error: message }) }], isError: true };
43
45
  }
44
46
  });
45
47
  return server;
46
48
  }
47
- function mountMCP(app, bridge, entries, path = '/mcp', getContext, toolMode = 'on_demand') {
49
+ function mountMCP(app, bridge, entries, path = '/mcp', toolMode = 'on_demand') {
48
50
  const sessions = new Map();
49
51
  // Evict sessions idle for longer than SESSION_TTL_MS
50
52
  const sweepInterval = setInterval(() => {
@@ -68,7 +70,7 @@ function mountMCP(app, bridge, entries, path = '/mcp', getContext, toolMode = 'o
68
70
  }
69
71
  else if (!sessionId && req.method === 'POST') {
70
72
  const headersRef = { current: req.headers };
71
- const server = createMCPServer(bridge, entries, headersRef, getContext, toolMode);
73
+ const server = createMCPServer(bridge, entries, headersRef, toolMode);
72
74
  transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
73
75
  sessionIdGenerator: () => (0, node_crypto_1.randomUUID)(),
74
76
  onsessioninitialized: (id) => {
@@ -0,0 +1,15 @@
1
+ import { IncomingHttpHeaders } from 'node:http';
2
+ import { Request, Response } from 'express';
3
+ export type Middleware = {
4
+ pattern: string;
5
+ handler: (req: Request, res: Response) => Promise<{
6
+ next?: boolean;
7
+ context?: any;
8
+ } | void>;
9
+ };
10
+ export declare const createMiddleware: (pattern: string, handler: Middleware["handler"]) => number;
11
+ export declare const runMiddlewares: (name: string, req: Request, res: Response) => Promise<{
12
+ blocked: boolean;
13
+ context: any;
14
+ }>;
15
+ export declare const runMiddlewaresForTool: (name: string, headers?: IncomingHttpHeaders) => Promise<any>;
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runMiddlewaresForTool = exports.runMiddlewares = exports.createMiddleware = void 0;
4
+ const helpers_1 = require("../helpers");
5
+ const middlewares = [];
6
+ const createMiddleware = (pattern, handler) => middlewares.push({ pattern, handler });
7
+ exports.createMiddleware = createMiddleware;
8
+ // Run every middleware whose pattern matches `name`, ordered broad → specific. Each
9
+ // middleware's returned context is shallow-merged on top of the previous (so a more
10
+ // specific middleware wins on a shared key). Returns the merged context, or signals a
11
+ // block when a middleware returns `{ next: false }`. Surface-agnostic: the HTTP path
12
+ // passes the real Express req/res; tool surfaces pass a synthetic req + throwing res.
13
+ const runMiddlewares = async (name, req, res) => {
14
+ let context = {};
15
+ const matching = middlewares
16
+ .filter(m => (0, helpers_1.matchesPattern)(name, m.pattern))
17
+ .sort((a, b) => (0, helpers_1.getPatternSpecificity)(a.pattern) - (0, helpers_1.getPatternSpecificity)(b.pattern));
18
+ for (const middleware of matching) {
19
+ const result = await middleware.handler(req, res);
20
+ if (result?.next === false)
21
+ return { blocked: true, context };
22
+ if (result?.context)
23
+ context = { ...context, ...result.context };
24
+ }
25
+ return { blocked: false, context };
26
+ };
27
+ exports.runMiddlewares = runMiddlewares;
28
+ // A stand-in for the Express response on tool surfaces (MCP / LLM), which have no real
29
+ // HTTP response. A middleware that blocks with `res.status(code).send(msg)` (or `.json`)
30
+ // throws a plain Error carrying that message — so the surface can report *why* access was
31
+ // denied (the message) back to the model, instead of a meaningless silent stop. The status
32
+ // code is irrelevant off HTTP, so it's ignored.
33
+ const createToolRes = () => {
34
+ const fail = (body) => {
35
+ throw new Error(typeof body === 'string' ? body : JSON.stringify(body));
36
+ };
37
+ const res = {
38
+ status: () => res,
39
+ send: fail,
40
+ json: fail,
41
+ setHeader: () => res,
42
+ set: () => res
43
+ };
44
+ return res;
45
+ };
46
+ // Run the middleware chain for a tool call (MCP / LLM). The synthetic request carries the
47
+ // forwarded `headers` and the matched entry name as `path` — so path-based middlewares
48
+ // (e.g. `req.path.split('/').pop()`) work identically to HTTP. There is no request body:
49
+ // MCP can forward nothing else, so middlewares must rely on headers/path alone.
50
+ // Blocking via `res.status().send()` throws (caught by the caller and returned as JSON);
51
+ // a bare `{ next: false }` with no response throws a generic error so access is still denied.
52
+ const runMiddlewaresForTool = async (name, headers) => {
53
+ // `path` mirrors HTTP's last-segment convention: on HTTP `req.path` is `/bridge/user.fetch`
54
+ // and handlers do `.split('/').pop()`; here it's the bare entry name, which pops to itself.
55
+ const req = { headers: headers || {}, path: name };
56
+ const res = createToolRes();
57
+ const { blocked, context } = await (0, exports.runMiddlewares)(name, req, res);
58
+ if (blocked)
59
+ throw new Error('Access denied');
60
+ return context;
61
+ };
62
+ exports.runMiddlewaresForTool = runMiddlewaresForTool;
@@ -1,3 +1,4 @@
1
+ import { IncomingHttpHeaders } from 'node:http';
1
2
  import { z } from 'zod';
2
3
  export type BridgeEntry = {
3
4
  handler: (...args: any[]) => Promise<any>;
@@ -36,6 +37,7 @@ export interface GetToolsOptions {
36
37
  export interface HandleToolCallOptions {
37
38
  context?: unknown;
38
39
  surface?: ToolSurface;
40
+ headers?: IncomingHttpHeaders;
39
41
  }
40
42
  export interface ToolCall {
41
43
  name: string;
@@ -173,8 +175,8 @@ export declare function toolDescribe(entries: BridgeEntries, name: string, surfa
173
175
  * query. Returns the serialized JSON (callers may discard it; it's used only to measure).
174
176
  */
175
177
  export declare function enforceToolOutputLimit(result: unknown): string;
176
- export declare function toolUse(bridge: Bridge, name: string, args: unknown, context?: unknown): Promise<any>;
177
- export declare function handleMetaToolCall(bridge: Bridge, entries: BridgeEntries, toolCall: ToolCall, context?: unknown, surface?: ToolSurface): Promise<unknown>;
178
+ export declare function toolUse(bridge: Bridge, name: string, args: unknown, context?: unknown, headers?: IncomingHttpHeaders): Promise<any>;
179
+ export declare function handleMetaToolCall(bridge: Bridge, entries: BridgeEntries, toolCall: ToolCall, context?: unknown, surface?: ToolSurface, headers?: IncomingHttpHeaders): Promise<unknown>;
178
180
  export declare const META_TOOL_NAMES: readonly ["tool_search", "tool_describe", "tool_use"];
179
181
  export declare function isMetaToolName(name: string): boolean;
180
182
  /**
@@ -20,6 +20,7 @@ exports.getTools = getTools;
20
20
  exports.handleToolCall = handleToolCall;
21
21
  const zod_1 = require("zod");
22
22
  const config_1 = require("../config");
23
+ const middleware_1 = require("../middleware");
23
24
  exports.LLM_TOOL_FORMATS = ['openai', 'anthropic', 'json-schema'];
24
25
  function isLLMToolFormat(value) {
25
26
  return exports.LLM_TOOL_FORMATS.includes(value);
@@ -246,18 +247,24 @@ function enforceToolOutputLimit(result) {
246
247
  }
247
248
  return serialized;
248
249
  }
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.
252
- async function toolUse(bridge, name, args, context) {
250
+ // The single execution boundary: runs the middleware chain, then the handler, and enforces
251
+ // tbConfig.maxToolOutputChars on its result. Every path that actually executes an entry
252
+ // (tool_use, direct call) goes through here, so middleware runs and output is capped exactly
253
+ // once. Discovery (search/describe) never reaches here, so listing tools needs no auth.
254
+ async function toolUse(bridge, name, args, context, headers) {
253
255
  const handler = bridge[name];
254
256
  if (!handler)
255
257
  throw new Error(`Tool not found: ${name}`);
256
- const result = await handler(args, context);
258
+ // Run the same pattern-matched middleware chain as HTTP. The caller-supplied context is
259
+ // the base; middleware-derived context is merged on top (more authoritative, header-derived).
260
+ const middlewareContext = await (0, middleware_1.runMiddlewaresForTool)(name, headers);
261
+ const base = context && typeof context === 'object' ? context : {};
262
+ const mergedContext = { ...base, ...middlewareContext };
263
+ const result = await handler(args, mergedContext);
257
264
  enforceToolOutputLimit(result);
258
265
  return result;
259
266
  }
260
- async function handleMetaToolCall(bridge, entries, toolCall, context, surface = 'llm') {
267
+ async function handleMetaToolCall(bridge, entries, toolCall, context, surface = 'llm', headers) {
261
268
  switch (toolCall.name) {
262
269
  case 'tool_search':
263
270
  return toolSearch(entries, toolCall.arguments?.query, surface);
@@ -267,7 +274,7 @@ async function handleMetaToolCall(bridge, entries, toolCall, context, surface =
267
274
  const args = toolCall.arguments;
268
275
  if (!isEntryVisible(entries[args.name], surface))
269
276
  throw new Error(`Tool not found: ${args.name}`);
270
- return toolUse(bridge, args.name, args.arguments || {}, context);
277
+ return toolUse(bridge, args.name, args.arguments || {}, context, headers);
271
278
  }
272
279
  default:
273
280
  throw new Error(`Unknown meta-tool: ${toolCall.name}. Expected "tool_search", "tool_describe", or "tool_use".`);
@@ -303,15 +310,16 @@ function getTools(entries, options) {
303
310
  async function handleToolCall(bridge, entries, toolCall, options) {
304
311
  const surface = options?.surface || 'llm';
305
312
  const context = options?.context;
313
+ const headers = options?.headers;
306
314
  if (isMetaToolName(toolCall.name)) {
307
- return handleMetaToolCall(bridge, entries, toolCall, context, surface);
315
+ return handleMetaToolCall(bridge, entries, toolCall, context, surface, headers);
308
316
  }
309
317
  // Direct entry call (attach_all mode): respect per-surface visibility.
310
318
  if (!isEntryVisible(entries[toolCall.name], surface))
311
319
  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);
320
+ // `toolUse` runs the middleware chain and enforces tbConfig.maxToolOutputChars on the
321
+ // executed result — the only place output is unbounded. Discovery results (tool_search /
322
+ // tool_describe) are deliberately left uncapped, and skip middleware, so a blanket search
323
+ // can never deadlock the model's own recovery path.
324
+ return toolUse(bridge, toolCall.name, toolCall.arguments || {}, context, headers);
317
325
  }
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.0.1",
4
+ "version": "4.0.4",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "author": "neilveil",
package/readme.md CHANGED
@@ -171,7 +171,7 @@ typedBridgeConfig.headers['X-Tenant'] = 'acme'
171
171
  delete typedBridgeConfig.headers['Authorization']
172
172
  ```
173
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`:
174
+ These headers are exactly what your `createMiddleware` chain reads on the server — and that same chain runs on HTTP, MCP, and LLM tool calls — so one token drives auth across every surface. You can also tap every response with `typedBridgeConfig.onResponse`:
175
175
 
176
176
  ```ts
177
177
  typedBridgeConfig.onResponse = res => {
@@ -204,24 +204,26 @@ Point any MCP client at it:
204
204
  }
205
205
  ```
206
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.)
207
+ Typed Bridge is a **remote (HTTP) MCP server**, so the client just needs the `url` and any auth `headers` — those headers flow straight into your middleware chain. (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
208
 
209
209
  ### Auth that actually works
210
210
 
211
- MCP requests skip your normal middleware, so you derive context straight from headers:
211
+ Your `createMiddleware` chain runs on MCP tool calls automatically the client's forwarded headers are fed through the exact same pattern-matched middleware that guards HTTP. No separate config, no duplicated logic:
212
212
 
213
213
  ```ts
214
- createBridge(bridge, 8080, '/bridge', {
215
- entries,
216
- mcp: true,
217
- mcpGetContext: async headers => {
218
- const user = await verifyToken(headers['authorization'])
219
- return { userId: user.id, role: user.role }
214
+ createMiddleware('user.*', async (req, res) => {
215
+ const user = await verifyToken(req.headers['authorization'])
216
+ if (!user) {
217
+ res.status(401).json({ error: 'Unauthorized' })
218
+ return { next: false }
220
219
  }
220
+ return { context: { userId: user.id, role: user.role } }
221
221
  })
222
+
223
+ createBridge(bridge, 8080, '/bridge', { entries, mcp: true })
222
224
  ```
223
225
 
224
- The returned context lands in every handler as the second argument, exactly like middleware context. Same security model for humans and agents.
226
+ The resolved context lands in every handler as the second argument, identically for a browser request and an agent's tool call — one security model for humans and AI. When a middleware blocks a tool call, the model receives the **message as JSON** (e.g. `{ "error": "Unauthorized" }`) so it knows *why* it was denied. On tool surfaces a middleware sees the request **headers** and the matched entry name as **`req.path`** (so path-based key derivation like `req.path.split('/').pop()` works everywhere) — but no request **body**, since MCP can forward nothing else.
225
227
 
226
228
  ### Control how many tools the client sees
227
229
 
@@ -283,12 +285,15 @@ import { getTools, handleToolCall } from 'typed-bridge'
283
285
  // Build the tool list (openai | anthropic | json-schema). Omit toolMode to use the default, on_demand.
284
286
  const tools = getTools(entries, { toolMode: 'on_demand', format: 'openai' })
285
287
 
286
- // Execute whatever the model called — same call for both modes
287
- const result = await handleToolCall(bridge, entries, toolCall, { context })
288
+ // Execute whatever the model called — same call for both modes.
289
+ // Forward the incoming request headers so your middleware chain (auth, context) runs here too.
290
+ const result = await handleToolCall(bridge, entries, toolCall, { headers: req.headers })
288
291
  ```
289
292
 
290
293
  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.
291
294
 
295
+ Your `createMiddleware` chain runs on these tool calls just like it does on HTTP and MCP. Unlike MCP — where the server already holds the client's headers — an LLM loop is your own code, so you pass the request `headers` into `handleToolCall` to drive the chain. The middleware-derived context is merged on top of any `context` you also pass. If a needed header is missing, the matching middleware denies the call (the model receives a JSON `{ error }`), exactly as it would over HTTP.
296
+
292
297
  ### `on_demand` — three tools, discovered as needed (default)
293
298
 
294
299
  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:
@@ -367,7 +372,7 @@ GraphQL is unmatched when clients need to shape their own queries across a compl
367
372
 
368
373
  ## Middleware when you need it
369
374
 
370
- Pattern based middleware runs before handlers and can inject context:
375
+ Pattern based middleware runs before handlers and can inject context. The **same chain runs on all three surfaces** — HTTP requests, MCP tool calls, and LLM tool calls — matched by entry name (e.g. `user.fetch` matches `user.*`). Over HTTP a middleware blocks by writing to `res`; on tool calls that same `res.status(code).send(msg)` is reported back to the model as JSON (`{ error }`) — the message reaches the model, the status code (irrelevant off HTTP) is dropped. On tool surfaces a middleware sees `req.headers` and `req.path` (the matched entry name) but **no `req.body`** and no real `res` for side effects (cookies, custom headers are no-ops), so write auth/context logic against `req.headers` / `req.path`:
371
376
 
372
377
  ```ts
373
378
  import { createMiddleware } from 'typed-bridge'