@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/dist/widget.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineMcpWidget` — author an MCP Apps (`ui://`) interactive widget for a nifra MCP server.
|
|
3
|
+
*
|
|
4
|
+
* A widget is ONE self-contained HTML document, served as an MCP resource with MIME {@link UI_MIME}. The
|
|
5
|
+
* host renders it in a sandboxed iframe and pushes the linked tool's `structuredContent` in over the
|
|
6
|
+
* {@link ./bridge.ts} bridge — so the author writes plain markup + `mcpApp.onData(render)`, no transport
|
|
7
|
+
* glue. Link it to a tool with {@link ./tool.ts | defineMcpTool({ widget })}, which stamps the tool's
|
|
8
|
+
* `_meta.ui.resourceUri` from {@link McpWidget.meta}.
|
|
9
|
+
*
|
|
10
|
+
* SECURITY: `structuredContent` (and any theme) arriving over the bridge is UNTRUSTED — escape it before
|
|
11
|
+
* writing to `innerHTML`, or render with a framework that escapes (React/Preact, or `@nifrajs/mcp/react`).
|
|
12
|
+
* The sandboxed iframe limits blast radius, but a widget that builds markup by string concatenation can
|
|
13
|
+
* still XSS itself; the bridge passes data through, it does not sanitize.
|
|
14
|
+
*/
|
|
15
|
+
import { type McpResource } from "./protocol.js";
|
|
16
|
+
export interface DefineMcpWidgetOptions {
|
|
17
|
+
/** The widget's resource URI — MUST use the `ui://` scheme, e.g. `ui://orders/table`. */
|
|
18
|
+
readonly uri: string;
|
|
19
|
+
/** Human-readable name (shown by hosts that list resources). */
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly description?: string;
|
|
22
|
+
/** Document title (defaults to `name`). */
|
|
23
|
+
readonly title?: string;
|
|
24
|
+
/** Extra `<head>` content — `<style>`, `<meta>`, fonts. The bridge `<script>` is injected for you. */
|
|
25
|
+
readonly head?: string;
|
|
26
|
+
/** The widget body: markup plus a `<script>` that renders from `mcpApp.onData(...)` and may
|
|
27
|
+
* `mcpApp.callTool(...)`. The bridge global is defined before this runs. */
|
|
28
|
+
readonly html: string;
|
|
29
|
+
}
|
|
30
|
+
/** The MCP Apps `_meta.ui.resourceUri` link, emitted nested (current spec) AND flat (the deprecated
|
|
31
|
+
* `ui/resourceUri`, still read by some hosts) so a tool renders across the transition. */
|
|
32
|
+
export declare function uiResourceMeta(uri: string): Record<string, unknown>;
|
|
33
|
+
/** A widget: the resource to register on the server, its `ui://` URI, and the `_meta` link for its tool. */
|
|
34
|
+
export interface McpWidget {
|
|
35
|
+
readonly uri: string;
|
|
36
|
+
readonly resource: McpResource;
|
|
37
|
+
readonly meta: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
/** Assemble the full self-contained widget document (bridge inlined in `<head>` so body scripts can use
|
|
40
|
+
* `mcpApp` immediately). */
|
|
41
|
+
export declare function widgetDocument(opts: DefineMcpWidgetOptions): string;
|
|
42
|
+
export declare function defineMcpWidget(opts: DefineMcpWidgetOptions): McpWidget;
|
|
43
|
+
//# sourceMappingURL=widget.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"widget.d.ts","sourceRoot":"","sources":["../src/widget.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,EAAE,KAAK,WAAW,EAAW,MAAM,eAAe,CAAA;AAEzD,MAAM,WAAW,sBAAsB;IACrC,yFAAyF;IACzF,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,gEAAgE;IAChE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,2CAA2C;IAC3C,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,sGAAsG;IACtG,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;IACtB;gFAC4E;IAC5E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED;0FAC0F;AAC1F,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEnE;AAED,4GAA4G;AAC5G,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAA;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACvC;AAMD;4BAC4B;AAC5B,wBAAgB,cAAc,CAAC,IAAI,EAAE,sBAAsB,GAAG,MAAM,CAgBnE;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,sBAAsB,GAAG,SAAS,CAavE"}
|
package/dist/widget.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineMcpWidget` — author an MCP Apps (`ui://`) interactive widget for a nifra MCP server.
|
|
3
|
+
*
|
|
4
|
+
* A widget is ONE self-contained HTML document, served as an MCP resource with MIME {@link UI_MIME}. The
|
|
5
|
+
* host renders it in a sandboxed iframe and pushes the linked tool's `structuredContent` in over the
|
|
6
|
+
* {@link ./bridge.ts} bridge — so the author writes plain markup + `mcpApp.onData(render)`, no transport
|
|
7
|
+
* glue. Link it to a tool with {@link ./tool.ts | defineMcpTool({ widget })}, which stamps the tool's
|
|
8
|
+
* `_meta.ui.resourceUri` from {@link McpWidget.meta}.
|
|
9
|
+
*
|
|
10
|
+
* SECURITY: `structuredContent` (and any theme) arriving over the bridge is UNTRUSTED — escape it before
|
|
11
|
+
* writing to `innerHTML`, or render with a framework that escapes (React/Preact, or `@nifrajs/mcp/react`).
|
|
12
|
+
* The sandboxed iframe limits blast radius, but a widget that builds markup by string concatenation can
|
|
13
|
+
* still XSS itself; the bridge passes data through, it does not sanitize.
|
|
14
|
+
*/
|
|
15
|
+
import { bridgeScript } from "./bridge.js";
|
|
16
|
+
import { UI_MIME } from "./protocol.js";
|
|
17
|
+
/** The MCP Apps `_meta.ui.resourceUri` link, emitted nested (current spec) AND flat (the deprecated
|
|
18
|
+
* `ui/resourceUri`, still read by some hosts) so a tool renders across the transition. */
|
|
19
|
+
export function uiResourceMeta(uri) {
|
|
20
|
+
return { ui: { resourceUri: uri }, "ui/resourceUri": uri };
|
|
21
|
+
}
|
|
22
|
+
function escapeHtml(text) {
|
|
23
|
+
return text.replace(/[&<>]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : ">"));
|
|
24
|
+
}
|
|
25
|
+
/** Assemble the full self-contained widget document (bridge inlined in `<head>` so body scripts can use
|
|
26
|
+
* `mcpApp` immediately). */
|
|
27
|
+
export function widgetDocument(opts) {
|
|
28
|
+
const title = escapeHtml(opts.title ?? opts.name);
|
|
29
|
+
return `<!doctype html>
|
|
30
|
+
<html lang="en">
|
|
31
|
+
<head>
|
|
32
|
+
<meta charset="utf-8" />
|
|
33
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
34
|
+
<title>${title}</title>
|
|
35
|
+
<script>${bridgeScript()}</script>
|
|
36
|
+
${opts.head ?? ""}
|
|
37
|
+
</head>
|
|
38
|
+
<body>
|
|
39
|
+
${opts.html}
|
|
40
|
+
</body>
|
|
41
|
+
</html>
|
|
42
|
+
`;
|
|
43
|
+
}
|
|
44
|
+
export function defineMcpWidget(opts) {
|
|
45
|
+
if (!opts.uri.startsWith("ui://")) {
|
|
46
|
+
throw new Error(`defineMcpWidget: uri must use the ui:// scheme (got "${opts.uri}")`);
|
|
47
|
+
}
|
|
48
|
+
const html = widgetDocument(opts);
|
|
49
|
+
const resource = {
|
|
50
|
+
uri: opts.uri,
|
|
51
|
+
name: opts.name,
|
|
52
|
+
...(opts.description !== undefined ? { description: opts.description } : {}),
|
|
53
|
+
mimeType: UI_MIME,
|
|
54
|
+
read: () => Promise.resolve({ text: html, mimeType: UI_MIME }),
|
|
55
|
+
};
|
|
56
|
+
return { uri: opts.uri, resource, meta: uiResourceMeta(opts.uri) };
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=widget.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"widget.js","sourceRoot":"","sources":["../src/widget.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,EAAoB,OAAO,EAAE,MAAM,eAAe,CAAA;AAiBzD;0FAC0F;AAC1F,MAAM,UAAU,cAAc,CAAC,GAAW;IACxC,OAAO,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,GAAG,EAAE,EAAE,gBAAgB,EAAE,GAAG,EAAE,CAAA;AAC5D,CAAC;AASD,SAAS,UAAU,CAAC,IAAY;IAC9B,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAA;AAC3F,CAAC;AAED;4BAC4B;AAC5B,MAAM,UAAU,cAAc,CAAC,IAA4B;IACzD,MAAM,KAAK,GAAG,UAAU,CAAC,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,IAAI,CAAC,CAAA;IACjD,OAAO;;;;;SAKA,KAAK;UACJ,YAAY,EAAE;EACtB,IAAI,CAAC,IAAI,IAAI,EAAE;;;EAGf,IAAI,CAAC,IAAI;;;CAGV,CAAA;AACD,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,IAA4B;IAC1D,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;QAClC,MAAM,IAAI,KAAK,CAAC,wDAAwD,IAAI,CAAC,GAAG,IAAI,CAAC,CAAA;IACvF,CAAC;IACD,MAAM,IAAI,GAAG,cAAc,CAAC,IAAI,CAAC,CAAA;IACjC,MAAM,QAAQ,GAAgB;QAC5B,GAAG,EAAE,IAAI,CAAC,GAAG;QACb,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,GAAG,CAAC,IAAI,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC5E,QAAQ,EAAE,OAAO;QACjB,IAAI,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC;KAC/D,CAAA;IACD,OAAO,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAA;AACpE,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Build MCP servers — and MCP Apps (interactive `ui://` widgets, SEP-1865) — for a nifra app. The transport-agnostic JSON-RPC core (also used by `@nifrajs/cli`'s dev MCP), a Streamable-HTTP handler you mount at `POST /mcp`, and `defineMcpWidget`/`defineMcpTool` for tool results that render real UI in MCP hosts.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"typescript",
|
|
8
|
+
"nifra",
|
|
9
|
+
"mcp",
|
|
10
|
+
"model-context-protocol",
|
|
11
|
+
"mcp-apps",
|
|
12
|
+
"mcp-ui",
|
|
13
|
+
"json-rpc",
|
|
14
|
+
"ai",
|
|
15
|
+
"tools",
|
|
16
|
+
"widgets"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"module": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"bun": "./src/index.ts",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./protocol": {
|
|
29
|
+
"bun": "./src/protocol.ts",
|
|
30
|
+
"types": "./dist/protocol.d.ts",
|
|
31
|
+
"default": "./dist/protocol.js"
|
|
32
|
+
},
|
|
33
|
+
"./http": {
|
|
34
|
+
"bun": "./src/http.ts",
|
|
35
|
+
"types": "./dist/http.d.ts",
|
|
36
|
+
"default": "./dist/http.js"
|
|
37
|
+
},
|
|
38
|
+
"./react": {
|
|
39
|
+
"bun": "./src/react.ts",
|
|
40
|
+
"types": "./dist/react.d.ts",
|
|
41
|
+
"default": "./dist/react.js"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist",
|
|
46
|
+
"src"
|
|
47
|
+
],
|
|
48
|
+
"sideEffects": false,
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
|
51
|
+
},
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"repository": {
|
|
57
|
+
"type": "git",
|
|
58
|
+
"url": "git+https://github.com/nifrajs/nifra.git",
|
|
59
|
+
"directory": "packages/mcp"
|
|
60
|
+
},
|
|
61
|
+
"bugs": {
|
|
62
|
+
"url": "https://github.com/nifrajs/nifra/issues"
|
|
63
|
+
},
|
|
64
|
+
"homepage": "https://github.com/nifrajs/nifra#readme"
|
|
65
|
+
}
|
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tiny, dependency-free client bridge injected into every `ui://` widget's HTML (MCP Apps / SEP-1865).
|
|
3
|
+
* It speaks the MCP-JSON-RPC-over-`postMessage` subset a host exposes to an embedded UI:
|
|
4
|
+
*
|
|
5
|
+
* host → widget : `ui/notifications/tool-result` (the tools/call result incl. `structuredContent`),
|
|
6
|
+
* `ui/notifications/tool-input` (the call arguments), and `ui/notifications/theme`
|
|
7
|
+
* (the host's design tokens) — pushed as JSON-RPC notifications.
|
|
8
|
+
* widget → host : `tools/call` requests (id-matched) so a button can re-invoke a tool through the host.
|
|
9
|
+
*
|
|
10
|
+
* Theming follows the shadcn/Tailwind semantic-token convention (`--background`, `--foreground`,
|
|
11
|
+
* `--primary`, `--card`, `--border`, `--radius`, …). The host pushes its resolved tokens; the bridge
|
|
12
|
+
* applies them to the document root, so a widget that styles with `var(--primary)` etc. matches the host
|
|
13
|
+
* app (e.g. a ShipNow-generated app injecting its theme preset) with zero per-widget work.
|
|
14
|
+
*
|
|
15
|
+
* It exposes a small global the widget author scripts against, so authoring a widget is just
|
|
16
|
+
* `mcpApp.onData(render)` + (optionally) `mcpApp.callTool(name, args)` — no postMessage plumbing.
|
|
17
|
+
*
|
|
18
|
+
* Returned as a string so {@link ./widget.ts} can inline it in the resource HTML (a widget ships as ONE
|
|
19
|
+
* self-contained document; the host renders it in a sandboxed iframe with no network of its own).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The author-facing global injected into a widget. Kept minimal and stable. */
|
|
23
|
+
export interface McpAppBridge {
|
|
24
|
+
/** Latest `structuredContent` the host pushed (or `null` before the first result). */
|
|
25
|
+
data: unknown
|
|
26
|
+
/** Register a callback for `structuredContent` — fired immediately if data already arrived, then on
|
|
27
|
+
* every later `ui/notifications/tool-result`. The common case: `mcpApp.onData(render)`. */
|
|
28
|
+
onData(cb: (data: unknown) => void): void
|
|
29
|
+
/** Register a callback for the raw tool-call arguments (`ui/notifications/tool-input`). */
|
|
30
|
+
onInput(cb: (args: unknown) => void): void
|
|
31
|
+
/** Latest theme the host pushed (`{ mode, tokens }`), or `null` before the first push. */
|
|
32
|
+
theme: unknown
|
|
33
|
+
/** Register a callback for host theme pushes. The bridge ALSO auto-applies the tokens to the document
|
|
34
|
+
* root (shadcn semantic vars + `color-scheme`/`data-theme`), so most widgets need no theme code. */
|
|
35
|
+
onTheme(cb: (theme: unknown) => void): void
|
|
36
|
+
/** Invoke a tool back through the host; resolves with the tools/call result. */
|
|
37
|
+
callTool(name: string, args?: Record<string, unknown>): Promise<unknown>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The bridge source, as a string for inlining in a `<script>`. Self-contained, no imports. */
|
|
41
|
+
export function bridgeScript(): string {
|
|
42
|
+
return BRIDGE_SOURCE
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Authored as a plain string (not a function we `.toString()`) so a bundler/minifier can't rename the
|
|
46
|
+
// global or strip it, and so it has zero dependency on this module's runtime. Intentionally ES5-ish for
|
|
47
|
+
// maximal host/iframe compatibility.
|
|
48
|
+
const BRIDGE_SOURCE = `(function () {
|
|
49
|
+
var pending = {};
|
|
50
|
+
var nextId = 1;
|
|
51
|
+
var dataCbs = [];
|
|
52
|
+
var inputCbs = [];
|
|
53
|
+
var themeCbs = [];
|
|
54
|
+
function applyTheme(theme) {
|
|
55
|
+
if (!theme || typeof theme !== "object") return;
|
|
56
|
+
var root = document.documentElement;
|
|
57
|
+
if (theme.mode === "light" || theme.mode === "dark") {
|
|
58
|
+
root.setAttribute("data-theme", theme.mode);
|
|
59
|
+
root.style.colorScheme = theme.mode;
|
|
60
|
+
}
|
|
61
|
+
var tokens = theme.tokens;
|
|
62
|
+
if (tokens && typeof tokens === "object") {
|
|
63
|
+
for (var k in tokens) {
|
|
64
|
+
if (Object.prototype.hasOwnProperty.call(tokens, k)) {
|
|
65
|
+
root.style.setProperty(k.charAt(0) === "-" ? k : "--" + k, String(tokens[k]));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
var api = {
|
|
71
|
+
data: null,
|
|
72
|
+
theme: null,
|
|
73
|
+
onData: function (cb) {
|
|
74
|
+
if (typeof cb !== "function") return;
|
|
75
|
+
dataCbs.push(cb);
|
|
76
|
+
if (api.data !== null) { try { cb(api.data); } catch (e) {} }
|
|
77
|
+
},
|
|
78
|
+
onInput: function (cb) { if (typeof cb === "function") inputCbs.push(cb); },
|
|
79
|
+
onTheme: function (cb) {
|
|
80
|
+
if (typeof cb !== "function") return;
|
|
81
|
+
themeCbs.push(cb);
|
|
82
|
+
if (api.theme !== null) { try { cb(api.theme); } catch (e) {} }
|
|
83
|
+
},
|
|
84
|
+
callTool: function (name, args) {
|
|
85
|
+
var id = "w" + nextId++;
|
|
86
|
+
return new Promise(function (resolve, reject) {
|
|
87
|
+
pending[id] = { resolve: resolve, reject: reject };
|
|
88
|
+
post({ jsonrpc: "2.0", id: id, method: "tools/call", params: { name: name, arguments: args || {} } });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
function post(msg) { try { (window.parent || window).postMessage(msg, "*"); } catch (e) {} }
|
|
93
|
+
function emit(list, value) { for (var i = 0; i < list.length; i++) { try { list[i](value); } catch (e) {} } }
|
|
94
|
+
window.addEventListener("message", function (event) {
|
|
95
|
+
// Only trust the embedding host (the parent window). A widget renders in a sandboxed iframe whose host
|
|
96
|
+
// posts via the parent (or a sandbox-proxy parent), so event.source === window.parent. Reject anything
|
|
97
|
+
// else — including a null source (synthetic/dispatched events) — so a sibling/opener frame or in-page
|
|
98
|
+
// script can't spoof tool-results, tool-call responses, or theme. (A top-level widget has no host.)
|
|
99
|
+
if (event.source !== window.parent || window.parent === window) return;
|
|
100
|
+
var msg = event.data;
|
|
101
|
+
if (!msg || typeof msg !== "object") return;
|
|
102
|
+
// A response to one of our tools/call requests (id-matched).
|
|
103
|
+
if (msg.id != null && pending[msg.id]) {
|
|
104
|
+
var p = pending[msg.id]; delete pending[msg.id];
|
|
105
|
+
if (msg.error) p.reject(msg.error); else p.resolve(msg.result);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// A host → widget notification.
|
|
109
|
+
if (msg.method === "ui/notifications/tool-result") {
|
|
110
|
+
var params = msg.params || {};
|
|
111
|
+
var structured = params.structuredContent != null ? params.structuredContent : params;
|
|
112
|
+
api.data = structured;
|
|
113
|
+
emit(dataCbs, structured);
|
|
114
|
+
} else if (msg.method === "ui/notifications/tool-input") {
|
|
115
|
+
emit(inputCbs, (msg.params || {}).arguments);
|
|
116
|
+
} else if (msg.method === "ui/notifications/theme") {
|
|
117
|
+
var theme = msg.params || {};
|
|
118
|
+
api.theme = theme;
|
|
119
|
+
applyTheme(theme);
|
|
120
|
+
emit(themeCbs, theme);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
window.mcpApp = api;
|
|
124
|
+
// Some hosts wait for the iframe to signal readiness before pushing data; harmless if ignored.
|
|
125
|
+
post({ jsonrpc: "2.0", method: "ui/notifications/ready" });
|
|
126
|
+
})();`
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport core shared by every HTTP host of an MCP server — the dev/docs MCP in `@nifrajs/cli`
|
|
3
|
+
* (`nifra docs-mcp`), the site's edge worker, and a nifra app mounting `POST /mcp` via
|
|
4
|
+
* {@link ./server.ts}. {@link respondMcpHttp} is a plain Web `fetch` handler: POST a JSON-RPC body →
|
|
5
|
+
* JSON-RPC response; GET is a health page; OPTIONS is the CORS preflight. It never throws — a bad body
|
|
6
|
+
* becomes a JSON-RPC parse error. Pass the tools (and optional {@link McpServerFeatures} for resources /
|
|
7
|
+
* prompts / the MCP Apps `ui://` widgets) so each host supplies its own corpus/source behind one core.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
handleRpc,
|
|
12
|
+
type JsonRpcRequest,
|
|
13
|
+
type McpServerFeatures,
|
|
14
|
+
type McpTool,
|
|
15
|
+
rpcError,
|
|
16
|
+
} from "./protocol.ts"
|
|
17
|
+
|
|
18
|
+
const CORS: Record<string, string> = {
|
|
19
|
+
"access-control-allow-origin": "*",
|
|
20
|
+
"access-control-allow-methods": "POST, GET, OPTIONS",
|
|
21
|
+
"access-control-allow-headers": "content-type",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_MAX_BODY_BYTES = 1_000_000
|
|
25
|
+
const TEXT_DECODER = new TextDecoder()
|
|
26
|
+
|
|
27
|
+
export interface McpHttpOptions {
|
|
28
|
+
/** Maximum JSON-RPC request body size in bytes. Default 1 MB. */
|
|
29
|
+
readonly maxBodyBytes?: number
|
|
30
|
+
/** Resources / prompts / the MCP Apps UI extension served alongside the tools. */
|
|
31
|
+
readonly features?: McpServerFeatures
|
|
32
|
+
/** Shown on the GET health page so each host can describe itself. */
|
|
33
|
+
readonly health?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseContentLength(value: string): number | undefined {
|
|
37
|
+
if (value.length === 0) return undefined
|
|
38
|
+
let length = 0
|
|
39
|
+
for (let i = 0; i < value.length; i++) {
|
|
40
|
+
const digit = value.charCodeAt(i) - 48
|
|
41
|
+
if (digit < 0 || digit > 9) return undefined
|
|
42
|
+
length = length * 10 + digit
|
|
43
|
+
if (length > Number.MAX_SAFE_INTEGER) return Number.POSITIVE_INFINITY
|
|
44
|
+
}
|
|
45
|
+
return length
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readJsonBounded(
|
|
49
|
+
request: Request,
|
|
50
|
+
maxBytes: number,
|
|
51
|
+
): Promise<{ ok: true; value: unknown } | { ok: false; status: 400 | 413 }> {
|
|
52
|
+
const declared = request.headers.get("content-length")
|
|
53
|
+
if (declared !== null) {
|
|
54
|
+
const length = parseContentLength(declared)
|
|
55
|
+
if (length === undefined) return { ok: false, status: 400 }
|
|
56
|
+
if (length > maxBytes) return { ok: false, status: 413 }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const body = request.body
|
|
60
|
+
if (body === null) return { ok: false, status: 400 }
|
|
61
|
+
|
|
62
|
+
const reader = body.getReader()
|
|
63
|
+
const chunks: Uint8Array[] = []
|
|
64
|
+
let total = 0
|
|
65
|
+
for (;;) {
|
|
66
|
+
const { done, value } = await reader.read()
|
|
67
|
+
if (done) break
|
|
68
|
+
total += value.byteLength
|
|
69
|
+
if (total > maxBytes) {
|
|
70
|
+
await reader.cancel()
|
|
71
|
+
return { ok: false, status: 413 }
|
|
72
|
+
}
|
|
73
|
+
chunks.push(value)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const bytes = new Uint8Array(total)
|
|
77
|
+
let offset = 0
|
|
78
|
+
for (const chunk of chunks) {
|
|
79
|
+
bytes.set(chunk, offset)
|
|
80
|
+
offset += chunk.byteLength
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return { ok: true, value: JSON.parse(TEXT_DECODER.decode(bytes)) as unknown }
|
|
84
|
+
} catch {
|
|
85
|
+
return { ok: false, status: 400 }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Handle one MCP request over HTTP against the given `tools`/`features`. POST a JSON-RPC body → JSON-RPC
|
|
91
|
+
* response; GET → a plain-text health page; OPTIONS → CORS preflight. Never throws — a bad body becomes a
|
|
92
|
+
* JSON-RPC parse error. The dispatch is the shared, transport-agnostic {@link handleRpc}.
|
|
93
|
+
*/
|
|
94
|
+
export async function respondMcpHttp(
|
|
95
|
+
request: Request,
|
|
96
|
+
tools: McpTool[],
|
|
97
|
+
serverInfo: { name: string; version: string },
|
|
98
|
+
options: McpHttpOptions = {},
|
|
99
|
+
): Promise<Response> {
|
|
100
|
+
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS })
|
|
101
|
+
if (request.method === "GET") {
|
|
102
|
+
return new Response(
|
|
103
|
+
options.health ??
|
|
104
|
+
"MCP server — POST JSON-RPC 2.0 here (methods: initialize, tools/list, tools/call).",
|
|
105
|
+
{ headers: { "content-type": "text/plain; charset=utf-8", ...CORS } },
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
if (request.method !== "POST") {
|
|
109
|
+
return new Response("method not allowed", {
|
|
110
|
+
status: 405,
|
|
111
|
+
headers: { allow: "POST, GET", ...CORS },
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
const parsed = await readJsonBounded(request, options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES)
|
|
115
|
+
if (!parsed.ok) {
|
|
116
|
+
if (parsed.status === 413) {
|
|
117
|
+
return Response.json(rpcError(null, -32000, "payload too large"), {
|
|
118
|
+
status: 413,
|
|
119
|
+
headers: CORS,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
return Response.json(rpcError(null, -32700, "parse error"), { status: 400, headers: CORS })
|
|
123
|
+
}
|
|
124
|
+
const message = parsed.value as JsonRpcRequest
|
|
125
|
+
const response = await handleRpc(message, tools, serverInfo, options.features ?? {}, {
|
|
126
|
+
signal: request.signal,
|
|
127
|
+
})
|
|
128
|
+
// A notification (no id) yields null — acknowledge with 204, no body.
|
|
129
|
+
if (response === null) return new Response(null, { status: 204, headers: CORS })
|
|
130
|
+
return Response.json(response, { headers: CORS })
|
|
131
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/mcp` — build MCP servers (and MCP Apps) for a nifra app.
|
|
3
|
+
*
|
|
4
|
+
* - {@link ./protocol.ts} — the transport-agnostic JSON-RPC dispatch ({@link handleRpc}) + types, plus
|
|
5
|
+
* the MCP Apps (SEP-1865) extension (`structuredContent`, `_meta.ui.resourceUri`, the UI capability).
|
|
6
|
+
* - {@link ./http.ts} — {@link respondMcpHttp}, a Web `fetch` handler you mount at `POST /mcp`.
|
|
7
|
+
*
|
|
8
|
+
* Higher-level authoring helpers — {@link defineMcpWidget}, {@link defineMcpTool}, {@link createMcpServer}
|
|
9
|
+
* — build MCP Apps servers whose tool results render real UI.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { bridgeScript, type McpAppBridge } from "./bridge.ts"
|
|
13
|
+
export { type McpHttpOptions, respondMcpHttp } from "./http.ts"
|
|
14
|
+
export * from "./protocol.ts"
|
|
15
|
+
export { type CreateMcpServerOptions, createMcpServer, type McpServer } from "./server.ts"
|
|
16
|
+
export {
|
|
17
|
+
type DefineMcpToolOptions,
|
|
18
|
+
defineMcpTool,
|
|
19
|
+
type McpToolHandlerResult,
|
|
20
|
+
type McpUiIntent,
|
|
21
|
+
} from "./tool.ts"
|
|
22
|
+
export {
|
|
23
|
+
type DefineMcpWidgetOptions,
|
|
24
|
+
defineMcpWidget,
|
|
25
|
+
type McpWidget,
|
|
26
|
+
uiResourceMeta,
|
|
27
|
+
widgetDocument,
|
|
28
|
+
} from "./widget.ts"
|