orpc-fn 0.1.0 → 0.2.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 (141) hide show
  1. package/README.md +48 -32
  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/client.d.ts +4 -2
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +4 -2
  12. package/dist/client.js.map +1 -1
  13. package/dist/compatibility.d.ts +16 -0
  14. package/dist/compatibility.d.ts.map +1 -0
  15. package/dist/compatibility.js +4 -0
  16. package/dist/compatibility.js.map +1 -0
  17. package/dist/create-fn.d.ts +30 -34
  18. package/dist/create-fn.d.ts.map +1 -1
  19. package/dist/create-fn.js +177 -121
  20. package/dist/create-fn.js.map +1 -1
  21. package/dist/expo.d.ts +11 -25
  22. package/dist/expo.d.ts.map +1 -1
  23. package/dist/expo.js +25 -30
  24. package/dist/expo.js.map +1 -1
  25. package/dist/hono.d.ts +12 -4
  26. package/dist/hono.d.ts.map +1 -1
  27. package/dist/hono.js +29 -12
  28. package/dist/hono.js.map +1 -1
  29. package/dist/index.d.ts +2 -2
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/json-schema.d.ts +2 -0
  34. package/dist/json-schema.d.ts.map +1 -1
  35. package/dist/json-schema.js +11 -0
  36. package/dist/json-schema.js.map +1 -1
  37. package/dist/live/codec.d.ts +6 -1
  38. package/dist/live/codec.d.ts.map +1 -1
  39. package/dist/live/codec.js +14 -3
  40. package/dist/live/codec.js.map +1 -1
  41. package/dist/live/fn-live.d.ts +63 -22
  42. package/dist/live/fn-live.d.ts.map +1 -1
  43. package/dist/live/fn-live.js +98 -16
  44. package/dist/live/fn-live.js.map +1 -1
  45. package/dist/live/index.d.ts +1 -1
  46. package/dist/live/index.d.ts.map +1 -1
  47. package/dist/live/index.js.map +1 -1
  48. package/dist/live/ioredis.d.ts +4 -1
  49. package/dist/live/ioredis.d.ts.map +1 -1
  50. package/dist/live/ioredis.js +21 -2
  51. package/dist/live/ioredis.js.map +1 -1
  52. package/dist/live/lifecycle.d.ts +7 -0
  53. package/dist/live/lifecycle.d.ts.map +1 -0
  54. package/dist/live/lifecycle.js +36 -0
  55. package/dist/live/lifecycle.js.map +1 -0
  56. package/dist/live/memory.d.ts.map +1 -1
  57. package/dist/live/memory.js +4 -0
  58. package/dist/live/memory.js.map +1 -1
  59. package/dist/live/pub-sub.d.ts +76 -12
  60. package/dist/live/pub-sub.d.ts.map +1 -1
  61. package/dist/live/pub-sub.js +429 -125
  62. package/dist/live/pub-sub.js.map +1 -1
  63. package/dist/live/redis-bun.d.ts +1 -1
  64. package/dist/live/redis-bun.d.ts.map +1 -1
  65. package/dist/live/redis-bun.js +3 -5
  66. package/dist/live/redis-bun.js.map +1 -1
  67. package/dist/live/redis.d.ts +2 -2
  68. package/dist/live/redis.d.ts.map +1 -1
  69. package/dist/live/redis.js +36 -9
  70. package/dist/live/redis.js.map +1 -1
  71. package/dist/live/transport.d.ts +5 -1
  72. package/dist/live/transport.d.ts.map +1 -1
  73. package/dist/live/transport.js.map +1 -1
  74. package/dist/logger.d.ts +2 -0
  75. package/dist/logger.d.ts.map +1 -1
  76. package/dist/logger.js +21 -0
  77. package/dist/logger.js.map +1 -1
  78. package/dist/mastra.d.ts.map +1 -1
  79. package/dist/mastra.js +16 -7
  80. package/dist/mastra.js.map +1 -1
  81. package/dist/mcp/sdk.d.ts +20 -0
  82. package/dist/mcp/sdk.d.ts.map +1 -0
  83. package/dist/mcp/sdk.js +82 -0
  84. package/dist/mcp/sdk.js.map +1 -0
  85. package/dist/mcp.d.ts +3 -2
  86. package/dist/mcp.d.ts.map +1 -1
  87. package/dist/mcp.js +22 -7
  88. package/dist/mcp.js.map +1 -1
  89. package/dist/meta.d.ts +15 -7
  90. package/dist/meta.d.ts.map +1 -1
  91. package/dist/meta.js +9 -2
  92. package/dist/meta.js.map +1 -1
  93. package/dist/otel.d.ts.map +1 -1
  94. package/dist/otel.js +77 -6
  95. package/dist/otel.js.map +1 -1
  96. package/dist/route-options.d.ts +22 -0
  97. package/dist/route-options.d.ts.map +1 -0
  98. package/dist/route-options.js +67 -0
  99. package/dist/route-options.js.map +1 -0
  100. package/dist/router.d.ts +5 -1
  101. package/dist/router.d.ts.map +1 -1
  102. package/dist/router.js +22 -0
  103. package/dist/router.js.map +1 -1
  104. package/dist/timing.d.ts +8 -0
  105. package/dist/timing.d.ts.map +1 -0
  106. package/dist/timing.js +4 -0
  107. package/dist/timing.js.map +1 -0
  108. package/dist/types.d.ts +86 -41
  109. package/dist/types.d.ts.map +1 -1
  110. package/dist/types.js +6 -2
  111. package/dist/types.js.map +1 -1
  112. package/package.json +15 -3
  113. package/src/assemble-procedure.ts +42 -0
  114. package/src/bound-call.ts +4 -3
  115. package/src/client.ts +9 -2
  116. package/src/compatibility.ts +39 -0
  117. package/src/create-fn.ts +297 -183
  118. package/src/expo.ts +49 -44
  119. package/src/hono.ts +36 -17
  120. package/src/index.ts +6 -2
  121. package/src/json-schema.ts +16 -0
  122. package/src/live/codec.ts +22 -3
  123. package/src/live/fn-live.ts +244 -69
  124. package/src/live/index.ts +2 -0
  125. package/src/live/ioredis.ts +22 -2
  126. package/src/live/lifecycle.ts +44 -0
  127. package/src/live/memory.ts +4 -0
  128. package/src/live/pub-sub.ts +608 -166
  129. package/src/live/redis-bun.ts +7 -5
  130. package/src/live/redis.ts +37 -11
  131. package/src/live/transport.ts +4 -1
  132. package/src/logger.ts +20 -0
  133. package/src/mastra.ts +15 -7
  134. package/src/mcp/sdk.ts +118 -0
  135. package/src/mcp.ts +48 -20
  136. package/src/meta.ts +40 -24
  137. package/src/otel.ts +77 -10
  138. package/src/route-options.ts +100 -0
  139. package/src/router.ts +39 -1
  140. package/src/timing.ts +5 -0
  141. package/src/types.ts +204 -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,
