typed-bridge 2.1.4 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/readme.md CHANGED
@@ -1,363 +1,332 @@
1
- # Typed Bridge - Strictly Typed Server Functions for TypeScript
1
+ <div align="center">
2
+
3
+ <img src="assets/default.png" alt="Typed Bridge" width="380" />
4
+
5
+ # Typed Bridge
6
+
7
+ ### Write one function. Get a typed API, an MCP server, and LLM tools.
2
8
 
3
9
  [![Downloads](https://img.shields.io/npm/dm/typed-bridge.svg)](https://www.npmjs.com/package/typed-bridge)
4
10
  [![Version](https://img.shields.io/npm/v/typed-bridge.svg)](https://www.npmjs.com/package/typed-bridge)
5
11
  [![License](https://img.shields.io/npm/l/typed-bridge.svg)](https://github.com/neilveil/typed-bridge/blob/main/license.txt)
6
12
 
7
- **End-to-End Type Safety · Framework Agnostic · Zero Config**
13
+ **Type-safe RPC for humans. Native tools for AI. Zero glue code.**
8
14
 
9
- Typed Bridge lets you define **strictly typed server functions** and auto-generates a typed client for your frontend. Call server functions like local functions, with full type safety from backend to frontend. No routers, no resolvers, no schema stitching. Just plain TypeScript functions.
15
+ </div>
10
16
 
11
17
  ---
12
18
 
13
- ## Quick Start
19
+ ## Your backend just became AI-native
14
20
 
15
- ### 1. Install
21
+ You already write plain TypeScript functions on your server. Typed Bridge takes those exact functions and hands you three things at once:
16
22
 
17
- ```bash
18
- npm i typed-bridge
19
- ```
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.
20
26
 
21
- ### 2. Setup Server
27
+ Same function. Same validation. No second codebase for AI. No hand written tool schemas. No drift.
22
28
 
23
- Create `server.ts`:
29
+ > Every function you ship is instantly something an agent can call. That is the whole pitch.
24
30
 
25
- ```typescript
26
- import { createBridge } from 'typed-bridge'
27
- import bridge from './bridge'
31
+ ---
28
32
 
29
- createBridge(bridge, 8080, '/bridge')
33
+ ## One function, three superpowers
34
+
35
+ ```mermaid
36
+ flowchart LR
37
+ A[Your TypeScript functions] --> B[defineBridge]
38
+ B --> C[Typed client for your frontend]
39
+ B --> D[MCP server for AI tools]
40
+ B --> E[LLM tool definitions]
41
+ D --> F[Cursor, Claude, Windsurf]
42
+ E --> G[OpenAI, Anthropic, your agents]
30
43
  ```
31
44
 
32
- ### 3. Create Bridge File
45
+ 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.
33
46
 
34
- Define routes in `bridge/index.ts`:
47
+ ---
35
48
 
36
- ```typescript
37
- import * as user from './user'
49
+ ## Quick start
38
50
 
39
- export default {
40
- 'user.fetch': user.fetch,
41
- 'user.update': user.update,
42
- 'user.fetchAll': user.fetchAll
43
- }
44
- ```
51
+ ### 1. Install
45
52
 
46
- ### 4. Declare Functions
53
+ ```bash
54
+ npm i typed-bridge
55
+ ```
47
56
 
48
- These are just normal async functions. The first argument is what the client sends, the return type is what the client receives. That's it.
57
+ ### 2. Write a normal function
49
58
 
50
59
  `bridge/user/index.ts`:
51
60
 
52
- ```typescript
53
- interface User {
54
- id: number
55
- name: string
56
- email: string
57
- }
61
+ ```ts
62
+ import { z } from 'typed-bridge'
63
+ import * as types from './types'
58
64
 
59
- // Fetch a single user by id
60
- export const fetch = async (args: { id: number }): Promise<User> => {
65
+ export const fetch = async (args: z.infer<typeof types.fetch.args>) => {
61
66
  return db.users.findById(args.id)
62
67
  }
63
-
64
- // Update user fields
65
- export const update = async (args: { id: number; name?: string; email?: string }): Promise<User> => {
66
- return db.users.update(args.id, args)
67
- }
68
-
69
- // List all users
70
- export const fetchAll = async (): Promise<User[]> => {
71
- return db.users.findAll()
72
- }
73
68
  ```
74
69
 
75
- ### 5. Generate Typed Client
70
+ ### 3. Describe it once
76
71
 
77
- Add the generation script to `package.json`:
72
+ `bridge/user/types.ts`:
78
73
 
79
- ```json
80
- {
81
- "scripts": {
82
- "gen:typed-bridge-client": "typed-bridge gen-typed-bridge-client --src ./src/bridge/index.ts --dest ./bridge.ts"
83
- }
84
- }
85
- ```
86
-
87
- Run it:
74
+ ```ts
75
+ import { z } from 'typed-bridge'
88
76
 
89
- ```bash
90
- npm run gen:typed-bridge-client
77
+ export const fetch = {
78
+ 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
+ }
91
82
  ```
92
83
 
93
- ### 6. Use in Frontend
84
+ ### 4. Wire it up with `defineBridge`
94
85
 
95
- Import the generated `bridge.ts` file in your frontend:
86
+ `bridge/index.ts`:
96
87
 
97
- ```typescript
98
- import bridge, { typedBridgeConfig } from './bridge'
88
+ ```ts
89
+ import { defineBridge } from 'typed-bridge'
90
+ import * as user from './user'
91
+ import * as userTypes from './user/types'
99
92
 
100
- typedBridgeConfig.host = 'http://localhost:8080/bridge'
101
- typedBridgeConfig.headers = {
102
- 'Content-Type': 'application/json',
103
- Authorization: 'Bearer 123'
104
- }
105
- typedBridgeConfig.onResponse = res => {
106
- // Custom response handling
93
+ export const entries = {
94
+ 'user.fetch': { handler: user.fetch, ...userTypes.fetch }
107
95
  }
108
96
 
109
- const user = await bridge['user.fetch']({ id: 1 })
97
+ export default defineBridge(entries)
110
98
  ```
111
99
 
112
- ---
113
-
114
- ## Keeping the Client in Sync
100
+ `defineBridge` auto-validates incoming args against your Zod schema. No manual `.parse()` in handlers, ever.
115
101
 
116
- The generated `bridge.ts` file lives in your backend and needs to reach your frontend. Here are common approaches:
102
+ ### 5. Boot the server, AI included
117
103
 
118
- **Monorepo**: Import the file directly across packages. Simplest if your repos are co-located.
104
+ ```ts
105
+ import { createBridge } from 'typed-bridge'
106
+ import bridge, { entries } from './bridge'
119
107
 
120
- **Copy script**: Add a build step that copies the file: `cp ../backend/bridge.ts ./src/bridge.ts`
108
+ createBridge(bridge, 8080, '/bridge', { entries, mcp: true })
109
+ ```
121
110
 
122
- **Serve and fetch**: Host the generated file via Express static and use [clone-kit](https://www.npmjs.com/package/clone-kit) to pull it in your frontend build. [Full guide](./docs/auto-bridge-sync.md)
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.
123
112
 
124
113
  ---
125
114
 
126
- ## Middleware
115
+ ## Superpower 1: The typed client
127
116
 
128
- Typed Bridge provides middleware that runs before bridge handlers.
117
+ Generate a standalone client file for your frontend:
129
118
 
130
- ```ts
131
- createMiddleware('user.fetch', async (req, res) => {
132
- console.log('Middleware for user.fetch')
133
- })
119
+ ```bash
120
+ typed-bridge gen-typed-bridge-client --src ./src/bridge/index.ts --dest ./bridge.ts
134
121
  ```
135
122
 
136
- Use glob patterns to match multiple routes:
123
+ Call your backend like it lives in the same file:
137
124
 
138
125
  ```ts
139
- createMiddleware('user.*', async (req, res) => {
140
- console.log('Middleware for all user routes')
141
- })
142
- ```
126
+ import bridge, { typedBridgeConfig } from './bridge'
143
127
 
144
- Middlewares execute in order of specificity, broader patterns run first:
128
+ typedBridgeConfig.host = 'http://localhost:8080/bridge'
145
129
 
146
- ```
147
- * → runs first (matches everything)
148
- user.* → runs second
149
- user.fetch → runs last (most specific)
130
+ const user = await bridge['user.fetch']({ id: 1 })
150
131
  ```
151
132
 
152
- ### Context
133
+ 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.
153
134
 
154
- Middlewares can return a `context` object that gets merged and passed to the handler:
155
-
156
- ```ts
157
- createMiddleware('user.*', async (req, res) => {
158
- return {
159
- context: {
160
- a: 1
161
- }
162
- }
163
- })
135
+ ---
164
136
 
165
- createMiddleware('user.fetch', async (req, res) => {
166
- return {
167
- context: {
168
- b: 2
169
- }
170
- }
171
- })
172
- ```
137
+ ## Superpower 2: The MCP server (the headline act)
173
138
 
174
- The handler receives the merged context:
139
+ Flip one flag and your backend becomes a [Model Context Protocol](https://modelcontextprotocol.io) server. AI tools connect to it and call your real functions, with your real validation.
175
140
 
176
141
  ```ts
177
- type Context = {
178
- a: number
179
- b: number
180
- }
142
+ createBridge(bridge, 8080, '/bridge', { entries, mcp: true })
143
+ ```
181
144
 
182
- export const fetch = async (
183
- args: { id: number },
184
- context: Context
185
- ): Promise<{ id: number; name: string } | undefined> => {
186
- console.log(context) // { a: 1, b: 2 }
187
- return users.find(user => user.id === args.id)
145
+ Point any MCP client at it:
146
+
147
+ ```json
148
+ {
149
+ "mcpServers": {
150
+ "my-backend": {
151
+ "url": "http://localhost:8080/bridge/mcp",
152
+ "headers": { "Authorization": "Bearer ${MCP_API_KEY}" },
153
+ "env": { "MCP_API_KEY": "your-api-key" }
154
+ }
155
+ }
188
156
  }
189
157
  ```
190
158
 
191
- ### Request Validation
159
+ ### Auth that actually works
192
160
 
193
- Throw errors or send custom responses to block requests:
161
+ MCP requests skip your normal middleware, so you derive context straight from headers:
194
162
 
195
163
  ```ts
196
- createMiddleware('user.*', async (req, res) => {
197
- if (req.headers.authorization !== 'Bearer 123') {
198
- throw new Error('Unauthorized')
164
+ createBridge(bridge, 8080, '/bridge', {
165
+ entries,
166
+ mcp: true,
167
+ mcpGetContext: async headers => {
168
+ const user = await verifyToken(headers['authorization'])
169
+ return { userId: user.id, role: user.role }
199
170
  }
200
171
  })
201
172
  ```
202
173
 
203
- For custom status codes:
174
+ The returned context lands in every handler as the second argument, exactly like middleware context. Same security model for humans and agents.
204
175
 
205
- ```ts
206
- createMiddleware('user.*', async (req, res) => {
207
- if (req.headers.authorization !== 'Bearer 123') {
208
- res.status(401).send('Unauthorized')
176
+ ### Choose what each surface can touch
209
177
 
210
- // Required to stop processing, otherwise the next middleware or handler will run
211
- return { next: false }
212
- }
213
- })
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:
179
+
180
+ - `mcp: false` keeps a handler off the MCP server.
181
+ - `llm: false` keeps it out of `toLLMTools`, tool search, and the `/bridge/tools` endpoint.
182
+
183
+ ```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
+ }
214
189
  ```
215
190
 
216
- ---
191
+ Hidden tools are dropped from discovery and rejected if called by name, so a model cannot reach them even by guessing.
217
192
 
218
- ## Zod Validation
193
+ ---
219
194
 
220
- Typed Bridge ships with Zod and re-exports it as `z`. Define schemas and use them in handlers:
195
+ ## Superpower 3: LLM tool calling
221
196
 
222
- ### 1. Declare Schemas
197
+ Skip MCP and talk to models directly. Typed Bridge speaks OpenAI, Anthropic, and raw JSON Schema.
223
198
 
224
- `types.ts`:
199
+ ### Hand every tool to the model
225
200
 
226
201
  ```ts
227
- import { z } from 'typed-bridge'
202
+ import { toLLMTools } from 'typed-bridge'
228
203
 
229
- export const fetch = {
230
- args: z.object({
231
- id: z.number().min(1)
232
- }),
233
- res: z.object({
234
- id: z.number(),
235
- name: z.string()
236
- })
237
- }
204
+ const tools = toLLMTools(entries, { format: 'openai' })
205
+ // Pass `tools` straight into openai.chat.completions.create()
238
206
  ```
239
207
 
240
- ### 2. Use in Handler
208
+ Formats: `openai`, `anthropic`, `json-schema`.
209
+
210
+ ### Have a giant API? Use meta-tools.
211
+
212
+ For hundreds of endpoints, do not flood the context window. Give the model three tools instead of two hundred:
241
213
 
242
214
  ```ts
243
- import { z } from 'typed-bridge'
244
- import * as types from './types'
215
+ import { getMetaTools, handleMetaToolCall } from 'typed-bridge'
245
216
 
246
- export const fetch = async (
247
- args: z.infer<typeof types.fetch.args>,
248
- context: { id: number }
249
- ): Promise<z.infer<typeof types.fetch.res>> => {
250
- args = types.fetch.args.parse(args)
217
+ const tools = getMetaTools({ format: 'openai' })
251
218
 
252
- const user = users.find(user => user.id === args.id)
253
- if (!user) {
254
- throw new Error(`User with ID ${args.id} not found`)
255
- }
219
+ // The model discovers tools, inspects their schema, then calls them:
220
+ // 1. tool_search({ query: "user" }) [{ name: "user.fetch", description: "..." }, ...]
221
+ // 2. tool_describe({ name: "user.fetch" }) → { name, description, args, response }
222
+ // 3. tool_use({ name: "user.fetch", arguments: { id: 1 } }) → { id: 1, name: "Alice" }
256
223
 
257
- return user
258
- }
224
+ const result = await handleMetaToolCall(bridge, entries, {
225
+ name: 'tool_use',
226
+ arguments: { name: 'user.fetch', arguments: { id: 1 } }
227
+ })
259
228
  ```
260
229
 
261
- The generated client automatically resolves Zod types to plain TypeScript. Your frontend never depends on Zod.
230
+ 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.
262
231
 
263
- ---
232
+ ### Or just hit the REST endpoint
264
233
 
265
- ## Configuration
234
+ ```
235
+ GET /bridge/tools?format=openai
236
+ ```
266
237
 
267
- ```typescript
268
- import { tbConfig } from 'typed-bridge'
238
+ ---
269
239
 
270
- tbConfig.logs.request = true
271
- tbConfig.logs.response = true
272
- tbConfig.logs.error = true
240
+ ## Why Typed Bridge
273
241
 
274
- tbConfig.logs.argsOnError = true
275
- tbConfig.logs.contextOnError = true
242
+ ### vs writing AI tools by hand
276
243
 
277
- tbConfig.responseDelay = 0 // Artificial delay in ms (useful for testing loading states)
278
- ```
244
+ | | **Typed Bridge** | **DIY tool calling** |
245
+ | ------------------------ | ----------------------------------------- | -------------------------------------------- |
246
+ | Tool schemas | Derived from your Zod types | Hand written and kept in sync manually |
247
+ | MCP server | One flag | A separate service to build and maintain |
248
+ | Validation | Shared with your API | Re-implemented for the AI path |
249
+ | Drift between code and AI | Impossible, single source | Constant, two sources |
279
250
 
280
- ---
251
+ ### vs tRPC
281
252
 
282
- ## Extending the Server
253
+ | | **Typed Bridge** | **tRPC** |
254
+ | ---------------------- | --------------------------------------------- | ----------------------------------- |
255
+ | Setup | Plain functions, generate a client, done | Routers, procedures, adapters |
256
+ | Monorepo required | No, the client is a standalone file | Practically yes for type inference |
257
+ | Frontend framework | Any | React first, adapters for others |
258
+ | AI tooling | Built in (MCP and LLM) | Not included |
283
259
 
284
- `createBridge` returns the underlying Express `app` and `server` instances, so you can add custom routes, serve static files, or attach any Express middleware:
260
+ ### vs GraphQL
261
+
262
+ | | **Typed Bridge** | **GraphQL** |
263
+ | ------------------ | ----------------------------------------- | -------------------------------------------- |
264
+ | Setup | Define functions, generate client | Schema, resolvers, codegen |
265
+ | Type safety | Automatic from signatures | Requires a codegen toolchain |
266
+ | Learning curve | Minimal, plain TypeScript | SDL, resolvers, fragments, queries |
267
+ | AI tooling | Built in | Roll your own |
285
268
 
286
- ```typescript
287
- import { createBridge, onShutdown } from 'typed-bridge'
288
- import path from 'path'
289
- import bridge from './bridge'
269
+ ---
290
270
 
291
- const { app, server } = createBridge(bridge, 8080, '/bridge')
271
+ ## Middleware when you need it
292
272
 
293
- // Serve static files
294
- app.use(express.static(path.join(__dirname, 'public')))
273
+ Pattern based middleware runs before handlers and can inject context:
295
274
 
296
- // Custom GET endpoint
297
- app.get('/status', (req, res) => {
298
- res.json({ status: 'ok', uptime: process.uptime() })
299
- })
275
+ ```ts
276
+ import { createMiddleware } from 'typed-bridge'
300
277
 
301
- // Cleanup on graceful shutdown (SIGINT/SIGTERM)
302
- onShutdown(() => {
303
- console.log('Server shutting down')
278
+ createMiddleware('user.*', async (req, res) => {
279
+ if (!req.headers.authorization) {
280
+ res.status(401).send('Unauthorized')
281
+ return { next: false }
282
+ }
283
+ return { context: { userId: 1 } }
304
284
  })
305
285
  ```
306
286
 
307
- ---
287
+ Broader patterns run first (`*`, then `user.*`, then `user.fetch`). Returned context is merged and passed to the handler.
308
288
 
309
- ## Typed Bridge vs Alternatives
289
+ ---
310
290
 
311
- ### vs tRPC
291
+ ## Configuration
312
292
 
313
- | | **Typed Bridge** | **tRPC** |
314
- | ---------------------- | --------------------------------------------- | -------------------------------------------------- |
315
- | **Setup** | Define functions, generate typed client, done | Routers, procedures, adapters |
316
- | **Monorepo required?** | No, generated client is a standalone file | Practically yes, for type inference |
317
- | **Frontend framework** | Any (React, Vue, Angular, RN, etc.) | React-first, adapters for others |
318
- | **Learning curve** | Minimal, plain async functions | Moderate, procedures, context, middleware patterns |
319
- | **Runtime validation** | Zod (built-in) | Zod or others via `.input()` |
293
+ ```ts
294
+ import { tbConfig } from 'typed-bridge'
320
295
 
321
- ### vs GraphQL
296
+ tbConfig.logs.request = true
297
+ tbConfig.logs.response = true
298
+ tbConfig.logs.error = true
299
+ 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
301
+ ```
322
302
 
323
- | | **Typed Bridge** | **GraphQL** |
324
- | ------------------ | ------------------------------------------------------ | ---------------------------------------------------------- |
325
- | **Setup** | Define functions, generate typed client, done | Schema definition, resolvers, codegen |
326
- | **Type safety** | Automatic from function signatures | Requires codegen toolchain (e.g. GraphQL Code Generator) |
327
- | **Overfetching** | Not applicable, you control what each function returns | Solved by design with field selection |
328
- | **Learning curve** | Minimal, plain TypeScript | Significant: SDL, resolvers, fragments, queries, mutations |
329
- | **Best for** | App-specific backends, internal APIs | Public APIs, multi-client data graphs |
303
+ `createBridge` also returns the underlying Express `app` and `server`, so you can add routes, serve static files, or attach any Express middleware.
330
304
 
331
- Typed Bridge is for teams that want **type-safe RPCs without the architecture overhead**. You write normal TypeScript functions on the server, and the client just works.
305
+ ### Guarding tool output size
332
306
 
333
- ---
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:
334
308
 
335
- ## Recommended File Organization
309
+ ```ts
310
+ tbConfig.maxToolOutputChars = 100_000
336
311
 
312
+ // A tool returning more than that responds with:
313
+ // "Result too large (182431 chars, limit 100000). Narrow the query with filters or pagination."
337
314
  ```
338
- src/
339
- server.ts Server entry
340
- middleware.ts Middleware registrations (side-effect import)
341
- bridge/
342
- index.ts Route map (flat object)
343
- user/
344
- index.ts Handler functions
345
- types.ts Zod schemas (optional)
346
- product/
347
- index.ts Handler functions
348
- types.ts Zod schemas (optional)
349
- ```
350
315
 
351
- ### Adding a new route
316
+ ---
317
+
318
+ ## Adding a new route
352
319
 
353
- 1. Create handler in `bridge/<module>/index.ts`
354
- 2. If using Zod, add schemas in `<module>/types.ts` using `z` from `typed-bridge`
355
- 3. Register route in `bridge/index.ts` as `'module.action': module.action`
356
- 4. If middleware needed, add `createMiddleware(...)` and import the file in server entry
357
- 5. Run `gen:typed-bridge-client` to regenerate the typed client
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.
358
327
 
359
328
  ---
360
329
 
361
330
  ## Developer
362
331
 
363
- Developed & maintained by [neilveil](https://github.com/neilveil). Give a star to support this project!
332
+ Built and maintained by [neilveil](https://github.com/neilveil). If Typed Bridge saves you a codebase, drop a star.
@@ -1,3 +0,0 @@
1
- {
2
- "editor.formatOnSave": true
3
- }