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.
- package/dist/bridge/index.d.ts +1 -11
- package/dist/bridge/index.js +7 -16
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/mcp/index.d.ts +1 -3
- package/dist/mcp/index.js +10 -8
- package/dist/middleware/index.d.ts +15 -0
- package/dist/middleware/index.js +62 -0
- package/dist/tools/index.d.ts +4 -2
- package/dist/tools/index.js +21 -13
- package/package.json +1 -1
- package/readme.md +18 -13
package/dist/bridge/index.d.ts
CHANGED
|
@@ -1,21 +1,11 @@
|
|
|
1
|
-
import { Application
|
|
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;
|
package/dist/bridge/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.createBridge = exports.onShutdown =
|
|
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.
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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,
|
|
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.
|
|
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");
|
package/dist/mcp/index.d.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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.
|
|
37
|
-
|
|
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',
|
|
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,
|
|
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;
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/tools/index.js
CHANGED
|
@@ -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
|
|
250
|
-
// on its result. Every path that actually executes an entry
|
|
251
|
-
// through here, so output is capped exactly
|
|
252
|
-
|
|
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
|
-
|
|
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
|
|
313
|
-
// place output is unbounded. Discovery results (tool_search /
|
|
314
|
-
// deliberately left uncapped so a blanket search
|
|
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.
|
|
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`
|
|
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
|
|
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
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
-
|
|
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'
|