@nifrajs/mcp 1.0.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.
@@ -0,0 +1,397 @@
1
+ /**
2
+ * The pure MCP (Model Context Protocol) JSON-RPC dispatch — no I/O, no `Bun.*`, no side effects, so it
3
+ * unit-tests cleanly. A transport (stdio in `@nifrajs/cli`'s `mcp.ts`, Streamable-HTTP in {@link ./http.ts})
4
+ * wires this to a byte stream; the tools/resources are injected, so the protocol logic is exercised
5
+ * without spawning or reading streams.
6
+ *
7
+ * Beyond base MCP this also implements the server half of **MCP Apps (SEP-1865)** — interactive `ui://`
8
+ * widgets: a tool links a UI resource via `_meta.ui.resourceUri`, `tools/call` returns `structuredContent`
9
+ * for the widget to render, and `initialize` advertises the `io.modelcontextprotocol/ui` extension. All of
10
+ * it is additive — a tool whose handler returns a plain `string` behaves exactly as before.
11
+ */
12
+
13
+ export const PROTOCOL_VERSION = "2024-11-05"
14
+
15
+ /** The MIME type a UI resource MUST use so a host recognizes it as an MCP App widget (SEP-1865). */
16
+ export const UI_MIME = "text/html;profile=mcp-app"
17
+ /** The `capabilities.extensions` key advertising UI support in the `initialize` result (SEP-1865). */
18
+ export const UI_EXTENSION_KEY = "io.modelcontextprotocol/ui"
19
+
20
+ type RequestId = string | number
21
+ type JsonRpcId = RequestId | null
22
+ type ProgressToken = string | number
23
+
24
+ export interface McpToolContext {
25
+ readonly signal: AbortSignal
26
+ readonly requestId: JsonRpcId
27
+ readonly progressToken?: ProgressToken
28
+ readonly reportProgress: (progress: number, total?: number) => void
29
+ }
30
+
31
+ /** A single content block in a tool result. Today only text — the model-facing representation. */
32
+ export interface McpContentBlock {
33
+ readonly type: "text"
34
+ readonly text: string
35
+ }
36
+
37
+ /**
38
+ * The rich result a tool handler may return instead of a bare string (MCP Apps). `content` is the
39
+ * model-facing text (also shown by text-only hosts); `structuredContent` is the data a linked `ui://`
40
+ * widget renders and is deliberately NOT added to the model's context; `_meta` carries the
41
+ * `ui.resourceUri` link and any render hints. Returning a `string` is shorthand for one text block.
42
+ */
43
+ export interface McpToolResult {
44
+ readonly content?: readonly McpContentBlock[]
45
+ readonly structuredContent?: Record<string, unknown>
46
+ readonly _meta?: Record<string, unknown>
47
+ readonly isError?: boolean
48
+ }
49
+
50
+ /** A tool the agent can call. `handler` returns the text shown to the agent, or a rich {@link McpToolResult}
51
+ * (for MCP Apps: structured data + a `ui://` widget link). `_meta` is the per-tool descriptor metadata
52
+ * surfaced in `tools/list` — for a widget tool it carries `ui.resourceUri`. */
53
+ export interface McpTool {
54
+ readonly name: string
55
+ readonly description: string
56
+ readonly inputSchema: Record<string, unknown>
57
+ readonly _meta?: Record<string, unknown>
58
+ readonly handler: (
59
+ args: Record<string, unknown>,
60
+ context: McpToolContext,
61
+ ) => Promise<string | McpToolResult>
62
+ }
63
+
64
+ export interface McpResource {
65
+ readonly uri: string
66
+ readonly name: string
67
+ readonly description?: string
68
+ readonly mimeType?: string
69
+ /** Descriptor `_meta` (MCP Apps: CSP / domain / border render hints under `ui`). */
70
+ readonly _meta?: Record<string, unknown>
71
+ readonly read: () => Promise<{ readonly text: string; readonly mimeType?: string }>
72
+ }
73
+
74
+ export interface McpPromptMessage {
75
+ readonly role: "user" | "assistant"
76
+ readonly content: { readonly type: "text"; readonly text: string }
77
+ }
78
+
79
+ export interface McpPrompt {
80
+ readonly name: string
81
+ readonly description: string
82
+ readonly arguments?: readonly {
83
+ readonly name: string
84
+ readonly description?: string
85
+ readonly required?: boolean
86
+ }[]
87
+ readonly handler: (args: Record<string, unknown>) => Promise<readonly McpPromptMessage[]>
88
+ }
89
+
90
+ export interface McpServerFeatures {
91
+ readonly resources?: readonly McpResource[]
92
+ readonly prompts?: readonly McpPrompt[]
93
+ /** Advertise the MCP Apps UI extension in `initialize`. Present when the server serves `ui://` widgets;
94
+ * `mimeTypes` defaults to `[UI_MIME]`. */
95
+ readonly ui?: { readonly mimeTypes?: readonly string[] }
96
+ }
97
+
98
+ export interface JsonRpcRequest {
99
+ readonly jsonrpc?: string
100
+ readonly id?: JsonRpcId
101
+ readonly method?: string
102
+ readonly params?: Record<string, unknown>
103
+ }
104
+
105
+ export interface JsonRpcNotification {
106
+ readonly jsonrpc: "2.0"
107
+ readonly method: string
108
+ readonly params?: Record<string, unknown>
109
+ }
110
+
111
+ export type JsonRpcResponse =
112
+ | { jsonrpc: "2.0"; id: JsonRpcId; result: unknown }
113
+ | { jsonrpc: "2.0"; id: JsonRpcId; error: { code: number; message: string } }
114
+
115
+ export const rpcResult = (id: JsonRpcId, value: unknown): JsonRpcResponse => ({
116
+ jsonrpc: "2.0",
117
+ id,
118
+ result: value,
119
+ })
120
+ export const rpcError = (id: JsonRpcId, code: number, message: string): JsonRpcResponse => ({
121
+ jsonrpc: "2.0",
122
+ id,
123
+ error: { code, message },
124
+ })
125
+
126
+ export interface McpProtocolState {
127
+ readonly activeRequests: Map<string, AbortController>
128
+ }
129
+
130
+ export interface McpProtocolOptions {
131
+ readonly state?: McpProtocolState
132
+ readonly signal?: AbortSignal
133
+ readonly sendNotification?: (notification: JsonRpcNotification) => void
134
+ }
135
+
136
+ export function createMcpProtocolState(): McpProtocolState {
137
+ return { activeRequests: new Map() }
138
+ }
139
+
140
+ function requestKey(id: unknown): string | undefined {
141
+ if (typeof id === "string") return `s:${id}`
142
+ return typeof id === "number" && Number.isFinite(id) ? `n:${id}` : undefined
143
+ }
144
+
145
+ function progressTokenOf(params: Record<string, unknown> | undefined): ProgressToken | undefined {
146
+ const meta = params?._meta
147
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return undefined
148
+ const token = (meta as Record<string, unknown>).progressToken
149
+ return typeof token === "string" || (typeof token === "number" && Number.isFinite(token))
150
+ ? token
151
+ : undefined
152
+ }
153
+
154
+ function cancellationReason(params: Record<string, unknown> | undefined): string | undefined {
155
+ const reason = params?.reason
156
+ return typeof reason === "string" && reason.length > 0 ? reason : undefined
157
+ }
158
+
159
+ function linkAbortSignal(parent: AbortSignal | undefined, child: AbortController): () => void {
160
+ if (parent === undefined) return () => {}
161
+ if (parent.aborted) {
162
+ child.abort(parent.reason)
163
+ return () => {}
164
+ }
165
+ const abort = (): void => child.abort(parent.reason)
166
+ parent.addEventListener("abort", abort, { once: true })
167
+ return () => parent.removeEventListener("abort", abort)
168
+ }
169
+
170
+ function abortMessage(signal: AbortSignal): string {
171
+ const reason = signal.reason
172
+ if (typeof reason === "string" && reason.length > 0) return `Cancelled: ${reason}`
173
+ return "Cancelled"
174
+ }
175
+
176
+ function compactDescription(description: string): string {
177
+ const text = description.replace(/\s+/g, " ").trim()
178
+ const sentenceEnd = text.search(/[.!?](?:\s|$)/)
179
+ const sentence = sentenceEnd === -1 ? text : text.slice(0, sentenceEnd + 1)
180
+ if (sentence.length <= 120) return sentence
181
+ return `${sentence.slice(0, 117).trimEnd()}...`
182
+ }
183
+
184
+ /** Normalize a handler's return into the `tools/call` result object. A `string` becomes a single text
185
+ * block (base-MCP behavior, unchanged); a {@link McpToolResult} passes `content`/`structuredContent`
186
+ * through, and inherits the tool descriptor's `_meta` (the `ui.resourceUri` link) when it sets none. */
187
+ function toolCallResult(result: string | McpToolResult, tool: McpTool): Record<string, unknown> {
188
+ if (typeof result === "string") return { content: [{ type: "text", text: result }] }
189
+ const meta =
190
+ tool._meta !== undefined || result._meta !== undefined
191
+ ? { ...(tool._meta ?? {}), ...(result._meta ?? {}) }
192
+ : undefined
193
+ return {
194
+ content: result.content ?? [],
195
+ ...(result.structuredContent !== undefined
196
+ ? { structuredContent: result.structuredContent }
197
+ : {}),
198
+ ...(meta !== undefined ? { _meta: meta } : {}),
199
+ ...(result.isError === true ? { isError: true } : {}),
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Dispatch one JSON-RPC message against the given tools. Returns the response, or `null` for a
205
+ * notification (no reply). Tool errors are reported in-band (`isError`) so the agent can react to them.
206
+ */
207
+ export async function handleRpc(
208
+ message: JsonRpcRequest,
209
+ tools: readonly McpTool[],
210
+ serverInfo: { name: string; version: string },
211
+ features: McpServerFeatures = {},
212
+ options: McpProtocolOptions = {},
213
+ ): Promise<JsonRpcResponse | null> {
214
+ const { id, method, params } = message
215
+ const isNotification = id === undefined
216
+ const rid = id ?? null
217
+ const resources = features.resources ?? []
218
+ const prompts = features.prompts ?? []
219
+ const state = options.state ?? createMcpProtocolState()
220
+
221
+ switch (method) {
222
+ case "initialize":
223
+ return rpcResult(rid, {
224
+ protocolVersion: PROTOCOL_VERSION,
225
+ capabilities: {
226
+ tools: {},
227
+ ...(resources.length > 0 ? { resources: {} } : {}),
228
+ ...(prompts.length > 0 ? { prompts: {} } : {}),
229
+ ...(features.ui !== undefined
230
+ ? {
231
+ extensions: {
232
+ [UI_EXTENSION_KEY]: { mimeTypes: features.ui.mimeTypes ?? [UI_MIME] },
233
+ },
234
+ }
235
+ : {}),
236
+ },
237
+ serverInfo,
238
+ })
239
+ case "notifications/initialized":
240
+ return null
241
+ case "notifications/cancelled": {
242
+ const key = requestKey(params?.requestId)
243
+ if (key !== undefined) state.activeRequests.get(key)?.abort(cancellationReason(params))
244
+ return null
245
+ }
246
+ case "ping":
247
+ return rpcResult(rid, {})
248
+ case "tools/list":
249
+ return rpcResult(
250
+ rid,
251
+ params?.compact === true
252
+ ? {
253
+ tools: tools.map((t) => ({
254
+ name: t.name,
255
+ description: compactDescription(t.description),
256
+ })),
257
+ }
258
+ : {
259
+ tools: tools.map((t) => ({
260
+ name: t.name,
261
+ description: t.description,
262
+ inputSchema: t.inputSchema,
263
+ ...(t._meta !== undefined ? { _meta: t._meta } : {}),
264
+ })),
265
+ },
266
+ )
267
+ case "tools/describe": {
268
+ const name = params?.name
269
+ const tool = tools.find((t) => t.name === name)
270
+ if (!tool) return rpcError(rid, -32602, `unknown tool: ${String(name)}`)
271
+ return rpcResult(rid, {
272
+ tool: {
273
+ name: tool.name,
274
+ description: tool.description,
275
+ inputSchema: tool.inputSchema,
276
+ ...(tool._meta !== undefined ? { _meta: tool._meta } : {}),
277
+ },
278
+ })
279
+ }
280
+ case "tools/call": {
281
+ const name = params?.name
282
+ const tool = tools.find((t) => t.name === name)
283
+ if (!tool) return rpcError(rid, -32602, `unknown tool: ${String(name)}`)
284
+ const args = (params?.arguments as Record<string, unknown>) ?? {}
285
+ const controller = new AbortController()
286
+ const key = requestKey(id)
287
+ const cleanupAbort = linkAbortSignal(options.signal, controller)
288
+ if (key !== undefined) state.activeRequests.set(key, controller)
289
+ const progressToken = progressTokenOf(params)
290
+ let lastProgress = Number.NEGATIVE_INFINITY
291
+ const reportProgress = (progress: number, total?: number): void => {
292
+ if (
293
+ progressToken === undefined ||
294
+ !Number.isFinite(progress) ||
295
+ progress <= lastProgress ||
296
+ (total !== undefined && !Number.isFinite(total))
297
+ ) {
298
+ return
299
+ }
300
+ lastProgress = progress
301
+ options.sendNotification?.({
302
+ jsonrpc: "2.0",
303
+ method: "notifications/progress",
304
+ params: {
305
+ progressToken,
306
+ progress,
307
+ ...(total !== undefined ? { total } : {}),
308
+ },
309
+ })
310
+ }
311
+ try {
312
+ reportProgress(0, 1)
313
+ if (controller.signal.aborted) throw new DOMException("cancelled", "AbortError")
314
+ const result = await tool.handler(args, {
315
+ signal: controller.signal,
316
+ requestId: rid,
317
+ ...(progressToken !== undefined ? { progressToken } : {}),
318
+ reportProgress,
319
+ })
320
+ if (controller.signal.aborted) throw new DOMException("cancelled", "AbortError")
321
+ reportProgress(1, 1)
322
+ return rpcResult(rid, toolCallResult(result, tool))
323
+ } catch (err) {
324
+ if (controller.signal.aborted) {
325
+ return rpcResult(rid, {
326
+ content: [{ type: "text", text: abortMessage(controller.signal) }],
327
+ isError: true,
328
+ })
329
+ }
330
+ const msg = err instanceof Error ? err.message : String(err)
331
+ return rpcResult(rid, { content: [{ type: "text", text: `Error: ${msg}` }], isError: true })
332
+ } finally {
333
+ cleanupAbort()
334
+ if (key !== undefined && state.activeRequests.get(key) === controller) {
335
+ state.activeRequests.delete(key)
336
+ }
337
+ }
338
+ }
339
+ case "resources/list":
340
+ return rpcResult(rid, {
341
+ resources: resources.map((r) => ({
342
+ uri: r.uri,
343
+ name: r.name,
344
+ ...(r.description !== undefined ? { description: r.description } : {}),
345
+ ...(r.mimeType !== undefined ? { mimeType: r.mimeType } : {}),
346
+ ...(r._meta !== undefined ? { _meta: r._meta } : {}),
347
+ })),
348
+ })
349
+ case "resources/read": {
350
+ const uri = params?.uri
351
+ const resource = resources.find((r) => r.uri === uri)
352
+ if (!resource) return rpcError(rid, -32602, `unknown resource: ${String(uri)}`)
353
+ try {
354
+ const content = await resource.read()
355
+ return rpcResult(rid, {
356
+ contents: [
357
+ {
358
+ uri: resource.uri,
359
+ mimeType: content.mimeType ?? resource.mimeType ?? "text/plain",
360
+ text: content.text,
361
+ ...(resource._meta !== undefined ? { _meta: resource._meta } : {}),
362
+ },
363
+ ],
364
+ })
365
+ } catch {
366
+ // Don't surface the raw read error — it can leak a filesystem path. Return a generic message.
367
+ return rpcError(rid, -32000, `failed to read resource: ${String(uri)}`)
368
+ }
369
+ }
370
+ case "prompts/list":
371
+ return rpcResult(rid, {
372
+ prompts: prompts.map((p) => ({
373
+ name: p.name,
374
+ description: p.description,
375
+ ...(p.arguments !== undefined ? { arguments: p.arguments } : {}),
376
+ })),
377
+ })
378
+ case "prompts/get": {
379
+ const name = params?.name
380
+ const prompt = prompts.find((p) => p.name === name)
381
+ if (!prompt) return rpcError(rid, -32602, `unknown prompt: ${String(name)}`)
382
+ const args = (params?.arguments as Record<string, unknown>) ?? {}
383
+ try {
384
+ return rpcResult(rid, {
385
+ description: prompt.description,
386
+ messages: await prompt.handler(args),
387
+ })
388
+ } catch (err) {
389
+ const msg = err instanceof Error ? err.message : String(err)
390
+ return rpcError(rid, -32000, msg)
391
+ }
392
+ }
393
+ default:
394
+ if (isNotification) return null
395
+ return rpcError(rid, -32601, `method not found: ${String(method)}`)
396
+ }
397
+ }
package/src/react.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `reactWidget` — author an MCP Apps (`ui://`) widget from a React component instead of hand-written HTML.
3
+ *
4
+ * The component is bundled for the browser (via `Bun.build`) together with a bootstrap that mounts it and
5
+ * re-renders on every `structuredContent` the host pushes — `mcpApp.onData(data => root.render(<C {...data}/>))`.
6
+ * So a widget author writes a normal nifra/React component that takes the tool's structured data as props;
7
+ * the bridge wiring and bundling are handled here. Client-rendered (the widget shell is the resource the
8
+ * host fetches once; data arrives over the bridge), which matches how a host renders MCP App widgets.
9
+ *
10
+ * `react` + `react-dom` resolve from the CONSUMER's `node_modules` at bundle time — they are NOT a
11
+ * dependency of `@nifrajs/mcp`. This entry is Bun-only (it calls `Bun.build`); import it from
12
+ * `@nifrajs/mcp/react`, not the package root, so the core stays runtime-agnostic and dependency-free.
13
+ */
14
+ import { randomBytes } from "node:crypto"
15
+ import { rm, writeFile } from "node:fs/promises"
16
+ import { basename, dirname, join } from "node:path"
17
+ import { defineMcpWidget, type McpWidget } from "./widget.ts"
18
+
19
+ export interface ReactWidgetOptions {
20
+ /** The widget's `ui://` resource URI, e.g. `ui://orders/table`. */
21
+ readonly uri: string
22
+ readonly name: string
23
+ readonly description?: string
24
+ readonly title?: string
25
+ /** Absolute path to a `.tsx`/`.jsx` module whose DEFAULT export is a React component. It receives the
26
+ * tool's `structuredContent` as props. */
27
+ readonly component: string
28
+ /** Extra `<head>` content (styles, fonts). The bridge + component bundle are injected for you. */
29
+ readonly head?: string
30
+ /** Minify the client bundle (default true). */
31
+ readonly minify?: boolean
32
+ }
33
+
34
+ /** Bundle `component` + a bootstrap into a self-contained browser IIFE that renders on pushed data. */
35
+ async function bundleComponent(componentPath: string, minify: boolean): Promise<string> {
36
+ const dir = dirname(componentPath)
37
+ // The bootstrap must sit next to the component so its relative import — and `react`/`react-dom`
38
+ // resolution up the tree — work against the consumer's node_modules. Unique name; removed in `finally`.
39
+ const bootPath = join(dir, `.nifra-mcp-widget.${randomBytes(6).toString("hex")}.tsx`)
40
+ const boot = `import { createElement } from "react"
41
+ import { createRoot } from "react-dom/client"
42
+ import Component from ${JSON.stringify(`./${basename(componentPath)}`)}
43
+ const el = document.getElementById("root")
44
+ if (el) {
45
+ const root = createRoot(el)
46
+ const draw = (data) => root.render(createElement(Component, data || {}))
47
+ const api = window.mcpApp
48
+ if (api) api.onData(draw); else draw({})
49
+ }`
50
+ try {
51
+ await writeFile(bootPath, boot)
52
+ const out = await Bun.build({
53
+ entrypoints: [bootPath],
54
+ target: "browser",
55
+ format: "iife",
56
+ minify,
57
+ define: { "process.env.NODE_ENV": JSON.stringify("production") },
58
+ })
59
+ if (!out.success) {
60
+ throw new Error(
61
+ `reactWidget: failed to bundle ${componentPath}:\n${out.logs.map((l) => String(l)).join("\n")}`,
62
+ )
63
+ }
64
+ const output = out.outputs[0]
65
+ if (output === undefined) throw new Error(`reactWidget: empty bundle for ${componentPath}`)
66
+ return await output.text()
67
+ } finally {
68
+ await rm(bootPath, { force: true })
69
+ }
70
+ }
71
+
72
+ /** Build a {@link McpWidget} from a React component. Async — it bundles the component at definition time
73
+ * (a one-time cost at server start); pass the result to `createMcpServer({ widgets })` / a tool's `widget`. */
74
+ export async function reactWidget(opts: ReactWidgetOptions): Promise<McpWidget> {
75
+ const bundle = await bundleComponent(opts.component, opts.minify ?? true)
76
+ // Escape any `</script` the bundle may contain (inside string/regex literals) so it can't terminate
77
+ // this inline <script> early — `<\/script` is equivalent JS but invisible to the HTML parser.
78
+ const safe = bundle.replace(/<\/(script)/gi, "<\\/$1")
79
+ return defineMcpWidget({
80
+ uri: opts.uri,
81
+ name: opts.name,
82
+ ...(opts.description !== undefined ? { description: opts.description } : {}),
83
+ ...(opts.title !== undefined ? { title: opts.title } : {}),
84
+ ...(opts.head !== undefined ? { head: opts.head } : {}),
85
+ html: `<div id="root"></div>\n<script>${safe}</script>`,
86
+ })
87
+ }
package/src/server.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `createMcpServer` — assemble a mountable MCP server for a nifra app, with MCP Apps (`ui://`) widgets.
3
+ *
4
+ * Pass tools (ideally from {@link ./tool.ts | defineMcpTool}) and widgets (from {@link ./widget.ts |
5
+ * defineMcpWidget}); the widgets' resources are registered and the `io.modelcontextprotocol/ui` capability
6
+ * is advertised automatically. Mount it in a nifra backend by handing the raw request to {@link McpServer.fetch}:
7
+ *
8
+ * ```ts
9
+ * const mcp = createMcpServer({ name: "orders", version: "1.0.0", tools, widgets })
10
+ * export const backend = server()
11
+ * .get("/mcp", (c) => mcp.fetch(c.req))
12
+ * .post("/mcp", (c) => mcp.fetch(c.req))
13
+ * ```
14
+ *
15
+ * {@link McpServer.handle} dispatches a single JSON-RPC message directly (for headless verification / tests).
16
+ *
17
+ * SECURITY: this has NO built-in authentication — once mounted, every tool is callable by anyone who can
18
+ * reach the route (the CORS header is `*` and no credentials are used, so it is effectively public). That
19
+ * is fine for read-only/public tools; if any tool mutates state or returns private data, gate the route
20
+ * yourself (check an `Authorization` header / session in the nifra handler before calling `mcp.fetch`).
21
+ */
22
+
23
+ import { respondMcpHttp } from "./http.ts"
24
+ import {
25
+ handleRpc,
26
+ type JsonRpcRequest,
27
+ type JsonRpcResponse,
28
+ type McpPrompt,
29
+ type McpResource,
30
+ type McpServerFeatures,
31
+ type McpTool,
32
+ UI_MIME,
33
+ } from "./protocol.ts"
34
+ import type { McpWidget } from "./widget.ts"
35
+
36
+ export interface CreateMcpServerOptions {
37
+ readonly name: string
38
+ readonly version: string
39
+ readonly tools?: readonly McpTool[]
40
+ /** MCP Apps widgets — their resources are registered and the UI capability is advertised. */
41
+ readonly widgets?: readonly McpWidget[]
42
+ /** Extra (non-widget) resources to expose. */
43
+ readonly resources?: readonly McpResource[]
44
+ readonly prompts?: readonly McpPrompt[]
45
+ /** GET health-page text. */
46
+ readonly health?: string
47
+ /** Max JSON-RPC body size in bytes (default 1 MB). */
48
+ readonly maxBodyBytes?: number
49
+ }
50
+
51
+ export interface McpServer {
52
+ readonly tools: McpTool[]
53
+ readonly features: McpServerFeatures
54
+ readonly serverInfo: { name: string; version: string }
55
+ /** Web `fetch` handler — mount at `POST /mcp` (GET is a health page, OPTIONS the CORS preflight). */
56
+ fetch(request: Request): Promise<Response>
57
+ /** Dispatch one JSON-RPC message directly (no HTTP) — for headless verification and unit tests. */
58
+ handle(message: JsonRpcRequest): Promise<JsonRpcResponse | null>
59
+ }
60
+
61
+ export function createMcpServer(opts: CreateMcpServerOptions): McpServer {
62
+ const tools = [...(opts.tools ?? [])]
63
+ const widgets = opts.widgets ?? []
64
+ // Widget resources first (a widget is discovered through its tool's _meta, but listing it is harmless
65
+ // and lets a host fetch it via resources/read).
66
+ const resources: McpResource[] = [...widgets.map((w) => w.resource), ...(opts.resources ?? [])]
67
+ const serverInfo = { name: opts.name, version: opts.version }
68
+ const features: McpServerFeatures = {
69
+ ...(resources.length > 0 ? { resources } : {}),
70
+ ...(opts.prompts !== undefined ? { prompts: opts.prompts } : {}),
71
+ ...(widgets.length > 0 ? { ui: { mimeTypes: [UI_MIME] } } : {}),
72
+ }
73
+ return {
74
+ tools,
75
+ features,
76
+ serverInfo,
77
+ fetch: (request) =>
78
+ respondMcpHttp(request, tools, serverInfo, {
79
+ features,
80
+ ...(opts.health !== undefined ? { health: opts.health } : {}),
81
+ ...(opts.maxBodyBytes !== undefined ? { maxBodyBytes: opts.maxBodyBytes } : {}),
82
+ }),
83
+ handle: (message) => handleRpc(message, tools, serverInfo, features),
84
+ }
85
+ }
package/src/tool.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `defineMcpTool` — author a tool for a nifra MCP server, optionally backed by an MCP Apps widget.
3
+ *
4
+ * The handler returns either a `string` (a plain text result, base MCP) or an object with `structuredContent`
5
+ * (the data a linked {@link ./widget.ts | widget} renders) and/or `text` (the model-facing summary). When a
6
+ * `widget` is passed, the tool advertises it via `_meta.ui.resourceUri` on both `tools/list` and each
7
+ * `tools/call` result, so the host knows to render the widget — see {@link ../protocol.ts}.
8
+ */
9
+
10
+ import type { McpContentBlock, McpTool, McpToolContext, McpToolResult } from "./protocol.ts"
11
+ import type { McpWidget } from "./widget.ts"
12
+
13
+ /** The ergonomic result an MCP-tool handler may return (coerced to the protocol's {@link McpToolResult}). */
14
+ export type McpToolHandlerResult =
15
+ | string
16
+ | {
17
+ /** Model-facing text (becomes a single text content block when `content` is omitted). */
18
+ readonly text?: string
19
+ /** Explicit content blocks (overrides `text`). */
20
+ readonly content?: readonly McpContentBlock[]
21
+ /** Structured data for the linked widget — NOT added to the model's context. */
22
+ readonly structuredContent?: Record<string, unknown>
23
+ readonly _meta?: Record<string, unknown>
24
+ readonly isError?: boolean
25
+ }
26
+
27
+ /**
28
+ * A render-intent hint for GENERATIVE hosts (e.g. the ShipNow builder): how to present the result's
29
+ * `structuredContent` when the host renders its OWN themed UI rather than an iframe widget. The host maps
30
+ * the intent to a component in its design system (a shadcn/Tailwind table, form, metric card, …). Open
31
+ * union — pick a known tag or a custom one.
32
+ */
33
+ export type McpUiIntent =
34
+ | "table"
35
+ | "list"
36
+ | "cards"
37
+ | "form"
38
+ | "metric"
39
+ | "detail"
40
+ | "chart"
41
+ | (string & {})
42
+
43
+ export interface DefineMcpToolOptions {
44
+ readonly name: string
45
+ readonly description: string
46
+ /** JSON Schema for the arguments. Defaults to an empty object schema. */
47
+ readonly inputSchema?: Record<string, unknown>
48
+ /** Link a widget so the tool's result renders as interactive UI in MCP Apps hosts (iframe). */
49
+ readonly widget?: McpWidget
50
+ /** Render-intent for generative hosts that build their own themed UI from `structuredContent`. Lands
51
+ * in `_meta.ui.intent`. Independent of `widget` — a tool can offer both (widget for MCP Apps hosts,
52
+ * intent for builders like ShipNow). */
53
+ readonly intent?: McpUiIntent
54
+ readonly handler: (
55
+ args: Record<string, unknown>,
56
+ context: McpToolContext,
57
+ ) => McpToolHandlerResult | Promise<McpToolHandlerResult>
58
+ }
59
+
60
+ /** Build a tool's descriptor `_meta` from its widget link and/or render intent (both under `ui`). */
61
+ function toolMeta(widget?: McpWidget, intent?: string): Record<string, unknown> | undefined {
62
+ if (widget === undefined && intent === undefined) return undefined
63
+ const ui: Record<string, unknown> = {}
64
+ if (widget !== undefined) ui.resourceUri = widget.uri
65
+ if (intent !== undefined) ui.intent = intent
66
+ const meta: Record<string, unknown> = { ui }
67
+ // Deprecated-flat alias for hosts that still read it.
68
+ if (widget !== undefined) meta["ui/resourceUri"] = widget.uri
69
+ return meta
70
+ }
71
+
72
+ const EMPTY_OBJECT_SCHEMA: Record<string, unknown> = {
73
+ type: "object",
74
+ properties: {},
75
+ additionalProperties: false,
76
+ }
77
+
78
+ function coerce(result: McpToolHandlerResult): string | McpToolResult {
79
+ if (typeof result === "string") return result
80
+ const content =
81
+ result.content ??
82
+ (result.text !== undefined ? [{ type: "text", text: result.text }] : undefined)
83
+ return {
84
+ ...(content !== undefined ? { content } : {}),
85
+ ...(result.structuredContent !== undefined
86
+ ? { structuredContent: result.structuredContent }
87
+ : {}),
88
+ ...(result._meta !== undefined ? { _meta: result._meta } : {}),
89
+ ...(result.isError === true ? { isError: true } : {}),
90
+ }
91
+ }
92
+
93
+ export function defineMcpTool(opts: DefineMcpToolOptions): McpTool {
94
+ const meta = toolMeta(opts.widget, opts.intent)
95
+ return {
96
+ name: opts.name,
97
+ description: opts.description,
98
+ inputSchema: opts.inputSchema ?? EMPTY_OBJECT_SCHEMA,
99
+ ...(meta !== undefined ? { _meta: meta } : {}),
100
+ handler: async (args, context) => coerce(await opts.handler(args, context)),
101
+ }
102
+ }