package/src/mcp/sdk.ts ADDED
@@ -0,0 +1,118 @@
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 {
8
+ type AnyRouter,
9
+ type InferRouterInitialContext,
10
+ call
11
+ } from '@orpc/server'
12
+ import { isObjectJsonSchema } from '../json-schema.js'
13
+ import { listTools, type McpTool, type ProcedureFilter } from '../mcp.js'
14
+
15
+ export type RegisterMcpToolsOptions<R extends AnyRouter> = {
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?: (
25
+ value: unknown,
26
+ tool: McpTool
27
+ ) => CallToolResult | Promise<CallToolResult>
28
+ }
29
+
30
+ /** Default protocol result: JSON/text. Provide formatResult for other MCP content. */
31
+ export function formatMcpResult(value: unknown): CallToolResult {
32
+ const text =
33
+ typeof value === 'string'
34
+ ? value
35
+ : (JSON.stringify(value, (_key, item) =>
36
+ typeof item === 'bigint' ? item.toString() : item
37
+ ) ?? 'null')
38
+ return { content: [{ type: 'text', text }] }
39
+ }
40
+
41
+ /** Register on the SDK's low-level Server, or on McpServer.server. */
42
+ export function registerMcpTools<R extends AnyRouter>(
43
+ server: Pick<Server, 'setRequestHandler'>,
44
+ router: R,
45
+ options: RegisterMcpToolsOptions<R>
46
+ ): readonly string[] {
47
+ const definitions = listTools(router, {
48
+ filter: options.filter,
49
+ ...(options.readOnly ? { readOnly: options.readOnly } : {}),
50
+ ...(options.name ? { name: options.name } : {})
51
+ })
52
+ const tools = new Map(definitions.map((tool) => [tool.name, tool]))
53
+ const descriptions = definitions.map((tool) => {
54
+ const inputSchema = tool.config.inputSchema?.[
55
+ '~standard'
56
+ ].jsonSchema.input() ?? { type: 'object', properties: {} }
57
+ if (!isObjectJsonSchema(inputSchema))
58
+ throw new TypeError(
59
+ `orpc-fn: MCP tool ${tool.name} needs an object input`
60
+ )
61
+ // MCP requires a top-level object; object unions retain their constraints.
62
+ return {
63
+ name: tool.name,
64
+ ...tool.config,
65
+ inputSchema: { ...inputSchema, type: 'object' as const }
66
+ }
67
+ })
68
+ server.setRequestHandler(ListToolsRequestSchema, () => ({
69
+ tools: descriptions
70
+ }))
71
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
72
+ const tool = tools.get(request.params.name)
73
+ if (!tool)
74
+ return {
75
+ content: [
76
+ { type: 'text', text: `Unknown tool: ${request.params.name}` }
77
+ ],
78
+ isError: true
79
+ }
80
+ try {
81
+ extra.signal.throwIfAborted()
82
+ const validation = await tool.config.inputSchema?.['~standard'].validate(
83
+ request.params.arguments ?? {}
84
+ )
85
+ if (validation?.issues)
86
+ return {
87
+ content: [
88
+ {
89
+ type: 'text',
90
+ text: validation.issues.map((issue) => issue.message).join('; ')
91
+ }
92
+ ],
93
+ isError: true
94
+ }
95
+ const context = await options.context({
96
+ name: tool.name,
97
+ signal: extra.signal
98
+ })
99
+ const result = await call(tool.procedure, validation?.value, {
100
+ context,
101
+ signal: extra.signal
102
+ })
103
+ return await (options.formatResult ?? formatMcpResult)(result, tool)
104
+ } catch (error) {
105
+ if (extra.signal.aborted) throw error
106
+ return {
107
+ content: [
108
+ {
109
+ type: 'text',
110
+ text: error instanceof Error ? error.message : String(error)
111
+ }
112
+ ],
113
+ isError: true
114
+ }
115
+ }
116
+ })
117
+ return [...tools.keys()]
118
+ }
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
  }
