orpc-fn 0.1.0 → 0.3.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.
Files changed (156) hide show
  1. package/README.md +70 -33
  2. package/dist/assemble-procedure.d.ts +13 -0
  3. package/dist/assemble-procedure.d.ts.map +1 -0
  4. package/dist/assemble-procedure.js +29 -0
  5. package/dist/assemble-procedure.js.map +1 -0
  6. package/dist/bound-call.d.ts +1 -1
  7. package/dist/bound-call.d.ts.map +1 -1
  8. package/dist/bound-call.js.map +1 -1
  9. package/dist/cli.d.ts +3 -0
  10. package/dist/cli.d.ts.map +1 -0
  11. package/dist/cli.js +56 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/client.d.ts +4 -2
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +4 -2
  16. package/dist/client.js.map +1 -1
  17. package/dist/compatibility.d.ts +16 -0
  18. package/dist/compatibility.d.ts.map +1 -0
  19. package/dist/compatibility.js +4 -0
  20. package/dist/compatibility.js.map +1 -0
  21. package/dist/create-fn.d.ts +30 -34
  22. package/dist/create-fn.d.ts.map +1 -1
  23. package/dist/create-fn.js +177 -121
  24. package/dist/create-fn.js.map +1 -1
  25. package/dist/expo.d.ts +11 -25
  26. package/dist/expo.d.ts.map +1 -1
  27. package/dist/expo.js +25 -30
  28. package/dist/expo.js.map +1 -1
  29. package/dist/hono.d.ts +12 -4
  30. package/dist/hono.d.ts.map +1 -1
  31. package/dist/hono.js +29 -12
  32. package/dist/hono.js.map +1 -1
  33. package/dist/index.d.ts +2 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/json-schema.d.ts +2 -0
  38. package/dist/json-schema.d.ts.map +1 -1
  39. package/dist/json-schema.js +11 -0
  40. package/dist/json-schema.js.map +1 -1
  41. package/dist/live/codec.d.ts +6 -1
  42. package/dist/live/codec.d.ts.map +1 -1
  43. package/dist/live/codec.js +14 -3
  44. package/dist/live/codec.js.map +1 -1
  45. package/dist/live/fn-live.d.ts +67 -22
  46. package/dist/live/fn-live.d.ts.map +1 -1
  47. package/dist/live/fn-live.js +98 -16
  48. package/dist/live/fn-live.js.map +1 -1
  49. package/dist/live/index.d.ts +1 -1
  50. package/dist/live/index.d.ts.map +1 -1
  51. package/dist/live/index.js.map +1 -1
  52. package/dist/live/ioredis.d.ts +4 -1
  53. package/dist/live/ioredis.d.ts.map +1 -1
  54. package/dist/live/ioredis.js +21 -2
  55. package/dist/live/ioredis.js.map +1 -1
  56. package/dist/live/lifecycle.d.ts +7 -0
  57. package/dist/live/lifecycle.d.ts.map +1 -0
  58. package/dist/live/lifecycle.js +36 -0
  59. package/dist/live/lifecycle.js.map +1 -0
  60. package/dist/live/memory.d.ts.map +1 -1
  61. package/dist/live/memory.js +4 -0
  62. package/dist/live/memory.js.map +1 -1
  63. package/dist/live/pub-sub.d.ts +81 -13
  64. package/dist/live/pub-sub.d.ts.map +1 -1
  65. package/dist/live/pub-sub.js +437 -126
  66. package/dist/live/pub-sub.js.map +1 -1
  67. package/dist/live/redis-bun.d.ts +1 -1
  68. package/dist/live/redis-bun.d.ts.map +1 -1
  69. package/dist/live/redis-bun.js +3 -5
  70. package/dist/live/redis-bun.js.map +1 -1
  71. package/dist/live/redis.d.ts +2 -2
  72. package/dist/live/redis.d.ts.map +1 -1
  73. package/dist/live/redis.js +36 -9
  74. package/dist/live/redis.js.map +1 -1
  75. package/dist/live/transport.d.ts +5 -1
  76. package/dist/live/transport.d.ts.map +1 -1
  77. package/dist/live/transport.js.map +1 -1
  78. package/dist/logger.d.ts +2 -0
  79. package/dist/logger.d.ts.map +1 -1
  80. package/dist/logger.js +21 -0
  81. package/dist/logger.js.map +1 -1
  82. package/dist/mastra.d.ts.map +1 -1
  83. package/dist/mastra.js +16 -7
  84. package/dist/mastra.js.map +1 -1
  85. package/dist/mcp/core.d.ts +41 -0
  86. package/dist/mcp/core.d.ts.map +1 -0
  87. package/dist/mcp/core.js +61 -0
  88. package/dist/mcp/core.js.map +1 -0
  89. package/dist/mcp/sdk.d.ts +12 -0
  90. package/dist/mcp/sdk.d.ts.map +1 -0
  91. package/dist/mcp/sdk.js +14 -0
  92. package/dist/mcp/sdk.js.map +1 -0
  93. package/dist/mcp/server.d.ts +11 -0
  94. package/dist/mcp/server.d.ts.map +1 -0
  95. package/dist/mcp/server.js +13 -0
  96. package/dist/mcp/server.js.map +1 -0
  97. package/dist/mcp.d.ts +3 -2
  98. package/dist/mcp.d.ts.map +1 -1
  99. package/dist/mcp.js +22 -7
  100. package/dist/mcp.js.map +1 -1
  101. package/dist/meta.d.ts +15 -7
  102. package/dist/meta.d.ts.map +1 -1
  103. package/dist/meta.js +9 -2
  104. package/dist/meta.js.map +1 -1
  105. package/dist/otel.d.ts.map +1 -1
  106. package/dist/otel.js +77 -6
  107. package/dist/otel.js.map +1 -1
  108. package/dist/route-options.d.ts +22 -0
  109. package/dist/route-options.d.ts.map +1 -0
  110. package/dist/route-options.js +67 -0
  111. package/dist/route-options.js.map +1 -0
  112. package/dist/router.d.ts +13 -1
  113. package/dist/router.d.ts.map +1 -1
  114. package/dist/router.js +35 -0
  115. package/dist/router.js.map +1 -1
  116. package/dist/timing.d.ts +8 -0
  117. package/dist/timing.d.ts.map +1 -0
  118. package/dist/timing.js +4 -0
  119. package/dist/timing.js.map +1 -0
  120. package/dist/types.d.ts +96 -41
  121. package/dist/types.d.ts.map +1 -1
  122. package/dist/types.js +6 -2
  123. package/dist/types.js.map +1 -1
  124. package/package.json +28 -3
  125. package/src/assemble-procedure.ts +42 -0
  126. package/src/bound-call.ts +4 -3
  127. package/src/cli.ts +68 -0
  128. package/src/client.ts +9 -2
  129. package/src/compatibility.ts +39 -0
  130. package/src/create-fn.ts +295 -182
  131. package/src/expo.ts +49 -44
  132. package/src/hono.ts +36 -17
  133. package/src/index.ts +7 -2
  134. package/src/json-schema.ts +16 -0
  135. package/src/live/codec.ts +22 -3
  136. package/src/live/fn-live.ts +248 -69
  137. package/src/live/index.ts +2 -0
  138. package/src/live/ioredis.ts +22 -2
  139. package/src/live/lifecycle.ts +44 -0
  140. package/src/live/memory.ts +4 -0
  141. package/src/live/pub-sub.ts +619 -168
  142. package/src/live/redis-bun.ts +7 -5
  143. package/src/live/redis.ts +37 -11
  144. package/src/live/transport.ts +4 -1
  145. package/src/logger.ts +20 -0
  146. package/src/mastra.ts +15 -7
  147. package/src/mcp/core.ts +102 -0
  148. package/src/mcp/sdk.ts +36 -0
  149. package/src/mcp/server.ts +35 -0
  150. package/src/mcp.ts +48 -20
  151. package/src/meta.ts +40 -24
  152. package/src/otel.ts +77 -10
  153. package/src/route-options.ts +100 -0
  154. package/src/router.ts +58 -1
  155. package/src/timing.ts +5 -0
  156. package/src/types.ts +186 -62
