orpc-fn 0.2.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.
- package/README.md +28 -7
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +56 -0
- package/dist/cli.js.map +1 -0
- package/dist/create-fn.d.ts +3 -3
- package/dist/create-fn.d.ts.map +1 -1
- package/dist/create-fn.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/live/fn-live.d.ts +9 -5
- package/dist/live/fn-live.d.ts.map +1 -1
- package/dist/live/fn-live.js.map +1 -1
- package/dist/live/pub-sub.d.ts +5 -1
- package/dist/live/pub-sub.d.ts.map +1 -1
- package/dist/live/pub-sub.js +9 -2
- package/dist/live/pub-sub.js.map +1 -1
- package/dist/mcp/core.d.ts +41 -0
- package/dist/mcp/core.d.ts.map +1 -0
- package/dist/mcp/core.js +61 -0
- package/dist/mcp/core.js.map +1 -0
- package/dist/mcp/sdk.d.ts +8 -16
- package/dist/mcp/sdk.d.ts.map +1 -1
- package/dist/mcp/sdk.js +10 -78
- package/dist/mcp/sdk.js.map +1 -1
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +13 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/router.d.ts +8 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +13 -0
- package/dist/router.js.map +1 -1
- package/dist/types.d.ts +16 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +15 -2
- package/src/cli.ts +68 -0
- package/src/create-fn.ts +3 -4
- package/src/index.ts +2 -1
- package/src/live/fn-live.ts +9 -5
- package/src/live/pub-sub.ts +13 -4
- package/src/mcp/core.ts +102 -0
- package/src/mcp/sdk.ts +20 -102
- package/src/mcp/server.ts +35 -0
- package/src/router.ts +19 -0
- package/src/types.ts +24 -42
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orpc-fn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Define a backend function once: a typed oRPC procedure with tracing, logging, guards, injected handler extras, nested calls and live queries over Redis.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -74,9 +74,16 @@
|
|
|
74
74
|
"./mcp/sdk": {
|
|
75
75
|
"types": "./dist/mcp/sdk.d.ts",
|
|
76
76
|
"import": "./dist/mcp/sdk.js"
|
|
77
|
+
},
|
|
78
|
+
"./mcp/server": {
|
|
79
|
+
"types": "./dist/mcp/server.d.ts",
|
|
80
|
+
"import": "./dist/mcp/server.js"
|
|
77
81
|
}
|
|
78
82
|
},
|
|
79
83
|
"types": "./dist/index.d.ts",
|
|
84
|
+
"bin": {
|
|
85
|
+
"orpc-fn": "./dist/cli.js"
|
|
86
|
+
},
|
|
80
87
|
"files": [
|
|
81
88
|
"dist",
|
|
82
89
|
"LICENSE",
|
|
@@ -113,7 +120,8 @@
|
|
|
113
120
|
"hono": "^4.0.0",
|
|
114
121
|
"ioredis": "^5.0.0",
|
|
115
122
|
"zod": "^4.0.0",
|
|
116
|
-
"@modelcontextprotocol/sdk": "^1.32.0"
|
|
123
|
+
"@modelcontextprotocol/sdk": "^1.32.0",
|
|
124
|
+
"@modelcontextprotocol/server": "^2.0.0"
|
|
117
125
|
},
|
|
118
126
|
"peerDependenciesMeta": {
|
|
119
127
|
"@mastra/core": {
|
|
@@ -139,13 +147,18 @@
|
|
|
139
147
|
},
|
|
140
148
|
"@modelcontextprotocol/sdk": {
|
|
141
149
|
"optional": true
|
|
150
|
+
},
|
|
151
|
+
"@modelcontextprotocol/server": {
|
|
152
|
+
"optional": true
|
|
142
153
|
}
|
|
143
154
|
},
|
|
144
155
|
"devDependencies": {
|
|
145
156
|
"@arethetypeswrong/cli": "^0.18.2",
|
|
146
157
|
"@biomejs/biome": "^2.2.4",
|
|
147
158
|
"@mastra/core": "1.51.0",
|
|
159
|
+
"@modelcontextprotocol/client": "2.3.0",
|
|
148
160
|
"@modelcontextprotocol/sdk": "1.32.0",
|
|
161
|
+
"@modelcontextprotocol/server": "2.3.0",
|
|
149
162
|
"@opentelemetry/api": "^1.9.0",
|
|
150
163
|
"@orpc/client": "1.14.8",
|
|
151
164
|
"@orpc/contract": "1.14.8",
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// biome-ignore-all lint/suspicious/noConsole: a CLI reports on the console
|
|
3
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { resolve } from 'node:path'
|
|
5
|
+
import { pathToFileURL } from 'node:url'
|
|
6
|
+
import { parseArgs } from 'node:util'
|
|
7
|
+
import type { AnyRouter } from '@orpc/server'
|
|
8
|
+
import { createStreamManifestAsync, renderStreamManifest } from './router.js'
|
|
9
|
+
|
|
10
|
+
const usage = `Usage: orpc-fn stream-manifest <module>[#export] --out <file.ts|file.json> [--name streamPaths] [--check]
|
|
11
|
+
|
|
12
|
+
Imports the router (export "default" unless #export is given) and writes the
|
|
13
|
+
paths of its streaming routes for createRpcLink({ streamPaths }). The file is
|
|
14
|
+
only written when its content changes. --check writes nothing and exits 1 when
|
|
15
|
+
the file is missing or stale. Importing the router runs its module code; run
|
|
16
|
+
TypeScript routers with a runtime that loads .ts (bun, tsx, node >= 22.18).`
|
|
17
|
+
|
|
18
|
+
async function run(argv: readonly string[]): Promise<number> {
|
|
19
|
+
const { positionals, values } = parseArgs({
|
|
20
|
+
args: [...argv],
|
|
21
|
+
allowPositionals: true,
|
|
22
|
+
options: {
|
|
23
|
+
out: { type: 'string' },
|
|
24
|
+
name: { type: 'string' },
|
|
25
|
+
check: { type: 'boolean' },
|
|
26
|
+
help: { type: 'boolean', short: 'h' }
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
const [command, target] = positionals
|
|
30
|
+
if (values.help || command !== 'stream-manifest' || !target || !values.out) {
|
|
31
|
+
console.error(usage)
|
|
32
|
+
return values.help ? 0 : 2
|
|
33
|
+
}
|
|
34
|
+
const [modulePath = '', exportName = 'default'] = target.split('#')
|
|
35
|
+
const loaded: Record<string, unknown> = await import(
|
|
36
|
+
pathToFileURL(resolve(modulePath)).href
|
|
37
|
+
)
|
|
38
|
+
const router = loaded[exportName]
|
|
39
|
+
if (!router || typeof router !== 'object') {
|
|
40
|
+
console.error(`orpc-fn: ${modulePath} has no router export "${exportName}"`)
|
|
41
|
+
return 2
|
|
42
|
+
}
|
|
43
|
+
const out = resolve(values.out)
|
|
44
|
+
const source = renderStreamManifest(
|
|
45
|
+
await createStreamManifestAsync(router as AnyRouter),
|
|
46
|
+
{ format: out.endsWith('.json') ? 'json' : 'ts', name: values.name }
|
|
47
|
+
)
|
|
48
|
+
const current = await readFile(out, 'utf8').catch(() => undefined)
|
|
49
|
+
if (current === source) return 0
|
|
50
|
+
if (values.check) {
|
|
51
|
+
console.error(
|
|
52
|
+
`orpc-fn: ${values.out} is stale; rerun without --check to update it`
|
|
53
|
+
)
|
|
54
|
+
return 1
|
|
55
|
+
}
|
|
56
|
+
await writeFile(out, source)
|
|
57
|
+
console.log(`orpc-fn: wrote ${values.out}`)
|
|
58
|
+
return 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Exit explicitly: the imported router may hold open database or broker handles.
|
|
62
|
+
run(process.argv.slice(2)).then(
|
|
63
|
+
(code) => process.exit(code),
|
|
64
|
+
(error) => {
|
|
65
|
+
console.error(error)
|
|
66
|
+
process.exit(1)
|
|
67
|
+
}
|
|
68
|
+
)
|
package/src/create-fn.ts
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
type CurrentContextOf,
|
|
39
39
|
type BuilderLike,
|
|
40
40
|
type FiniteExtras,
|
|
41
|
-
type
|
|
41
|
+
type ScopedExtrasGuard,
|
|
42
42
|
type Fn,
|
|
43
43
|
type FnCompletedEvent,
|
|
44
44
|
type FnDefinition,
|
|
@@ -115,7 +115,7 @@ export type CreateFnOptions<
|
|
|
115
115
|
signal: AbortSignal | undefined
|
|
116
116
|
name: string
|
|
117
117
|
procedure: K
|
|
118
|
-
}) => MaybePromise<TScoped[K]
|
|
118
|
+
}) => MaybePromise<TScoped[K] & ScopedExtrasGuard<NoInfer<TScoped[K]>>>
|
|
119
119
|
}
|
|
120
120
|
/**
|
|
121
121
|
* Checks run before the handler; each key becomes a typed `fn()` option.
|
|
@@ -240,8 +240,7 @@ export function createFn<
|
|
|
240
240
|
TLogger,
|
|
241
241
|
TScoped
|
|
242
242
|
> &
|
|
243
|
-
FiniteExtras<NoInfer<TExtras>>
|
|
244
|
-
FiniteScopedExtras<NoInfer<TScoped>>
|
|
243
|
+
FiniteExtras<NoInfer<TExtras>>
|
|
245
244
|
): FnFactory<
|
|
246
245
|
Definition<
|
|
247
246
|
TProcedures,
|
package/src/index.ts
CHANGED
package/src/live/fn-live.ts
CHANGED
|
@@ -66,10 +66,14 @@ export type FnLiveConfig<
|
|
|
66
66
|
ChannelQueueOptions & {
|
|
67
67
|
/** Subscribe route name; default `${name}.live`. */
|
|
68
68
|
name?: string
|
|
69
|
-
/**
|
|
70
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Validates reducer state without reapplying an output transform. Required with a
|
|
71
|
+
* transformer. Its input may be looser than its output (e.g. branded IDs); it is
|
|
72
|
+
* checked at runtime.
|
|
73
|
+
*/
|
|
74
|
+
stateSchema?: Schema<unknown, TOutput>
|
|
71
75
|
/** Validates patch payloads. Required when a transformer returns fnLivePatch. */
|
|
72
|
-
emitSchema?: Schema<
|
|
76
|
+
emitSchema?: Schema<unknown, TEmit>
|
|
73
77
|
/** Optional access check before each update; idle streams keep their initial authorization. */
|
|
74
78
|
reauthorize?: AuthFn<TInput, FnContext<TDef, TKey>>
|
|
75
79
|
eventSchema: TEventSchema
|
|
@@ -122,10 +126,10 @@ export type FnLiveConfig<
|
|
|
122
126
|
TEmit,
|
|
123
127
|
TErrors
|
|
124
128
|
>
|
|
125
|
-
stateSchema: Schema<
|
|
129
|
+
stateSchema: Schema<unknown, TOutput>
|
|
126
130
|
}
|
|
127
131
|
) &
|
|
128
|
-
([TEmit] extends [never] ? object : { emitSchema: Schema<
|
|
132
|
+
([TEmit] extends [never] ? object : { emitSchema: Schema<unknown, TEmit> })
|
|
129
133
|
|
|
130
134
|
type FnLiveConfigTransformer<
|
|
131
135
|
TDef extends FnDefinition,
|
package/src/live/pub-sub.ts
CHANGED
|
@@ -204,7 +204,11 @@ export type CreatePublisher = <TEventSchema extends ZodType>(
|
|
|
204
204
|
export type PubSubRuntimeOptions = {
|
|
205
205
|
/** Queue depths, drops, successful reconnects and parse failures. */
|
|
206
206
|
onMetric?: (event: PubSubMetric) => void
|
|
207
|
-
|
|
207
|
+
/**
|
|
208
|
+
* The broker, or a function creating it on first use so importing routes
|
|
209
|
+
* opens no connection: `transport: () => bunRedisTransport(getRedis())`.
|
|
210
|
+
*/
|
|
211
|
+
transport: PubSubTransport | (() => PubSubTransport)
|
|
208
212
|
/**
|
|
209
213
|
* Called per event dropped from ingress, replay or delivery queues.
|
|
210
214
|
* Delivery drops count events before subscriber filtering.
|
|
@@ -525,13 +529,19 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
525
529
|
// the client's backoff reconnects to the new deployment.
|
|
526
530
|
const activeSubscriberCleanups = new Set<() => void>()
|
|
527
531
|
|
|
532
|
+
let transport: PubSubTransport | undefined
|
|
528
533
|
const requireTransport = () => {
|
|
529
534
|
if (!options.pubsub) {
|
|
530
535
|
throw new Error(
|
|
531
536
|
'orpc-fn: live routes need a transport, pass createFn({ pubsub: { transport } })'
|
|
532
537
|
)
|
|
533
538
|
}
|
|
534
|
-
return
|
|
539
|
+
if (transport) return transport
|
|
540
|
+
// A lazy transport is never created during or after shutdown.
|
|
541
|
+
assertOpen()
|
|
542
|
+
const configured = options.pubsub.transport
|
|
543
|
+
transport = typeof configured === 'function' ? configured() : configured
|
|
544
|
+
return transport
|
|
535
545
|
}
|
|
536
546
|
|
|
537
547
|
/**
|
|
@@ -1241,8 +1251,7 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
1241
1251
|
shutdownPromise = (async () => {
|
|
1242
1252
|
await Promise.allSettled([...pendingOpens])
|
|
1243
1253
|
await Promise.all([...releases])
|
|
1244
|
-
if (options.pubsub?.ownsTransport)
|
|
1245
|
-
await options.pubsub.transport.close?.()
|
|
1254
|
+
if (options.pubsub?.ownsTransport) await transport?.close?.()
|
|
1246
1255
|
})()
|
|
1247
1256
|
return shutdownPromise
|
|
1248
1257
|
},
|
package/src/mcp/core.ts
ADDED
|
@@ -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
CHANGED
|
@@ -4,115 +4,33 @@ import {
|
|
|
4
4
|
ListToolsRequestSchema,
|
|
5
5
|
type CallToolResult
|
|
6
6
|
} from '@modelcontextprotocol/sdk/types.js'
|
|
7
|
+
import type { AnyRouter } from '@orpc/server'
|
|
7
8
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from '
|
|
12
|
-
import { isObjectJsonSchema } from '../json-schema.js'
|
|
13
|
-
import { listTools, type McpTool, type ProcedureFilter } from '../mcp.js'
|
|
9
|
+
createToolHandlers,
|
|
10
|
+
formatMcpResult,
|
|
11
|
+
type RegisterMcpToolsOptions as CoreOptions
|
|
12
|
+
} from './core.js'
|
|
14
13
|
|
|
15
|
-
export
|
|
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
|
-
}
|
|
14
|
+
export { formatMcpResult }
|
|
29
15
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
? value
|
|
35
|
-
: (JSON.stringify(value, (_key, item) =>
|
|
36
|
-
typeof item === 'bigint' ? item.toString() : item
|
|
37
|
-
) ?? 'null')
|
|
38
|
-
return { content: [{ type: 'text', text }] }
|
|
39
|
-
}
|
|
16
|
+
export type RegisterMcpToolsOptions<R extends AnyRouter> = CoreOptions<
|
|
17
|
+
R,
|
|
18
|
+
CallToolResult
|
|
19
|
+
>
|
|
40
20
|
|
|
41
|
-
/**
|
|
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
|
+
*/
|
|
42
25
|
export function registerMcpTools<R extends AnyRouter>(
|
|
43
26
|
server: Pick<Server, 'setRequestHandler'>,
|
|
44
27
|
router: R,
|
|
45
28
|
options: RegisterMcpToolsOptions<R>
|
|
46
29
|
): readonly string[] {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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()]
|
|
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
|
|
118
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/router.ts
CHANGED
|
@@ -51,3 +51,22 @@ export async function createStreamManifestAsync(
|
|
|
51
51
|
)
|
|
52
52
|
return paths
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Deterministic module source for a stream manifest: paths sorted, `json`
|
|
57
|
+
* as an array, `ts` as `export const <name>`. Used by `orpc-fn stream-manifest`.
|
|
58
|
+
*/
|
|
59
|
+
export function renderStreamManifest(
|
|
60
|
+
paths: readonly (readonly string[])[],
|
|
61
|
+
options: { format: 'json' | 'ts'; name?: string | undefined }
|
|
62
|
+
): string {
|
|
63
|
+
const lines = paths
|
|
64
|
+
.map(
|
|
65
|
+
(path) =>
|
|
66
|
+
`\t[${path.map((segment) => JSON.stringify(segment)).join(', ')}]`
|
|
67
|
+
)
|
|
68
|
+
.sort()
|
|
69
|
+
const array = lines.length ? `[\n${lines.join(',\n')}\n]` : '[]'
|
|
70
|
+
if (options.format === 'json') return `${array}\n`
|
|
71
|
+
return `// Generated by \`orpc-fn stream-manifest\`. Do not edit.\nexport const ${options.name ?? 'streamPaths'}: readonly (readonly string[])[] = ${array}\n`
|
|
72
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -140,6 +140,17 @@ export type RejectKeys<T, K extends PropertyKey, TMessage extends string> = {
|
|
|
140
140
|
[P in Extract<keyof T, K>]: TMessage
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/** Params every handler receives; extras may not reuse these names. */
|
|
144
|
+
type HandlerParamKey =
|
|
145
|
+
| 'input'
|
|
146
|
+
| 'context'
|
|
147
|
+
| 'call'
|
|
148
|
+
| 'signal'
|
|
149
|
+
| 'span'
|
|
150
|
+
| 'logger'
|
|
151
|
+
| 'errors'
|
|
152
|
+
| 'lastEventId'
|
|
153
|
+
|
|
143
154
|
/**
|
|
144
155
|
* Handler extras must have known keys: an index signature would claim every
|
|
145
156
|
* handler param. Intersected into `createFn`'s options to reject it.
|
|
@@ -149,17 +160,7 @@ export type FiniteExtras<T> = T extends unknown
|
|
|
149
160
|
? { extras: 'orpc-fn: extras need known keys, not an index signature' }
|
|
150
161
|
: number extends keyof T
|
|
151
162
|
? { extras: 'orpc-fn: extras need known keys, not an index signature' }
|
|
152
|
-
: Extract<
|
|
153
|
-
keyof T,
|
|
154
|
-
| 'input'
|
|
155
|
-
| 'context'
|
|
156
|
-
| 'call'
|
|
157
|
-
| 'signal'
|
|
158
|
-
| 'span'
|
|
159
|
-
| 'logger'
|
|
160
|
-
| 'errors'
|
|
161
|
-
| 'lastEventId'
|
|
162
|
-
> extends never
|
|
163
|
+
: Extract<keyof T, HandlerParamKey> extends never
|
|
163
164
|
? unknown
|
|
164
165
|
: { extras: 'orpc-fn: extras cannot shadow handler parameters' }
|
|
165
166
|
: never
|
|
@@ -272,14 +273,7 @@ export type HandlerParams<
|
|
|
272
273
|
: object
|
|
273
274
|
: object
|
|
274
275
|
>,
|
|
275
|
-
|
|
276
|
-
| 'context'
|
|
277
|
-
| 'call'
|
|
278
|
-
| 'signal'
|
|
279
|
-
| 'span'
|
|
280
|
-
| 'logger'
|
|
281
|
-
| 'errors'
|
|
282
|
-
| 'lastEventId'
|
|
276
|
+
HandlerParamKey
|
|
283
277
|
> & {
|
|
284
278
|
input: TInput
|
|
285
279
|
context: FnContext<TDef, TKey>
|
|
@@ -506,26 +500,14 @@ export type CompletedHookEvent<
|
|
|
506
500
|
)
|
|
507
501
|
}[keyof TProcedures & string]
|
|
508
502
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
| 'span'
|
|
521
|
-
| 'logger'
|
|
522
|
-
| 'errors'
|
|
523
|
-
| 'lastEventId'
|
|
524
|
-
> extends never
|
|
525
|
-
? never
|
|
526
|
-
: K
|
|
527
|
-
}[keyof T] extends never
|
|
528
|
-
? unknown
|
|
529
|
-
: {
|
|
530
|
-
extrasByProcedure: 'orpc-fn: scoped extras need finite keys and cannot shadow handler parameters'
|
|
531
|
-
}
|
|
503
|
+
/**
|
|
504
|
+
* Intersected into each scoped extras function's return type: rejects index
|
|
505
|
+
* signatures and handler-parameter names at the offending property. A
|
|
506
|
+
* top-level conditional over the whole map broke inference when zero-argument
|
|
507
|
+
* and context-reading functions were mixed.
|
|
508
|
+
*/
|
|
509
|
+
export type ScopedExtrasGuard<T> = (string extends keyof T
|
|
510
|
+
? { 'orpc-fn: scoped extras need finite keys': never }
|
|
511
|
+
: number extends keyof T
|
|
512
|
+
? { 'orpc-fn: scoped extras need finite keys': never }
|
|
513
|
+
: unknown) & { [K in HandlerParamKey]?: never }
|