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.
- package/README.md +48 -32
- package/dist/assemble-procedure.d.ts +13 -0
- package/dist/assemble-procedure.d.ts.map +1 -0
- package/dist/assemble-procedure.js +29 -0
- package/dist/assemble-procedure.js.map +1 -0
- package/dist/bound-call.d.ts +1 -1
- package/dist/bound-call.d.ts.map +1 -1
- package/dist/bound-call.js.map +1 -1
- package/dist/client.d.ts +4 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +4 -2
- package/dist/client.js.map +1 -1
- package/dist/compatibility.d.ts +16 -0
- package/dist/compatibility.d.ts.map +1 -0
- package/dist/compatibility.js +4 -0
- package/dist/compatibility.js.map +1 -0
- package/dist/create-fn.d.ts +30 -34
- package/dist/create-fn.d.ts.map +1 -1
- package/dist/create-fn.js +177 -121
- package/dist/create-fn.js.map +1 -1
- package/dist/expo.d.ts +11 -25
- package/dist/expo.d.ts.map +1 -1
- package/dist/expo.js +25 -30
- package/dist/expo.js.map +1 -1
- package/dist/hono.d.ts +12 -4
- package/dist/hono.d.ts.map +1 -1
- package/dist/hono.js +29 -12
- package/dist/hono.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/json-schema.d.ts +2 -0
- package/dist/json-schema.d.ts.map +1 -1
- package/dist/json-schema.js +11 -0
- package/dist/json-schema.js.map +1 -1
- package/dist/live/codec.d.ts +6 -1
- package/dist/live/codec.d.ts.map +1 -1
- package/dist/live/codec.js +14 -3
- package/dist/live/codec.js.map +1 -1
- package/dist/live/fn-live.d.ts +63 -22
- package/dist/live/fn-live.d.ts.map +1 -1
- package/dist/live/fn-live.js +98 -16
- package/dist/live/fn-live.js.map +1 -1
- package/dist/live/index.d.ts +1 -1
- package/dist/live/index.d.ts.map +1 -1
- package/dist/live/index.js.map +1 -1
- package/dist/live/ioredis.d.ts +4 -1
- package/dist/live/ioredis.d.ts.map +1 -1
- package/dist/live/ioredis.js +21 -2
- package/dist/live/ioredis.js.map +1 -1
- package/dist/live/lifecycle.d.ts +7 -0
- package/dist/live/lifecycle.d.ts.map +1 -0
- package/dist/live/lifecycle.js +36 -0
- package/dist/live/lifecycle.js.map +1 -0
- package/dist/live/memory.d.ts.map +1 -1
- package/dist/live/memory.js +4 -0
- package/dist/live/memory.js.map +1 -1
- package/dist/live/pub-sub.d.ts +76 -12
- package/dist/live/pub-sub.d.ts.map +1 -1
- package/dist/live/pub-sub.js +429 -125
- package/dist/live/pub-sub.js.map +1 -1
- package/dist/live/redis-bun.d.ts +1 -1
- package/dist/live/redis-bun.d.ts.map +1 -1
- package/dist/live/redis-bun.js +3 -5
- package/dist/live/redis-bun.js.map +1 -1
- package/dist/live/redis.d.ts +2 -2
- package/dist/live/redis.d.ts.map +1 -1
- package/dist/live/redis.js +36 -9
- package/dist/live/redis.js.map +1 -1
- package/dist/live/transport.d.ts +5 -1
- package/dist/live/transport.d.ts.map +1 -1
- package/dist/live/transport.js.map +1 -1
- package/dist/logger.d.ts +2 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +21 -0
- package/dist/logger.js.map +1 -1
- package/dist/mastra.d.ts.map +1 -1
- package/dist/mastra.js +16 -7
- package/dist/mastra.js.map +1 -1
- package/dist/mcp/sdk.d.ts +20 -0
- package/dist/mcp/sdk.d.ts.map +1 -0
- package/dist/mcp/sdk.js +82 -0
- package/dist/mcp/sdk.js.map +1 -0
- package/dist/mcp.d.ts +3 -2
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +22 -7
- package/dist/mcp.js.map +1 -1
- package/dist/meta.d.ts +15 -7
- package/dist/meta.d.ts.map +1 -1
- package/dist/meta.js +9 -2
- package/dist/meta.js.map +1 -1
- package/dist/otel.d.ts.map +1 -1
- package/dist/otel.js +77 -6
- package/dist/otel.js.map +1 -1
- package/dist/route-options.d.ts +22 -0
- package/dist/route-options.d.ts.map +1 -0
- package/dist/route-options.js +67 -0
- package/dist/route-options.js.map +1 -0
- package/dist/router.d.ts +5 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +22 -0
- package/dist/router.js.map +1 -1
- package/dist/timing.d.ts +8 -0
- package/dist/timing.d.ts.map +1 -0
- package/dist/timing.js +4 -0
- package/dist/timing.js.map +1 -0
- package/dist/types.d.ts +86 -41
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +6 -2
- package/dist/types.js.map +1 -1
- package/package.json +15 -3
- package/src/assemble-procedure.ts +42 -0
- package/src/bound-call.ts +4 -3
- package/src/client.ts +9 -2
- package/src/compatibility.ts +39 -0
- package/src/create-fn.ts +297 -183
- package/src/expo.ts +49 -44
- package/src/hono.ts +36 -17
- package/src/index.ts +6 -2
- package/src/json-schema.ts +16 -0
- package/src/live/codec.ts +22 -3
- package/src/live/fn-live.ts +244 -69
- package/src/live/index.ts +2 -0
- package/src/live/ioredis.ts +22 -2
- package/src/live/lifecycle.ts +44 -0
- package/src/live/memory.ts +4 -0
- package/src/live/pub-sub.ts +608 -166
- package/src/live/redis-bun.ts +7 -5
- package/src/live/redis.ts +37 -11
- package/src/live/transport.ts +4 -1
- package/src/logger.ts +20 -0
- package/src/mastra.ts +15 -7
- package/src/mcp/sdk.ts +118 -0
- package/src/mcp.ts +48 -20
- package/src/meta.ts +40 -24
- package/src/otel.ts +77 -10
- package/src/route-options.ts +100 -0
- package/src/router.ts +39 -1
- package/src/timing.ts +5 -0
- package/src/types.ts +204 -62
package/src/live/redis-bun.ts
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
69
|
-
.
|
|
70
|
-
|
|
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
|
-
|
|
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)
|
|
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 = {
|
|
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
|
}
|
package/src/live/transport.ts
CHANGED
|
@@ -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()
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
39
|
+
procedureDefinition(contract).route?.tags?.includes(tag) ?? false
|
|
39
40
|
|
|
40
41
|
const isGet: ProcedureFilter = ({ contract }) =>
|
|
41
|
-
contract
|
|
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).
|
|
46
|
-
*
|
|
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: {
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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:
|
|
15
|
-
meta:
|
|
18
|
+
procedure: TKey
|
|
19
|
+
meta: TMeta
|
|
20
|
+
stream: boolean
|
|
16
21
|
}
|
|
17
22
|
|
|
18
|
-
export type FnMeta<
|
|
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:
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
+
}
|