@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.
- package/dist/bridge.d.ts +40 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +107 -0
- package/dist/bridge.js.map +1 -0
- package/dist/http.d.ts +27 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +107 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +134 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +287 -0
- package/dist/protocol.js.map +1 -0
- package/dist/react.d.ts +19 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +71 -0
- package/dist/react.js.map +1 -0
- package/dist/server.d.ts +51 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +48 -0
- package/dist/server.js.map +1 -0
- package/dist/tool.d.ts +43 -0
- package/dist/tool.d.ts.map +1 -0
- package/dist/tool.js +53 -0
- package/dist/tool.js.map +1 -0
- package/dist/widget.d.ts +43 -0
- package/dist/widget.d.ts.map +1 -0
- package/dist/widget.js +58 -0
- package/dist/widget.js.map +1 -0
- package/package.json +65 -0
- package/src/bridge.ts +126 -0
- package/src/http.ts +131 -0
- package/src/index.ts +28 -0
- package/src/protocol.ts +397 -0
- package/src/react.ts +87 -0
- package/src/server.ts +85 -0
- package/src/tool.ts +102 -0
- package/src/widget.ts +84 -0
package/src/protocol.ts
ADDED
|
@@ -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
|
+
}
|