orpc-fn 0.1.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 (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -0
  3. package/dist/bound-call.d.ts +23 -0
  4. package/dist/bound-call.d.ts.map +1 -0
  5. package/dist/bound-call.js +23 -0
  6. package/dist/bound-call.js.map +1 -0
  7. package/dist/client.d.ts +44 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +49 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/create-fn.d.ts +97 -0
  12. package/dist/create-fn.d.ts.map +1 -0
  13. package/dist/create-fn.js +224 -0
  14. package/dist/create-fn.js.map +1 -0
  15. package/dist/expected-client-error.d.ts +11 -0
  16. package/dist/expected-client-error.d.ts.map +1 -0
  17. package/dist/expected-client-error.js +21 -0
  18. package/dist/expected-client-error.js.map +1 -0
  19. package/dist/expo.d.ts +66 -0
  20. package/dist/expo.d.ts.map +1 -0
  21. package/dist/expo.js +74 -0
  22. package/dist/expo.js.map +1 -0
  23. package/dist/hono.d.ts +81 -0
  24. package/dist/hono.d.ts.map +1 -0
  25. package/dist/hono.js +138 -0
  26. package/dist/hono.js.map +1 -0
  27. package/dist/index.d.ts +10 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +8 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/json-schema.d.ts +47 -0
  32. package/dist/json-schema.d.ts.map +1 -0
  33. package/dist/json-schema.js +44 -0
  34. package/dist/json-schema.js.map +1 -0
  35. package/dist/live/codec.d.ts +9 -0
  36. package/dist/live/codec.d.ts.map +1 -0
  37. package/dist/live/codec.js +27 -0
  38. package/dist/live/codec.js.map +1 -0
  39. package/dist/live/fn-live.d.ts +155 -0
  40. package/dist/live/fn-live.d.ts.map +1 -0
  41. package/dist/live/fn-live.js +210 -0
  42. package/dist/live/fn-live.js.map +1 -0
  43. package/dist/live/index.d.ts +4 -0
  44. package/dist/live/index.d.ts.map +1 -0
  45. package/dist/live/index.js +5 -0
  46. package/dist/live/index.js.map +1 -0
  47. package/dist/live/ioredis.d.ts +29 -0
  48. package/dist/live/ioredis.d.ts.map +1 -0
  49. package/dist/live/ioredis.js +49 -0
  50. package/dist/live/ioredis.js.map +1 -0
  51. package/dist/live/memory.d.ts +10 -0
  52. package/dist/live/memory.d.ts.map +1 -0
  53. package/dist/live/memory.js +45 -0
  54. package/dist/live/memory.js.map +1 -0
  55. package/dist/live/pub-sub.d.ts +211 -0
  56. package/dist/live/pub-sub.d.ts.map +1 -0
  57. package/dist/live/pub-sub.js +529 -0
  58. package/dist/live/pub-sub.js.map +1 -0
  59. package/dist/live/redis-bun.d.ts +26 -0
  60. package/dist/live/redis-bun.d.ts.map +1 -0
  61. package/dist/live/redis-bun.js +59 -0
  62. package/dist/live/redis-bun.js.map +1 -0
  63. package/dist/live/redis.d.ts +45 -0
  64. package/dist/live/redis.d.ts.map +1 -0
  65. package/dist/live/redis.js +214 -0
  66. package/dist/live/redis.js.map +1 -0
  67. package/dist/live/transport.d.ts +35 -0
  68. package/dist/live/transport.d.ts.map +1 -0
  69. package/dist/live/transport.js +2 -0
  70. package/dist/live/transport.js.map +1 -0
  71. package/dist/logger.d.ts +15 -0
  72. package/dist/logger.d.ts.map +1 -0
  73. package/dist/logger.js +15 -0
  74. package/dist/logger.js.map +1 -0
  75. package/dist/mastra.d.ts +44 -0
  76. package/dist/mastra.d.ts.map +1 -0
  77. package/dist/mastra.js +78 -0
  78. package/dist/mastra.js.map +1 -0
  79. package/dist/mcp.d.ts +39 -0
  80. package/dist/mcp.d.ts.map +1 -0
  81. package/dist/mcp.js +38 -0
  82. package/dist/mcp.js.map +1 -0
  83. package/dist/meta.d.ts +35 -0
  84. package/dist/meta.d.ts.map +1 -0
  85. package/dist/meta.js +30 -0
  86. package/dist/meta.js.map +1 -0
  87. package/dist/otel.d.ts +66 -0
  88. package/dist/otel.d.ts.map +1 -0
  89. package/dist/otel.js +63 -0
  90. package/dist/otel.js.map +1 -0
  91. package/dist/router.d.ts +13 -0
  92. package/dist/router.d.ts.map +1 -0
  93. package/dist/router.js +14 -0
  94. package/dist/router.js.map +1 -0
  95. package/dist/stream.d.ts +13 -0
  96. package/dist/stream.d.ts.map +1 -0
  97. package/dist/stream.js +44 -0
  98. package/dist/stream.js.map +1 -0
  99. package/dist/types.d.ts +196 -0
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +29 -0
  102. package/dist/types.js.map +1 -0
  103. package/package.json +157 -0
  104. package/src/bound-call.ts +50 -0
  105. package/src/client.ts +82 -0
  106. package/src/create-fn.ts +461 -0
  107. package/src/expected-client-error.ts +23 -0
  108. package/src/expo.ts +127 -0
  109. package/src/hono.ts +247 -0
  110. package/src/index.ts +45 -0
  111. package/src/json-schema.ts +82 -0
  112. package/src/live/codec.ts +31 -0
  113. package/src/live/fn-live.ts +470 -0
  114. package/src/live/index.ts +31 -0
  115. package/src/live/ioredis.ts +74 -0
  116. package/src/live/memory.ts +50 -0
  117. package/src/live/pub-sub.ts +828 -0
  118. package/src/live/redis-bun.ts +76 -0
  119. package/src/live/redis.ts +255 -0
  120. package/src/live/transport.ts +41 -0
  121. package/src/logger.ts +28 -0
  122. package/src/mastra.ts +140 -0
  123. package/src/mcp.ts +73 -0
  124. package/src/meta.ts +72 -0
  125. package/src/otel.ts +151 -0
  126. package/src/router.ts +15 -0
  127. package/src/stream.ts +54 -0
  128. package/src/types.ts +389 -0
@@ -0,0 +1,76 @@
1
+ import { createRedisTransport } from './redis.js'
2
+
3
+ type Listener = (message: string, channel: string) => void
4
+
5
+ /** The part of Bun's `RedisClient` this transport uses. */
6
+ export interface BunRedisClientLike {
7
+ send(command: string, args: string[]): Promise<unknown>
8
+ subscribe(channel: string, listener: Listener): Promise<unknown>
9
+ unsubscribe(channel: string, listener?: Listener): Promise<unknown>
10
+ duplicate(): Promise<BunRedisClientLike>
11
+ close(): void
12
+ onconnect: ((this: never) => void) | null
13
+ onclose: ((this: never, error: Error) => void) | null
14
+ }
15
+
16
+ /**
17
+ * `PubSubTransport` for Bun's built-in `RedisClient`. Subscriptions use a
18
+ * dedicated connection (`client.duplicate()` unless `subscriber` is given)
19
+ * that re-subscribes its channels after every reconnect; configure retries on
20
+ * the client (`maxRetries`) since the duplicate copies its options.
21
+ *
22
+ * Call `close()` on shutdown to close the subscriber connection it created.
23
+ */
24
+ export function bunRedisTransport(
25
+ client: BunRedisClientLike,
26
+ options: { subscriber?: BunRedisClientLike } = {}
27
+ ) {
28
+ return createRedisTransport({
29
+ send: (command, args) => client.send(command, args),
30
+ async connectSubscriber(handlers) {
31
+ const owned = !options.subscriber
32
+ const subscriber = options.subscriber ?? (await client.duplicate())
33
+ const dispatch: Listener = (message, channel) =>
34
+ handlers.onMessage(channel, message)
35
+ const ours = new Set<string>()
36
+ // Bun reconnects on its own but drops every subscription. Also fires on
37
+ // the first connect, when there is nothing to restore yet.
38
+ subscriber.onconnect = () => handlers.onReconnect()
39
+ // Fires only when Bun gives up reconnecting (and on close(), which
40
+ // detaches it first).
41
+ subscriber.onclose = (error) =>
42
+ handlers.onLost(error ?? new Error('Redis subscriber closed'))
43
+ return {
44
+ subscribe: (channel) => {
45
+ ours.add(channel)
46
+ return subscriber.subscribe(channel, dispatch)
47
+ },
48
+ // Bun keeps listeners across a reconnect and does not dedupe them, so
49
+ // clear the channel before adding its listener again. unsubscribe
50
+ // throws synchronously outside subscriber mode.
51
+ resubscribe: async (channel) => {
52
+ await Promise.resolve()
53
+ .then(() => subscriber.unsubscribe(channel))
54
+ .catch(() => {})
55
+ return subscriber.subscribe(channel, dispatch)
56
+ },
57
+ unsubscribe: (channel) => {
58
+ ours.delete(channel)
59
+ return subscriber.unsubscribe(channel, dispatch)
60
+ },
61
+ close() {
62
+ // No-ops, not null: Bun calls `onclose` without checking it.
63
+ subscriber.onconnect = () => {}
64
+ subscriber.onclose = () => {}
65
+ if (owned) return subscriber.close()
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
+ }
72
+ }
73
+ }
74
+ }
75
+ })
76
+ }
@@ -0,0 +1,255 @@
1
+ import { errorMessageOf } from '../otel.js'
2
+ import { backlogKey, type PubSubTransport } from './transport.js'
3
+
4
+ export type RedisSend = (command: string, args: string[]) => Promise<unknown>
5
+
6
+ function isNoScriptError(error: unknown) {
7
+ return errorMessageOf(error).includes('NOSCRIPT')
8
+ }
9
+
10
+ /**
11
+ * Ship a Lua script by SHA instead of resending its source on every call.
12
+ * Redis forgets its script cache on restart and on `SCRIPT FLUSH`, so a
13
+ * `NOSCRIPT` reply falls back to an inline `EVAL` (which repopulates the
14
+ * cache) and drops the stale digest.
15
+ */
16
+ export function createLuaScript(send: RedisSend, source: string) {
17
+ let sha: string | null = null
18
+ let loading: Promise<string> | null = null
19
+
20
+ function load() {
21
+ loading ??= send('SCRIPT', ['LOAD', source])
22
+ .then(String)
23
+ .finally(() => {
24
+ loading = null
25
+ })
26
+ return loading
27
+ }
28
+
29
+ return async function evalScript(keys: string[], args: string[]) {
30
+ const tail = [String(keys.length), ...keys, ...args]
31
+ sha ??= await load()
32
+ try {
33
+ return await send('EVALSHA', [sha, ...tail])
34
+ } catch (error) {
35
+ if (!isNoScriptError(error)) throw error
36
+ sha = null
37
+ return await send('EVAL', [source, ...tail])
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Publish payloads and maintain each channel's capped, TTL'd backlog in one
44
+ * atomic round-trip. Separate PUBLISH/RPUSH/LTRIM/EXPIRE calls can tear (a
45
+ * message published but missing from the backlog, or a backlog without TTL).
46
+ *
47
+ * KEYS = channel1, backlog1, channel2, backlog2, ...
48
+ * ARGV[1] = max backlog size (0 = no backlog), ARGV[2] = TTL seconds,
49
+ * ARGV[3..] = pairs of (channel slot, payload)
50
+ */
51
+ const PUBLISH_LUA = `
52
+ local maxSize = tonumber(ARGV[1])
53
+ local ttl = tonumber(ARGV[2])
54
+ for i = 3, #ARGV, 2 do
55
+ local slot = tonumber(ARGV[i]) * 2
56
+ redis.call('PUBLISH', KEYS[slot - 1], ARGV[i + 1])
57
+ if maxSize > 0 then
58
+ redis.call('RPUSH', KEYS[slot], ARGV[i + 1])
59
+ end
60
+ end
61
+ if maxSize > 0 then
62
+ for slot = 2, #KEYS, 2 do
63
+ redis.call('LTRIM', KEYS[slot], -maxSize, -1)
64
+ redis.call('EXPIRE', KEYS[slot], ttl)
65
+ end
66
+ end
67
+ return (#ARGV - 2) / 2
68
+ `
69
+
70
+ export type SubscriberConnection = {
71
+ subscribe(channel: string): Promise<unknown>
72
+ unsubscribe(channel: string): Promise<unknown>
73
+ /**
74
+ * Restore one channel after the driver reconnected on its own and lost its
75
+ * subscriptions. Omit when the client re-subscribes itself (ioredis).
76
+ */
77
+ resubscribe?(channel: string): Promise<unknown>
78
+ /** Stop delivering and release the connection (if the driver created it). */
79
+ close(): void
80
+ }
81
+
82
+ /** Callbacks for one connection; they do nothing once it is replaced. */
83
+ export type SubscriberHandlers = {
84
+ onMessage(channel: string, message: string): void
85
+ /** The driver reconnected; its subscriptions must be restored. */
86
+ onReconnect(): void
87
+ /** The connection is gone for good; a fresh one replaces it. */
88
+ onLost(error: Error): void
89
+ }
90
+
91
+ type Entry = {
92
+ listener: (payload: string) => void
93
+ onLost: ((error: Error) => void) | undefined
94
+ }
95
+
96
+ type ChannelState = {
97
+ entries: Set<Entry>
98
+ ready: Promise<unknown>
99
+ settled: boolean
100
+ }
101
+
102
+ /**
103
+ * Shared core of the Redis transports: one SUBSCRIBE per channel no matter
104
+ * how many listeners, and one dedicated subscriber connection at a time.
105
+ *
106
+ * Each connection is a generation. Losing it (or failing to restore its
107
+ * channels after a reconnect) retires it: the core closes it, ignores
108
+ * anything it still emits, and tells every listener, whose resubscribe then
109
+ * opens the next generation. Nothing from an old connection can reach the
110
+ * channels of a new one.
111
+ */
112
+ export function createRedisTransport(driver: {
113
+ send: RedisSend
114
+ connectSubscriber: (
115
+ handlers: SubscriberHandlers
116
+ ) => Promise<SubscriberConnection>
117
+ }): PubSubTransport & { close(): void } {
118
+ const publishScript = createLuaScript(driver.send, PUBLISH_LUA)
119
+ const channels = new Map<string, ChannelState>()
120
+ // An UNSUBSCRIBE still in flight would cancel a SUBSCRIBE sent after it.
121
+ const unsubscribing = new Map<string, Promise<unknown>>()
122
+ let current: {
123
+ connection: Promise<SubscriberConnection>
124
+ retire: () => void
125
+ } | null = null
126
+
127
+ const connect = () => {
128
+ let retired = false
129
+ const retire = (error?: Error) => {
130
+ if (retired) return
131
+ retired = true
132
+ if (current?.connection === connection) current = null
133
+ void connection.then((open) => open.close()).catch(() => {})
134
+ if (!error) return
135
+ const lost = [...channels.values()].flatMap((state) => [...state.entries])
136
+ channels.clear()
137
+ for (const entry of lost) entry.onLost?.(error)
138
+ }
139
+ const handlers: SubscriberHandlers = {
140
+ onMessage(channel, message) {
141
+ if (retired) return
142
+ for (const entry of [...(channels.get(channel)?.entries ?? [])]) {
143
+ try {
144
+ entry.listener(message)
145
+ } catch {
146
+ // One listener must not starve the others.
147
+ }
148
+ }
149
+ },
150
+ onReconnect() {
151
+ if (retired) return
152
+ // In-flight SUBSCRIBEs are excluded: they still land on their own.
153
+ const settled = [...channels]
154
+ .filter(([, state]) => state.settled)
155
+ .map(([channel]) => channel)
156
+ void connection
157
+ .then((open) =>
158
+ Promise.all(settled.map((channel) => open.resubscribe?.(channel)))
159
+ )
160
+ .catch((error) => retire(asError(error)))
161
+ },
162
+ onLost: (error) => retire(error)
163
+ }
164
+ const connection = driver.connectSubscriber(handlers)
165
+ connection.catch(() => {
166
+ if (current?.connection === connection) current = null
167
+ })
168
+ current = { connection, retire: () => retire() }
169
+ return connection
170
+ }
171
+
172
+ return {
173
+ async publish(messages, backlog) {
174
+ const [first] = messages
175
+ if (!first) return
176
+ if (!backlog && messages.length === 1) {
177
+ await driver.send('PUBLISH', [first.channel, first.payload])
178
+ return
179
+ }
180
+ const keys: string[] = []
181
+ const slots = new Map<string, number>()
182
+ const args = [
183
+ String(backlog?.size ?? 0),
184
+ String(backlog?.ttlSeconds ?? 0)
185
+ ]
186
+ for (const { channel, payload } of messages) {
187
+ let slot = slots.get(channel)
188
+ if (slot === undefined) {
189
+ keys.push(channel, backlogKey(channel))
190
+ slot = keys.length / 2
191
+ slots.set(channel, slot)
192
+ }
193
+ args.push(String(slot), payload)
194
+ }
195
+ await publishScript(keys, args)
196
+ },
197
+ async readBacklog(channel) {
198
+ const items = await driver.send('LRANGE', [
199
+ backlogKey(channel),
200
+ '0',
201
+ '-1'
202
+ ])
203
+ return Array.isArray(items) ? items.map(String) : []
204
+ },
205
+ async subscribe(channel, listener, onLost) {
206
+ const connection = await (current?.connection ?? connect())
207
+ const entry: Entry = { listener, onLost }
208
+ await unsubscribing.get(channel)
209
+ let state = channels.get(channel)
210
+ if (!state) {
211
+ const created: ChannelState = {
212
+ entries: new Set(),
213
+ ready: connection.subscribe(channel),
214
+ settled: false
215
+ }
216
+ created.ready.then(
217
+ () => {
218
+ created.settled = true
219
+ },
220
+ () => {
221
+ if (channels.get(channel) === created) channels.delete(channel)
222
+ }
223
+ )
224
+ channels.set(channel, created)
225
+ state = created
226
+ }
227
+ state.entries.add(entry)
228
+ try {
229
+ await state.ready
230
+ } catch (error) {
231
+ state.entries.delete(entry)
232
+ throw error
233
+ }
234
+ const subscribed = state
235
+ return async () => {
236
+ if (!subscribed.entries.delete(entry)) return
237
+ if (subscribed.entries.size > 0 || channels.get(channel) !== subscribed)
238
+ return
239
+ channels.delete(channel)
240
+ const done = connection.unsubscribe(channel).finally(() => {
241
+ if (unsubscribing.get(channel) === done) unsubscribing.delete(channel)
242
+ })
243
+ unsubscribing.set(channel, done)
244
+ await done
245
+ }
246
+ },
247
+ close() {
248
+ current?.retire()
249
+ channels.clear()
250
+ }
251
+ }
252
+ }
253
+
254
+ const asError = (error: unknown) =>
255
+ error instanceof Error ? error : new Error(errorMessageOf(error))
@@ -0,0 +1,41 @@
1
+ /** One payload for one channel. */
2
+ export type PubSubMessage = { channel: string; payload: string }
3
+
4
+ export type BacklogOptions = {
5
+ /** Items kept per channel (oldest dropped). */
6
+ size: number
7
+ /** Seconds the backlog lives after its last write. */
8
+ ttlSeconds: number
9
+ }
10
+
11
+ /**
12
+ * The broker behind `createPubSub` / `fnLive`. Implementations:
13
+ * `orpc-fn/live/redis-bun`, `orpc-fn/live/ioredis` and `orpc-fn/live/memory`.
14
+ *
15
+ * The backlog of a channel lives at `${channel}:backlog` (see {@link backlogKey}).
16
+ */
17
+ export interface PubSubTransport {
18
+ /**
19
+ * Publish every message, in order. With `backlog`, also append each payload
20
+ * to its channel's backlog, trim and refresh its TTL - atomically, so a
21
+ * message is never published without its backlog entry or vice versa.
22
+ */
23
+ publish(
24
+ messages: readonly PubSubMessage[],
25
+ backlog?: BacklogOptions
26
+ ): Promise<void>
27
+ /** The channel's backlog, oldest first. */
28
+ readBacklog(channel: string): Promise<string[]>
29
+ /**
30
+ * Deliver every payload published to `channel` until the returned function
31
+ * is called. `onLost` fires when the subscription can no longer deliver
32
+ * (connection closed for good); the caller resubscribes.
33
+ */
34
+ subscribe(
35
+ channel: string,
36
+ listener: (payload: string) => void,
37
+ onLost?: (error: Error) => void
38
+ ): Promise<() => Promise<void>>
39
+ }
40
+
41
+ export const backlogKey = (channel: string) => `${channel}:backlog`
package/src/logger.ts ADDED
@@ -0,0 +1,28 @@
1
+ export type LogAttributes = Record<string, unknown>
2
+
3
+ /** The logger handlers receive. Any app logger with these four methods fits. */
4
+ export interface FnLogger {
5
+ debug(message: string, attributes?: LogAttributes): void
6
+ info(message: string, attributes?: LogAttributes): void
7
+ warn(message: string, attributes?: LogAttributes): void
8
+ error(message: string, attributes?: LogAttributes): void
9
+ }
10
+
11
+ const format = (scope: string, message: string, attributes?: LogAttributes) =>
12
+ `[${scope}] ${message}${attributes ? ` ${JSON.stringify(attributes)}` : ''}`
13
+
14
+ /**
15
+ * Default logger: silent for `debug`/`info`, console for `warn`/`error`, so a
16
+ * library default never floods stdout with one line per call. Inject your own
17
+ * with `createFn({ logger })`.
18
+ */
19
+ export function createDefaultLogger(scope: string): FnLogger {
20
+ return {
21
+ debug() {},
22
+ info() {},
23
+ warn: (message, attributes) =>
24
+ console.warn(format(scope, message, attributes)),
25
+ error: (message, attributes) =>
26
+ console.error(format(scope, message, attributes))
27
+ }
28
+ }
package/src/mastra.ts ADDED
@@ -0,0 +1,140 @@
1
+ import type { RequestContext } from '@mastra/core/request-context'
2
+ import { createTool, type Tool } from '@mastra/core/tools'
3
+ import { type AnyProcedure, call } from '@orpc/server'
4
+ import {
5
+ passThroughOutputSchema,
6
+ rawInputSchema,
7
+ type ToolSchema
8
+ } from './json-schema.js'
9
+ import { readFnMeta, toToolName } from './meta.js'
10
+ import type { ProcedureInput, ProcedureOutput } from './types.js'
11
+
12
+ export { toToolName }
13
+
14
+ export type CreateMastraToolOptions = {
15
+ /** Override the tool id (defaults to the sanitized `fn` name). */
16
+ id?: string
17
+ /** Override the tool description (defaults to the route summary). */
18
+ description?: string
19
+ /** Gate execution behind the AI SDK tool-approval flow. */
20
+ requireApproval?: boolean
21
+ /**
22
+ * Accept a procedure without an input schema (the tool then takes an empty
23
+ * object). For no-input queries like `teams.list`.
24
+ */
25
+ allowMissingInputSchema?: boolean
26
+ onExecuteFinish?: (event: {
27
+ toolId: string
28
+ durationMs: number
29
+ outcome: 'success' | 'error'
30
+ }) => void
31
+ /** Key holding the oRPC context in Mastra's `RequestContext`. Default 'orpcContext'. */
32
+ contextKey?: string
33
+ }
34
+
35
+ /** What the tool takes: the procedure's raw input, or `{}` without a schema. */
36
+ export type MastraToolInput<TProc> =
37
+ unknown extends ProcedureInput<TProc>
38
+ ? Record<string, never>
39
+ : ProcedureInput<TProc>
40
+
41
+ /** A Mastra tool typed by the procedure's raw input and parsed output. */
42
+ export type MastraTool<TProc extends AnyProcedure> = Tool<
43
+ MastraToolInput<TProc>,
44
+ ProcedureOutput<TProc>
45
+ >
46
+
47
+ /** LLM tool parameters must be an object; other inputs are a type error. */
48
+ type ObjectInputCheck<TProc> =
49
+ MastraToolInput<TProc> extends Record<string, unknown>
50
+ ? unknown
51
+ : { 'orpc-fn: an agent tool needs an object input': ProcedureInput<TProc> }
52
+
53
+ /**
54
+ * Creates a Mastra tool from a procedure made with `fn()`. Execution calls
55
+ * the procedure with the oRPC context stored in the Mastra request context
56
+ * (`requestContext.set('orpcContext', context)`), so auth and guards apply.
57
+ *
58
+ * Mastra checks the input against the procedure's schema (with dates coerced
59
+ * from strings) but passes the raw input on for the procedure to parse;
60
+ * the output is the procedure's parsed result, passed through unchanged.
61
+ */
62
+ export function createMastraTool<TProc extends AnyProcedure>(
63
+ procedure: TProc & ObjectInputCheck<TProc>,
64
+ options: CreateMastraToolOptions = {}
65
+ ): MastraTool<TProc> {
66
+ const meta = readFnMeta(procedure)
67
+ const name = options.id ?? meta.name
68
+ if (!name) {
69
+ throw new Error(
70
+ 'Procedure must be created with fn() to use createMastraTool'
71
+ )
72
+ }
73
+ let inputSchema: ToolSchema | undefined
74
+ if (meta.inputSchema) {
75
+ inputSchema = rawInputSchema(meta.inputSchema)
76
+ if (inputSchema['~standard'].jsonSchema.input().type !== 'object') {
77
+ throw new Error(`${name} must take an object input to be an agent tool`)
78
+ }
79
+ } else if (!options.allowMissingInputSchema) {
80
+ throw new Error(
81
+ `${name} must declare an input schema to be exposed as an agent tool`
82
+ )
83
+ }
84
+
85
+ const id = options.id ?? toToolName(name)
86
+ const contextKey = options.contextKey ?? 'orpcContext'
87
+
88
+ const tool = createTool({
89
+ id,
90
+ description: options.description ?? meta.description,
91
+ requireApproval: options.requireApproval === true,
92
+ inputSchema:
93
+ inputSchema ??
94
+ ({
95
+ '~standard': {
96
+ version: 1,
97
+ vendor: 'orpc-fn',
98
+ validate: () => ({ value: {} }),
99
+ jsonSchema: {
100
+ input: () => ({ type: 'object', properties: {} }),
101
+ output: () => ({ type: 'object', properties: {} })
102
+ }
103
+ }
104
+ } satisfies ToolSchema),
105
+ ...(meta.outputSchema
106
+ ? { outputSchema: passThroughOutputSchema(meta.outputSchema) }
107
+ : {}),
108
+ execute: async (input, ctx) => {
109
+ const requestContext = ctx?.requestContext as RequestContext | undefined
110
+ const context = requestContext?.get(contextKey)
111
+ if (!context) {
112
+ throw new Error(
113
+ `createMastraTool execution requires ${contextKey} in the Mastra request context`
114
+ )
115
+ }
116
+ const startedAt = performance.now()
117
+ const finish = (outcome: 'success' | 'error') =>
118
+ options.onExecuteFinish?.({
119
+ toolId: id,
120
+ durationMs: performance.now() - startedAt,
121
+ outcome
122
+ })
123
+ try {
124
+ const result = await call(
125
+ procedure,
126
+ meta.inputSchema ? input : undefined,
127
+ ctx?.abortSignal ? { context, signal: ctx.abortSignal } : { context }
128
+ )
129
+ finish('success')
130
+ return result
131
+ } catch (error) {
132
+ finish('error')
133
+ throw error
134
+ }
135
+ }
136
+ })
137
+
138
+ // The runtime schemas are untyped `ToolSchema`s; `MastraTool` is the typed surface.
139
+ return tool as unknown as MastraTool<TProc>
140
+ }
package/src/mcp.ts ADDED
@@ -0,0 +1,73 @@
1
+ import {
2
+ type AnyProcedure,
3
+ type AnyRouter,
4
+ type TraverseContractProcedureCallbackOptions,
5
+ traverseContractProcedures
6
+ } from '@orpc/server'
7
+ import { rawInputSchema, type ToolSchema } from './json-schema.js'
8
+ import { readFnMeta, toToolName } from './meta.js'
9
+
10
+ export { toToolName }
11
+ export { rawInputSchema as toolInputSchema }
12
+ export type { ToolSchema }
13
+
14
+ /** Picks procedures; also fits oRPC's `OpenAPIHandler({ filter })`. */
15
+ export type ProcedureFilter = (
16
+ options: TraverseContractProcedureCallbackOptions
17
+ ) => boolean
18
+
19
+ export type McpTool = {
20
+ /** Tool name: the sanitized `fn` name (`user.me` becomes `user-me`). */
21
+ name: string
22
+ procedure: AnyProcedure
23
+ config: {
24
+ description?: string
25
+ /**
26
+ * Validates and coerces (date strings to `Date`) but returns the raw
27
+ * input: pass it to `call(procedure, args)`, which parses it.
28
+ */
29
+ inputSchema?: ToolSchema
30
+ annotations: { readOnlyHint: boolean }
31
+ }
32
+ }
33
+
34
+ /** Procedures whose route has `tag`: `listTools(router, { filter: hasTag('external') })`. */
35
+ export const hasTag =
36
+ (tag: string): ProcedureFilter =>
37
+ ({ contract }) =>
38
+ contract['~orpc'].route.tags?.includes(tag) ?? false
39
+
40
+ const isGet: ProcedureFilter = ({ contract }) =>
41
+ contract['~orpc'].route.method === 'GET'
42
+
43
+ /**
44
+ * 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)`.
47
+ *
48
+ * `readOnly` sets `readOnlyHint`; it defaults to GET routes, since an MCP
49
+ * client treats anything else as a mutation and may ask for approval.
50
+ */
51
+ export function listTools(
52
+ router: AnyRouter,
53
+ options: { filter?: ProcedureFilter; readOnly?: ProcedureFilter } = {}
54
+ ): McpTool[] {
55
+ const { filter, readOnly = isGet } = options
56
+ 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
+ })
72
+ return tools
73
+ }
package/src/meta.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { AnySchema } from '@orpc/contract'
2
+ import type { AnyProcedure } from '@orpc/server'
3
+
4
+ /**
5
+ * Key under which `fn()` stores its metadata in oRPC's procedure meta
6
+ * (`.meta()`). Living in oRPC's own meta means it survives everything that
7
+ * rebuilds a procedure (`os.router()`, `.use()` on a router, lazy routers),
8
+ * unlike a symbol property on the procedure object.
9
+ */
10
+ export const FN_META_KEY = 'orpc-fn'
11
+
12
+ export type StoredFnMeta = {
13
+ name: string
14
+ procedure: string
15
+ meta: Record<string, unknown>
16
+ }
17
+
18
+ export type FnMeta<TMeta extends object = Record<string, unknown>> = {
19
+ /** `fn({ name })`, also the OpenAPI operationId; undefined if not made by `fn()`. */
20
+ name: string | undefined
21
+ /** The `procedures` key the route was built from. */
22
+ procedure: string | undefined
23
+ /** The app's typed meta (`createFn({ meta })`) declared on this route. */
24
+ meta: Partial<TMeta>
25
+ /** Route summary, falling back to description, falling back to ''. */
26
+ description: string
27
+ summary: string | undefined
28
+ method: string | undefined
29
+ path: string | undefined
30
+ tags: readonly string[]
31
+ inputSchema: AnySchema | undefined
32
+ outputSchema: AnySchema | undefined
33
+ }
34
+
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
46
+ }
47
+
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'] ?? {}
53
+ const stored = def.meta?.[FN_META_KEY] as StoredFnMeta | undefined
54
+ const route = def.route ?? {}
55
+ return {
56
+ name: stored?.name,
57
+ procedure: stored?.procedure,
58
+ meta: (stored?.meta ?? {}) as Partial<TMeta>,
59
+ description: route.summary ?? route.description ?? '',
60
+ summary: route.summary,
61
+ method: route.method,
62
+ path: route.path,
63
+ tags: route.tags ?? [],
64
+ inputSchema: def.inputSchema,
65
+ outputSchema: def.outputSchema
66
+ }
67
+ }
68
+
69
+ /** Tool-safe name (`a-z0-9-_`): `Users.Get` becomes `users-get`. */
70
+ export function toToolName(name: string): string {
71
+ return name.toLowerCase().replace(/[^a-z0-9-_]/g, '-')
72
+ }