@@ -64,11 +64,13 @@ export function bunRedisTransport(
64
64
  subscriber.onclose = () => {}
65
65
  if (owned) return subscriber.close()
66
66
  // A given subscriber stays open: remove only this transport's listeners.
67
- for (const channel of ours) {
68
- void Promise.resolve()
69
- .then(() => subscriber.unsubscribe(channel, dispatch))
70
- .catch(() => {})
71
- }
67
+ return Promise.all(
68
+ [...ours].map((channel) =>
69
+ Promise.resolve()
70
+ .then(() => subscriber.unsubscribe(channel, dispatch))
71
+ .catch(() => {})
72
+ )
73
+ ).then(() => {})
72
74
  }
73
75
  }
74
76
  }
package/src/live/redis.ts CHANGED
@@ -76,7 +76,7 @@ export type SubscriberConnection = {
76
76
  */
77
77
  resubscribe?(channel: string): Promise<unknown>
78
78
  /** Stop delivering and release the connection (if the driver created it). */
79
- close(): void
79
+ close(): void | Promise<void>
80
80
  }
81
81
 
82
82
  /** Callbacks for one connection; they do nothing once it is replaced. */
@@ -91,6 +91,7 @@ export type SubscriberHandlers = {
91
91
  type Entry = {
92
92
  listener: (payload: string) => void
93
93
  onLost: ((error: Error) => void) | undefined
94
+ onReconnect: (() => void) | undefined
94
95
  }
95
96
 
96
97
  type ChannelState = {
@@ -114,27 +115,35 @@ export function createRedisTransport(driver: {
114
115
  connectSubscriber: (
115
116
  handlers: SubscriberHandlers
116
117
  ) => Promise<SubscriberConnection>
117
- }): PubSubTransport & { close(): void } {
118
+ }): PubSubTransport & { close(): Promise<void> } {
118
119
  const publishScript = createLuaScript(driver.send, PUBLISH_LUA)
119
120
  const channels = new Map<string, ChannelState>()
120
121
  // An UNSUBSCRIBE still in flight would cancel a SUBSCRIBE sent after it.
121
122
  const unsubscribing = new Map<string, Promise<unknown>>()
122
123
  let current: {
123
124
  connection: Promise<SubscriberConnection>
124
- retire: () => void
125
+ retire: () => Promise<void>
125
126
  } | null = null
126
127
 
127
128
  const connect = () => {
128
129
  let retired = false
130
+ let closing: Promise<void> | undefined
129
131
  const retire = (error?: Error) => {
130
- if (retired) return
132
+ if (retired) return closing ?? Promise.resolve()
131
133
  retired = true
132
134
  if (current?.connection === connection) current = null
133
- void connection.then((open) => open.close()).catch(() => {})
134
- if (!error) return
135
+ closing = connection.then((open) => open.close()).catch(() => {})
136
+ if (!error) return closing
135
137
  const lost = [...channels.values()].flatMap((state) => [...state.entries])
136
138
  channels.clear()
137
- for (const entry of lost) entry.onLost?.(error)
139
+ for (const entry of lost) {
140
+ try {
141
+ entry.onLost?.(error)
142
+ } catch {
143
+ /* One callback cannot interrupt retirement. */
144
+ }
145
+ }
146
+ return closing
138
147
  }
139
148
  const handlers: SubscriberHandlers = {
140
149
  onMessage(channel, message) {
@@ -157,6 +166,17 @@ export function createRedisTransport(driver: {
157
166
  .then((open) =>
158
167
  Promise.all(settled.map((channel) => open.resubscribe?.(channel)))
159
168
  )
169
+ .then(() => {
170
+ if (retired) return
171
+ for (const channel of settled)
172
+ for (const entry of [...(channels.get(channel)?.entries ?? [])]) {
173
+ try {
174
+ entry.onReconnect?.()
175
+ } catch {
176
+ /* Isolate subscriber callbacks. */
177
+ }
178
+ }
179
+ })
160
180
  .catch((error) => retire(asError(error)))
161
181
  },
162
182
  onLost: (error) => retire(error)
@@ -202,9 +222,13 @@ export function createRedisTransport(driver: {
202
222
  ])
203
223
  return Array.isArray(items) ? items.map(String) : []
204
224
  },
205
- async subscribe(channel, listener, onLost) {
225
+ async subscribe(channel, listener, onLost, options) {
206
226
  const connection = await (current?.connection ?? connect())
207
- const entry: Entry = { listener, onLost }
227
+ const entry: Entry = {
228
+ listener,
229
+ onLost,
230
+ onReconnect: options?.onReconnect
231
+ }
208
232
  await unsubscribing.get(channel)
209
233
  let state = channels.get(channel)
210
234
  if (!state) {
@@ -244,9 +268,11 @@ export function createRedisTransport(driver: {
244
268
  await done
245
269
  }
246
270
  },
247
- close() {
248
- current?.retire()
271
+ async close() {
272
+ const closing = current?.retire()
249
273
  channels.clear()
274
+ await closing
275
+ await Promise.allSettled([...unsubscribing.values()])
250
276
  }
251
277
  }
252
278
  }
@@ -15,6 +15,8 @@ export type BacklogOptions = {
15
15
  * The backlog of a channel lives at `${channel}:backlog` (see {@link backlogKey}).
16
16
  */
17
17
  export interface PubSubTransport {
18
+ /** Optional disposal, used only with ownsTransport: true. */
19
+ close?: () => void | Promise<void>
18
20
  /**
19
21
  * Publish every message, in order. With `backlog`, also append each payload
20
22
  * to its channel's backlog, trim and refresh its TTL - atomically, so a
@@ -34,7 +36,8 @@ export interface PubSubTransport {
34
36
  subscribe(
35
37
  channel: string,
36
38
  listener: (payload: string) => void,
37
- onLost?: (error: Error) => void
39
+ onLost?: (error: Error) => void,
40
+ options?: { onReconnect?: () => void }
38
41
  ): Promise<() => Promise<void>>
39
42
  }
40
43
 
package/src/logger.ts CHANGED
@@ -26,3 +26,23 @@ export function createDefaultLogger(scope: string): FnLogger {
26
26
  console.error(format(scope, message, attributes))
27
27
  }
28
28
  }
29
+
30
+ /** Protect telemetry calls while preserving application logger extensions. */
31
+ export function protectLogger<T extends FnLogger>(logger: T): T {
32
+ return new Proxy(logger, {
33
+ get(target, key) {
34
+ const value = Reflect.get(target, key, target)
35
+ if (typeof value !== 'function') return value
36
+ if (['debug', 'info', 'warn', 'error'].includes(String(key))) {
37
+ return (...args: unknown[]) => {
38
+ try {
39
+ return value.apply(target, args)
40
+ } catch {
41
+ /* Telemetry must not replace a procedure outcome. */
42
+ }
43
+ }
44
+ }
45
+ return value.bind(target)
46
+ }
47
+ })
48
+ }
package/src/mastra.ts CHANGED
@@ -2,6 +2,7 @@ import type { RequestContext } from '@mastra/core/request-context'
2
2
  import { createTool, type Tool } from '@mastra/core/tools'
3
3
  import { type AnyProcedure, call } from '@orpc/server'
4
4
  import {
5
+ isObjectJsonSchema,
5
6
  passThroughOutputSchema,
6
7
  rawInputSchema,
7
8
  type ToolSchema
@@ -64,6 +65,8 @@ export function createMastraTool<TProc extends AnyProcedure>(
64
65
  options: CreateMastraToolOptions = {}
65
66
  ): MastraTool<TProc> {
66
67
  const meta = readFnMeta(procedure)
68
+ if (meta.stream)
69
+ throw new TypeError('orpc-fn: streaming procedures cannot be Mastra tools')
67
70
  const name = options.id ?? meta.name
68
71
  if (!name) {
69
72
  throw new Error(
@@ -73,7 +76,7 @@ export function createMastraTool<TProc extends AnyProcedure>(
73
76
  let inputSchema: ToolSchema | undefined
74
77
  if (meta.inputSchema) {
75
78
  inputSchema = rawInputSchema(meta.inputSchema)
76
- if (inputSchema['~standard'].jsonSchema.input().type !== 'object') {
79
+ if (!isObjectJsonSchema(inputSchema['~standard'].jsonSchema.input())) {
77
80
  throw new Error(`${name} must take an object input to be an agent tool`)
78
81
  }
79
82
  } else if (!options.allowMissingInputSchema) {
@@ -114,12 +117,17 @@ export function createMastraTool<TProc extends AnyProcedure>(
114
117
  )
115
118
  }
116
119
  const startedAt = performance.now()
117
- const finish = (outcome: 'success' | 'error') =>
118
- options.onExecuteFinish?.({
119
- toolId: id,
120
- durationMs: performance.now() - startedAt,
121
- outcome
122
- })
120
+ const finish = (outcome: 'success' | 'error') => {
121
+ try {
122
+ options.onExecuteFinish?.({
123
+ toolId: id,
124
+ durationMs: performance.now() - startedAt,
125
+ outcome
126
+ })
127
+ } catch {
128
+ /* A telemetry hook cannot change tool execution. */
129
+ }
130
+ }
123
131
  try {
124
132
  const result = await call(
125
133
  procedure,
@@ -0,0 +1,102 @@
1
+ import {
2
+ type AnyRouter,
3
+ type InferRouterInitialContext,
4
+ call
5
+ } from '@orpc/server'
6
+ import { isObjectJsonSchema } from '../json-schema.js'
7
+ import { listTools, type McpTool, type ProcedureFilter } from '../mcp.js'
8
+
9
+ /** Text-only tool result: the shape both MCP SDK majors accept. */
10
+ export type TextToolResult = {
11
+ content: { type: 'text'; text: string }[]
12
+ isError?: boolean
13
+ }
14
+
15
+ export type RegisterMcpToolsOptions<R extends AnyRouter, TResult> = {
16
+ /** Tool exposure must be an explicit application choice. */
17
+ filter: ProcedureFilter
18
+ context: (request: {
19
+ name: string
20
+ signal: AbortSignal
21
+ }) => InferRouterInitialContext<R> | Promise<InferRouterInitialContext<R>>
22
+ readOnly?: ProcedureFilter
23
+ name?: NonNullable<Parameters<typeof listTools>[1]>['name']
24
+ formatResult?: (value: unknown, tool: McpTool) => TResult | Promise<TResult>
25
+ }
26
+
27
+ /** Default protocol result: JSON/text. Provide formatResult for other MCP content. */
28
+ export function formatMcpResult(value: unknown): TextToolResult {
29
+ const text =
30
+ typeof value === 'string'
31
+ ? value
32
+ : (JSON.stringify(value, (_key, item) =>
33
+ typeof item === 'bigint' ? item.toString() : item
34
+ ) ?? 'null')
35
+ return { content: [{ type: 'text', text }] }
36
+ }
37
+
38
+ const errorResult = (text: string): TextToolResult => ({
39
+ content: [{ type: 'text', text }],
40
+ isError: true
41
+ })
42
+
43
+ /** @internal SDK-independent `tools/list` and `tools/call` behind both adapters. */
44
+ export function createToolHandlers<R extends AnyRouter, TResult>(
45
+ router: R,
46
+ options: RegisterMcpToolsOptions<R, TResult>
47
+ ) {
48
+ const definitions = listTools(router, {
49
+ filter: options.filter,
50
+ ...(options.readOnly ? { readOnly: options.readOnly } : {}),
51
+ ...(options.name ? { name: options.name } : {})
52
+ })
53
+ const tools = new Map(definitions.map((tool) => [tool.name, tool]))
54
+ const descriptions = definitions.map((tool) => {
55
+ const inputSchema = tool.config.inputSchema?.[
56
+ '~standard'
57
+ ].jsonSchema.input() ?? { type: 'object', properties: {} }
58
+ if (!isObjectJsonSchema(inputSchema))
59
+ throw new TypeError(
60
+ `orpc-fn: MCP tool ${tool.name} needs an object input`
61
+ )
62
+ // MCP requires a top-level object; object unions retain their constraints.
63
+ return {
64
+ name: tool.name,
65
+ ...tool.config,
66
+ inputSchema: { ...inputSchema, type: 'object' as const }
67
+ }
68
+ })
69
+ return {
70
+ names: [...tools.keys()] as readonly string[],
71
+ list: () => ({ tools: descriptions }),
72
+ call: async (
73
+ name: string,
74
+ args: Record<string, unknown> | undefined,
75
+ signal: AbortSignal
76
+ ): Promise<TResult | TextToolResult> => {
77
+ const tool = tools.get(name)
78
+ if (!tool) return errorResult(`Unknown tool: ${name}`)
79
+ try {
80
+ signal.throwIfAborted()
81
+ const validation = await tool.config.inputSchema?.[
82
+ '~standard'
83
+ ].validate(args ?? {})
84
+ if (validation?.issues)
85
+ return errorResult(
86
+ validation.issues.map((issue) => issue.message).join('; ')
87
+ )
88
+ const context = await options.context({ name: tool.name, signal })
89
+ const result = await call(tool.procedure, validation?.value, {
90
+ context,
91
+ signal
92
+ })
93
+ return await (options.formatResult ?? formatMcpResult)(result, tool)
94
+ } catch (error) {
95
+ if (signal.aborted) throw error
96
+ return errorResult(
97
+ error instanceof Error ? error.message : String(error)
98
+ )
99
+ }
100
+ }
101
+ }
102
+ }
package/src/mcp/sdk.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
2
+ import {
3
+ CallToolRequestSchema,
4
+ ListToolsRequestSchema,
5
+ type CallToolResult
6
+ } from '@modelcontextprotocol/sdk/types.js'
7
+ import type { AnyRouter } from '@orpc/server'
8
+ import {
9
+ createToolHandlers,
10
+ formatMcpResult,
11
+ type RegisterMcpToolsOptions as CoreOptions
12
+ } from './core.js'
13
+
14
+ export { formatMcpResult }
15
+
16
+ export type RegisterMcpToolsOptions<R extends AnyRouter> = CoreOptions<
17
+ R,
18
+ CallToolResult
19
+ >
20
+
21
+ /**
22
+ * Register on the SDK v1 low-level Server, or on McpServer.server. For SDK v2
23
+ * (`@modelcontextprotocol/server`) use `orpc-fn/mcp/server`.
24
+ */
25
+ export function registerMcpTools<R extends AnyRouter>(
26
+ server: Pick<Server, 'setRequestHandler'>,
27
+ router: R,
28
+ options: RegisterMcpToolsOptions<R>
29
+ ): readonly string[] {
30
+ const handlers = createToolHandlers(router, options)
31
+ server.setRequestHandler(ListToolsRequestSchema, handlers.list)
32
+ server.setRequestHandler(CallToolRequestSchema, (request, extra) =>
33
+ handlers.call(request.params.name, request.params.arguments, extra.signal)
34
+ )
35
+ return handlers.names
36
+ }
@@ -0,0 +1,35 @@
1
+ import type { CallToolResult, Server } from '@modelcontextprotocol/server'
2
+ import type { AnyRouter } from '@orpc/server'
3
+ import {
4
+ createToolHandlers,
5
+ formatMcpResult,
6
+ type RegisterMcpToolsOptions as CoreOptions
7
+ } from './core.js'
8
+
9
+ export { formatMcpResult }
10
+
11
+ export type RegisterMcpToolsOptions<R extends AnyRouter> = CoreOptions<
12
+ R,
13
+ CallToolResult
14
+ >
15
+
16
+ /**
17
+ * Register on the SDK v2 (`@modelcontextprotocol/server`) low-level Server, or
18
+ * on McpServer.server. Declare `capabilities: { tools: {} }` on the Server.
19
+ */
20
+ export function registerMcpTools<R extends AnyRouter>(
21
+ server: Pick<Server, 'setRequestHandler'>,
22
+ router: R,
23
+ options: RegisterMcpToolsOptions<R>
24
+ ): readonly string[] {
25
+ const handlers = createToolHandlers(router, options)
26
+ server.setRequestHandler('tools/list', handlers.list)
27
+ server.setRequestHandler('tools/call', (request, ctx) =>
28
+ handlers.call(
29
+ request.params.name,
30
+ request.params.arguments,
31
+ ctx.mcpReq.signal
32
+ )
33
+ )
34
+ return handlers.names
35
+ }
package/src/mcp.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  type TraverseContractProcedureCallbackOptions,
5
5
  traverseContractProcedures
6
6
  } from '@orpc/server'
7
+ import { procedureDefinition } from './compatibility.js'
7
8
  import { rawInputSchema, type ToolSchema } from './json-schema.js'
8
9
  import { readFnMeta, toToolName } from './meta.js'
9
10
 
@@ -35,39 +36,66 @@ export type McpTool = {
35
36
  export const hasTag =
36
37
  (tag: string): ProcedureFilter =>
37
38
  ({ contract }) =>
38
- contract['~orpc'].route.tags?.includes(tag) ?? false
39
+ procedureDefinition(contract).route?.tags?.includes(tag) ?? false
39
40
 
40
41
  const isGet: ProcedureFilter = ({ contract }) =>
41
- contract['~orpc'].route.method === 'GET'
42
+ procedureDefinition(contract).route?.method === 'GET'
42
43
 
43
44
  /**
44
45
  * MCP tool definitions for the procedures in `router` matching `filter`
45
- * (default: all). Register them with any MCP server:
46
- * `server.registerTool(tool.name, tool.config, run)`.
46
+ * (default: all). These are inspection definitions, not McpServer.registerTool configs.
47
+ * Use registerMcpTools from orpc-fn/mcp/sdk for executable registration.
47
48
  *
48
49
  * `readOnly` sets `readOnlyHint`; it defaults to GET routes, since an MCP
49
50
  * client treats anything else as a mutation and may ask for approval.
50
51
  */
51
52
  export function listTools(
52
53
  router: AnyRouter,
53
- options: { filter?: ProcedureFilter; readOnly?: ProcedureFilter } = {}
54
+ options: {
55
+ filter?: ProcedureFilter
56
+ readOnly?: ProcedureFilter
57
+ name?: (options: TraverseContractProcedureCallbackOptions) => string
58
+ } = {}
54
59
  ): McpTool[] {
55
60
  const { filter, readOnly = isGet } = options
56
61
  const tools: McpTool[] = []
57
- traverseContractProcedures({ router, path: [] }, (traversed) => {
58
- if (filter && !filter(traversed)) return
59
- const procedure = traversed.contract as AnyProcedure
60
- const { inputSchema, route } = procedure['~orpc']
61
- const description = route.description ?? route.summary
62
- tools.push({
63
- name: toToolName(readFnMeta(procedure).name ?? traversed.path.join('.')),
64
- procedure,
65
- config: {
66
- ...(description === undefined ? {} : { description }),
67
- ...(inputSchema ? { inputSchema: rawInputSchema(inputSchema) } : {}),
68
- annotations: { readOnlyHint: readOnly(traversed) }
69
- }
70
- })
71
- })
62
+ const names = new Map<string, AnyProcedure>()
63
+ const unresolved = traverseContractProcedures(
64
+ { router, path: [] },
65
+ (traversed) => {
66
+ if (filter && !filter(traversed)) return
67
+ const procedure = traversed.contract as AnyProcedure
68
+ const meta = readFnMeta(procedure)
69
+ if (meta.stream)
70
+ throw new TypeError(
71
+ `orpc-fn: streaming procedure ${meta.name ?? traversed.path.join('.')} cannot be an MCP tool`
72
+ )
73
+ const name =
74
+ options.name?.(traversed) ??
75
+ toToolName(meta.name ?? traversed.path.join('.'))
76
+ const previous = names.get(name)
77
+ if (previous === procedure) return
78
+ if (previous)
79
+ throw new TypeError(
80
+ `orpc-fn: MCP tool name collision: ${name}; provide a name override`
81
+ )
82
+ names.set(name, procedure)
83
+ const { inputSchema, route = {} } = procedureDefinition(procedure)
84
+ const description = route.description ?? route.summary
85
+ tools.push({
86
+ name,
87
+ procedure,
88
+ config: {
89
+ ...(description === undefined ? {} : { description }),
90
+ ...(inputSchema ? { inputSchema: rawInputSchema(inputSchema) } : {}),
91
+ annotations: { readOnlyHint: readOnly(traversed) }
92
+ }
93
+ })
94
+ }
95
+ )
96
+ if (unresolved.length)
97
+ throw new TypeError(
98
+ 'orpc-fn: resolve lazy routers with unlazyRouter before listing MCP tools'
99
+ )
72
100
  return tools
73
101
  }
package/src/meta.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { AnySchema } from '@orpc/contract'
1
+ import { getEventIteratorSchemaDetails, type AnySchema } from '@orpc/contract'
2
+ import { type LibraryMeta, procedureDefinition } from './compatibility.js'
2
3
  import type { AnyProcedure } from '@orpc/server'
3
4
 
4
5
  /**
@@ -9,17 +10,24 @@ import type { AnyProcedure } from '@orpc/server'
9
10
  */
10
11
  export const FN_META_KEY = 'orpc-fn'
11
12
 
12
- export type StoredFnMeta = {
13
+ export type StoredFnMeta<
14
+ TMeta extends object = Record<string, unknown>,
15
+ TKey extends string = string
16
+ > = {
13
17
  name: string
14
- procedure: string
15
- meta: Record<string, unknown>
18
+ procedure: TKey
19
+ meta: TMeta
20
+ stream: boolean
16
21
  }
17
22
 
18
- export type FnMeta<TMeta extends object = Record<string, unknown>> = {
23
+ export type FnMeta<
24
+ TMeta extends object = Record<string, unknown>,
25
+ TKey extends string = string
26
+ > = {
19
27
  /** `fn({ name })`, also the OpenAPI operationId; undefined if not made by `fn()`. */
20
28
  name: string | undefined
21
29
  /** The `procedures` key the route was built from. */
22
- procedure: string | undefined
30
+ procedure: TKey | undefined
23
31
  /** The app's typed meta (`createFn({ meta })`) declared on this route. */
24
32
  meta: Partial<TMeta>
25
33
  /** Route summary, falling back to description, falling back to ''. */
@@ -27,40 +35,48 @@ export type FnMeta<TMeta extends object = Record<string, unknown>> = {
27
35
  summary: string | undefined
28
36
  method: string | undefined
29
37
  path: string | undefined
38
+ stream: boolean
30
39
  tags: readonly string[]
31
40
  inputSchema: AnySchema | undefined
32
41
  outputSchema: AnySchema | undefined
33
42
  }
34
43
 
35
- type ProcedureDefinition = {
36
- meta?: Record<string, unknown>
37
- route?: {
38
- summary?: string
39
- description?: string
40
- method?: string
41
- path?: string
42
- tags?: readonly string[]
43
- }
44
- inputSchema?: AnySchema
45
- outputSchema?: AnySchema
44
+ /** Read what `fn()` (and oRPC) recorded on a procedure. */
45
+ type MetaFrom<T> = [LibraryMeta<T>] extends [never]
46
+ ? Record<string, unknown>
47
+ : LibraryMeta<T> extends StoredFnMeta<infer M, string>
48
+ ? M
49
+ : Record<string, unknown>
50
+
51
+ type KeyFrom<T> = [LibraryMeta<T>] extends [never]
52
+ ? string
53
+ : LibraryMeta<T> extends StoredFnMeta<object, infer K>
54
+ ? K
55
+ : string
56
+
57
+ /** Declares the route metadata contract; it does not provide defaults. */
58
+ export function defineMeta<T extends object>(): T {
59
+ return {} as T
46
60
  }
47
61
 
48
- /** Read what `fn()` (and oRPC) recorded on a procedure. */
49
- export function readFnMeta<TMeta extends object = Record<string, unknown>>(
50
- procedure: AnyProcedure
51
- ): FnMeta<TMeta> {
52
- const def = (procedure as { '~orpc'?: ProcedureDefinition })['~orpc'] ?? {}
62
+ export function readFnMeta<T extends AnyProcedure>(
63
+ procedure: T
64
+ ): FnMeta<MetaFrom<T>, KeyFrom<T>> {
65
+ const def = procedureDefinition(procedure)
53
66
  const stored = def.meta?.[FN_META_KEY] as StoredFnMeta | undefined
54
67
  const route = def.route ?? {}
55
68
  return {
56
69
  name: stored?.name,
57
- procedure: stored?.procedure,
58
- meta: (stored?.meta ?? {}) as Partial<TMeta>,
70
+ procedure: stored?.procedure as KeyFrom<T> | undefined,
71
+ meta: (stored?.meta ?? {}) as Partial<MetaFrom<T>>,
59
72
  description: route.summary ?? route.description ?? '',
60
73
  summary: route.summary,
61
74
  method: route.method,
62
75
  path: route.path,
63
76
  tags: route.tags ?? [],
77
+ stream:
78
+ stored?.stream === true ||
79
+ getEventIteratorSchemaDetails(def.outputSchema) !== undefined,
64
80
  inputSchema: def.inputSchema,
65
81
  outputSchema: def.outputSchema
66
82
  }