package/src/otel.ts CHANGED
@@ -84,9 +84,15 @@ export type Tracing = {
84
84
  }
85
85
 
86
86
  export function createTracing(otel: OtelApiLike | undefined): Tracing {
87
- const tracer = otel?.trace.getTracer('orpc-fn')
87
+ let tracer: TracerLike<SpanLike> | undefined
88
+ try {
89
+ tracer = otel?.trace.getTracer('orpc-fn')
90
+ } catch {
91
+ /* Missing telemetry cannot prevent execution. */
92
+ }
88
93
  const end: Tracing['end'] = (span, ...error) => {
89
94
  if (!span || !otel) return
95
+ span = protectSpan(span)
90
96
  if (error.length === 0) {
91
97
  span.setStatus({ code: otel.SpanStatusCode.OK })
92
98
  } else {
@@ -103,6 +109,8 @@ export function createTracing(otel: OtelApiLike | undefined): Tracing {
103
109
  const settle = (span: SpanLike | undefined, ...error: [] | [unknown]) => {
104
110
  try {
105
111
  onSettled?.(span, ...error)
112
+ } catch {
113
+ /* Telemetry callbacks cannot replace the original result. */
106
114
  } finally {
107
115
  end(span, ...error)
108
116
  }
@@ -111,6 +119,7 @@ export function createTracing(otel: OtelApiLike | undefined): Tracing {
111
119
  span: SpanLike | undefined,
112
120
  scope?: StreamScope
113
121
  ): Promise<Awaited<ReturnType<typeof fn>>> => {
122
+ span = span ? protectSpan(span) : undefined
114
123
  let result: Awaited<ReturnType<typeof fn>>
115
124
  try {
116
125
  result = await fn(span)
@@ -129,18 +138,54 @@ export function createTracing(otel: OtelApiLike | undefined): Tracing {
129
138
  return result
130
139
  }
131
140
  if (!tracer || !otel) return run(undefined)
132
- return tracer.startActiveSpan(
133
- name,
134
- { kind: otel.SpanKind[kind] },
135
- (span) => {
136
- const active = otel.context.active()
137
- return run(span, (step) => otel.context.with(active as never, step))
138
- }
139
- )
141
+ let started: Promise<Awaited<ReturnType<typeof fn>>> | undefined
142
+ try {
143
+ return tracer.startActiveSpan(
144
+ name,
145
+ { kind: otel.SpanKind[kind] },
146
+ (span) => {
147
+ let active: unknown
148
+ try {
149
+ active = otel.context.active()
150
+ } catch {
151
+ /* Fall back to the span without scope restoration. */
152
+ }
153
+ started = run(span, (step) => {
154
+ let completed: { value: ReturnType<typeof step> } | undefined
155
+ let failed: { error: unknown } | undefined
156
+ try {
157
+ return otel.context.with(active as never, () => {
158
+ try {
159
+ const value = step()
160
+ completed = { value }
161
+ return value
162
+ } catch (error) {
163
+ failed = { error }
164
+ throw error
165
+ }
166
+ })
167
+ } catch {
168
+ if (completed) return completed.value
169
+ if (failed) throw failed.error
170
+ return step()
171
+ }
172
+ })
173
+ return started
174
+ }
175
+ )
176
+ } catch {
177
+ return started ?? run(undefined)
178
+ }
140
179
  },
141
180
  startSpan(name, kind, attributes) {
142
181
  if (!tracer || !otel) return undefined
143
- return tracer.startSpan(name, { kind: otel.SpanKind[kind], attributes })
182
+ try {
183
+ return protectSpan(
184
+ tracer.startSpan(name, { kind: otel.SpanKind[kind], attributes })
185
+ )
186
+ } catch {
187
+ return undefined
188
+ }
144
189
  },
145
190
  end
146
191
  }
@@ -149,3 +194,25 @@ export function createTracing(otel: OtelApiLike | undefined): Tracing {
149
194
  export function errorMessageOf(error: unknown): string {
150
195
  return error instanceof Error ? error.message : String(error)
151
196
  }
197
+
198
+ function protectSpan<T extends SpanLike>(span: T): T {
199
+ return new Proxy(span, {
200
+ get(target, key) {
201
+ const value = Reflect.get(target, key, target)
202
+ if (typeof value !== 'function') return value
203
+ if (
204
+ ['setAttribute', 'setStatus', 'recordException', 'end'].includes(
205
+ String(key)
206
+ )
207
+ )
208
+ return (...args: unknown[]) => {
209
+ try {
210
+ return value.apply(target, args)
211
+ } catch {
212
+ /* Ignore exporter failures. */
213
+ }
214
+ }
215
+ return value.bind(target)
216
+ }
217
+ })
218
+ }