@plitzi/sdk-server 0.32.21 → 0.32.22
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/CHANGELOG.md +10 -0
- package/dist/core/services/mcp.js +3 -1
- package/dist/core/services/proxy.js +12 -0
- package/dist/core/services/registry.js +3 -1
- package/dist/modules/mcp/apps/shared/registerApp.js +19 -3
- package/dist/modules/mcp/proxy/config.js +46 -0
- package/dist/modules/mcp/proxy/fetch.js +91 -0
- package/dist/modules/mcp/proxy/grant.js +65 -0
- package/dist/modules/mcp/proxy/guard.js +38 -0
- package/dist/modules/mcp/proxy/handler.js +69 -0
- package/dist/modules/mcp/proxy/rewrite.js +72 -0
- package/dist/modules/mcp/proxy/sign.js +15 -0
- package/dist/modules/mcp/proxy/types.js +9 -0
- package/dist/modules/mcp/resources/renderGuide.js +16 -0
- package/dist/modules/mcp/server.js +15 -9
- package/dist/modules/mcp/tools/render.js +5 -3
- package/dist/src/core/services/proxy.d.ts +2 -0
- package/dist/src/modules/mcp/handler.d.ts +4 -0
- package/dist/src/modules/mcp/proxy/config.d.ts +17 -0
- package/dist/src/modules/mcp/proxy/endpoint.test.d.ts +1 -0
- package/dist/src/modules/mcp/proxy/fetch.d.ts +18 -0
- package/dist/src/modules/mcp/proxy/grant.d.ts +23 -0
- package/dist/src/modules/mcp/proxy/guard.d.ts +6 -0
- package/dist/src/modules/mcp/proxy/handler.d.ts +8 -0
- package/dist/src/modules/mcp/proxy/index.d.ts +7 -0
- package/dist/src/modules/mcp/proxy/proxy.test.d.ts +1 -0
- package/dist/src/modules/mcp/proxy/rewrite.d.ts +13 -0
- package/dist/src/modules/mcp/proxy/sign.d.ts +6 -0
- package/dist/src/modules/mcp/proxy/types.d.ts +45 -0
- package/dist/src/modules/mcp/proxy/wiring.test.d.ts +1 -0
- package/dist/src/modules/mcp/server.d.ts +6 -1
- package/dist/src/modules/mcp/tools/render.d.ts +8 -1
- package/dist/src/modules/mcp/tools/shared/tool.d.ts +5 -0
- package/dist/src/modules/mcp/types/appTypes.d.ts +5 -0
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requestProxy } from "../../modules/mcp/proxy/config.js";
|
|
1
2
|
import { handleMcp } from "../../modules/mcp/handler.js";
|
|
2
3
|
import { createHttpPreviewClient } from "../../modules/mcp/previewClient.js";
|
|
3
4
|
import { createHttpScreenshotClient } from "../../modules/mcp/screenshotClient.js";
|
|
@@ -9,7 +10,8 @@ var serveMcp = async (ctx) => {
|
|
|
9
10
|
preview: previewClient ? createHttpPreviewClient(previewClient) : void 0,
|
|
10
11
|
screenshot: screenshot ? createHttpScreenshotClient(screenshot) : void 0,
|
|
11
12
|
logger,
|
|
12
|
-
renderStreaming: ctx.config.mcpAi?.renderStreaming
|
|
13
|
+
renderStreaming: ctx.config.mcpAi?.renderStreaming,
|
|
14
|
+
proxy: requestProxy(ctx.config, ctx.req)
|
|
13
15
|
});
|
|
14
16
|
};
|
|
15
17
|
var mcpStage = async (ctx) => {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { proxySettings } from "../../modules/mcp/proxy/config.js";
|
|
2
|
+
import { handleProxyRequest } from "../../modules/mcp/proxy/handler.js";
|
|
3
|
+
//#region src/core/services/proxy.ts
|
|
4
|
+
var widgetProxyStage = async (ctx) => {
|
|
5
|
+
const settings = proxySettings(ctx.config);
|
|
6
|
+
if (!settings || ctx.req.path !== settings.path) return false;
|
|
7
|
+
ctx.operation = "proxy";
|
|
8
|
+
await handleProxyRequest(ctx.req, ctx.res, settings);
|
|
9
|
+
return true;
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { widgetProxyStage };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mcpOnlyStage, mcpStage } from "./mcp.js";
|
|
2
2
|
import { oauthGuardStage, oauthStage } from "./oauth.js";
|
|
3
3
|
import { previewStage } from "./preview.js";
|
|
4
|
+
import { widgetProxyStage } from "./proxy.js";
|
|
4
5
|
import { rscStage } from "./rsc.js";
|
|
5
6
|
import { notFoundStage, ssrStage } from "./ssr.js";
|
|
6
7
|
import { authRoutesStages } from "../http/stages/authRoutes.js";
|
|
@@ -19,7 +20,7 @@ var buildPagePipeline = (services) => {
|
|
|
19
20
|
...authRoutesStages,
|
|
20
21
|
configStaticStage
|
|
21
22
|
];
|
|
22
|
-
if (services.mcp) stages.push(mcpStage);
|
|
23
|
+
if (services.mcp) stages.push(widgetProxyStage, mcpStage);
|
|
23
24
|
stages.push(previewStage);
|
|
24
25
|
stages.push(middlewaresStage);
|
|
25
26
|
if (services.rsc) stages.push(rscStage);
|
|
@@ -29,6 +30,7 @@ var buildPagePipeline = (services) => {
|
|
|
29
30
|
var buildMCPPipeline = () => [
|
|
30
31
|
healthStage,
|
|
31
32
|
configStaticStage,
|
|
33
|
+
widgetProxyStage,
|
|
32
34
|
oauthStage,
|
|
33
35
|
oauthGuardStage,
|
|
34
36
|
mcpOnlyStage
|
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
import { page } from "./page.js";
|
|
2
2
|
import { RESOURCE_MIME_TYPE, registerAppResource } from "@modelcontextprotocol/ext-apps/server";
|
|
3
3
|
//#region src/modules/mcp/apps/shared/registerApp.ts
|
|
4
|
-
var
|
|
4
|
+
var defaultCsp = (proxyOrigin) => ({
|
|
5
5
|
resourceDomains: [
|
|
6
|
+
...proxyOrigin ? [proxyOrigin] : [],
|
|
6
7
|
"*",
|
|
8
|
+
"https:",
|
|
9
|
+
"https://*",
|
|
7
10
|
"data:",
|
|
8
11
|
"blob:"
|
|
9
12
|
],
|
|
10
|
-
connectDomains: [
|
|
13
|
+
connectDomains: [
|
|
14
|
+
...proxyOrigin ? [proxyOrigin] : [],
|
|
15
|
+
"*",
|
|
16
|
+
"https:",
|
|
17
|
+
"https://*"
|
|
18
|
+
]
|
|
19
|
+
});
|
|
20
|
+
var originOf = (endpoint) => {
|
|
21
|
+
if (!endpoint) return;
|
|
22
|
+
try {
|
|
23
|
+
return new URL(endpoint).origin;
|
|
24
|
+
} catch {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
11
27
|
};
|
|
12
28
|
/** Serves the app as a self-contained page: no import map, no asset mounts, no cross-origin fetches, so the
|
|
13
29
|
* strictest host sandbox runs it and no deployment has to serve anything extra. */
|
|
14
30
|
var registerApp = (server, app, settings) => {
|
|
15
|
-
const meta = { ui: { csp: app.csp ??
|
|
31
|
+
const meta = { ui: { csp: app.csp ?? defaultCsp(originOf(settings.proxyEndpoint)) } };
|
|
16
32
|
registerAppResource(server, app.name, app.uri, {
|
|
17
33
|
description: app.description,
|
|
18
34
|
_meta: meta
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { requestOrigin } from "../../../core/requestParser.js";
|
|
2
|
+
import { connectionId } from "./sign.js";
|
|
3
|
+
import { DEFAULT_PROXY_TOOLS } from "./types.js";
|
|
4
|
+
//#region src/modules/mcp/proxy/config.ts
|
|
5
|
+
var PROXY_PATH = "/__proxy";
|
|
6
|
+
var MAX_BYTES = 8388608;
|
|
7
|
+
var TTL_SECONDS = 604800;
|
|
8
|
+
/** What this deployment serves at its widget endpoint, or undefined when it serves none. A secret is what turns it
|
|
9
|
+
* on: without one the endpoint could not tell its own widgets' URLs from anyone else's, and an unsigned fetcher
|
|
10
|
+
* on a public origin is an open proxy. */
|
|
11
|
+
var proxySettings = (config) => {
|
|
12
|
+
const proxy = config.mcpAi?.proxy;
|
|
13
|
+
if (!proxy?.secret || proxy.enabled === false) return;
|
|
14
|
+
return {
|
|
15
|
+
path: proxy.path ?? "/__proxy",
|
|
16
|
+
secret: proxy.secret,
|
|
17
|
+
maxBytes: proxy.maxBytes ?? MAX_BYTES,
|
|
18
|
+
ttl: proxy.ttl ?? TTL_SECONDS,
|
|
19
|
+
tools: proxy.tools ?? DEFAULT_PROXY_TOOLS,
|
|
20
|
+
baseUrl: proxy.baseUrl
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
/** The endpoint as a widget must address it: absolute, because the page runs on the host's origin and a relative
|
|
24
|
+
* URL there points at the host. Falls back to the origin this request came in on, which is the right one whenever
|
|
25
|
+
* the MCP server owns its sub-domain; a deployment reached under a different public name sets `proxy.baseUrl`.
|
|
26
|
+
* The grants it mints carry the fingerprint of THIS request's credential, so they belong to this connection. */
|
|
27
|
+
var requestProxy = (config, req) => {
|
|
28
|
+
const settings = proxySettings(config);
|
|
29
|
+
if (!settings) return;
|
|
30
|
+
const base = (settings.baseUrl ?? requestOrigin(req)).replace(/\/$/, "");
|
|
31
|
+
if (!base) return;
|
|
32
|
+
return {
|
|
33
|
+
endpoint: `${base}${settings.path}`,
|
|
34
|
+
secret: settings.secret,
|
|
35
|
+
identity: connectionId(req.headers.authorization, settings.secret),
|
|
36
|
+
ttl: settings.ttl,
|
|
37
|
+
tools: settings.tools
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
/** The proxy THIS tool may use, or undefined — the single place that decides it, so a tool cannot reach the
|
|
41
|
+
* endpoint by being wired to it later. A render authors a throwaway widget, so rewriting its URLs is invisible
|
|
42
|
+
* and lasts as long as the widget does; a tool that WRITES a space (plitzi_apply) would persist those URLs into
|
|
43
|
+
* content the user owns, which is why it is not on the list unless a deployment puts it there deliberately. */
|
|
44
|
+
var proxyForTool = (proxy, tool) => proxy?.tools.includes(tool) ? proxy : void 0;
|
|
45
|
+
//#endregion
|
|
46
|
+
export { PROXY_PATH, proxyForTool, proxySettings, requestProxy };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { isPublicHost } from "./guard.js";
|
|
2
|
+
//#region src/modules/mcp/proxy/fetch.ts
|
|
3
|
+
var TIMEOUT_MS = 1e4;
|
|
4
|
+
var MAX_REDIRECTS = 4;
|
|
5
|
+
var ALLOWED_TYPES = {
|
|
6
|
+
asset: [
|
|
7
|
+
"image/",
|
|
8
|
+
"video/",
|
|
9
|
+
"audio/",
|
|
10
|
+
"font/",
|
|
11
|
+
"application/font",
|
|
12
|
+
"application/octet-stream"
|
|
13
|
+
],
|
|
14
|
+
data: [
|
|
15
|
+
"application/json",
|
|
16
|
+
"application/ld+json",
|
|
17
|
+
"application/xml",
|
|
18
|
+
"text/plain",
|
|
19
|
+
"text/csv",
|
|
20
|
+
"text/xml"
|
|
21
|
+
]
|
|
22
|
+
};
|
|
23
|
+
var REQUEST_HEADERS = {
|
|
24
|
+
asset: {
|
|
25
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36",
|
|
26
|
+
Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
|
27
|
+
"Accept-Language": "en-US,en;q=0.9"
|
|
28
|
+
},
|
|
29
|
+
data: {
|
|
30
|
+
"User-Agent": "PlitziWidget/1.0 (+https://plitzi.com)",
|
|
31
|
+
Accept: "application/json,text/plain;q=0.9,*/*;q=0.8"
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
var failure = (status, reason) => ({
|
|
35
|
+
ok: false,
|
|
36
|
+
status,
|
|
37
|
+
reason
|
|
38
|
+
});
|
|
39
|
+
var parseTarget = (target) => {
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(target);
|
|
42
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url : void 0;
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
/** Fetch a remote resource on the widget's behalf. Redirects are followed by hand rather than by fetch, because
|
|
48
|
+
* each hop is a new host that has to pass the same guard — an allowed URL that redirects into the private network
|
|
49
|
+
* would otherwise walk straight through it. (It is also what makes the redirect-based image services work at
|
|
50
|
+
* all: the sandbox loses the redirect, this does not.) */
|
|
51
|
+
var fetchResource = async (target, kind, maxBytes) => {
|
|
52
|
+
let url = parseTarget(target);
|
|
53
|
+
if (!url) return failure(400, "Only http(s) URLs can be fetched.");
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
56
|
+
try {
|
|
57
|
+
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
|
|
58
|
+
if (!await isPublicHost(url.hostname)) return failure(403, "That host is not reachable from here.");
|
|
59
|
+
const response = await fetch(url, {
|
|
60
|
+
headers: REQUEST_HEADERS[kind],
|
|
61
|
+
redirect: "manual",
|
|
62
|
+
signal: controller.signal
|
|
63
|
+
});
|
|
64
|
+
const location = response.headers.get("location");
|
|
65
|
+
if (response.status >= 300 && response.status < 400 && location) {
|
|
66
|
+
const next = parseTarget(new URL(location, url).toString());
|
|
67
|
+
if (!next) return failure(502, "It redirected somewhere that is not an http(s) URL.");
|
|
68
|
+
url = next;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!response.ok) return failure(502, `It could not be fetched (upstream ${response.status}).`);
|
|
72
|
+
const contentType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
73
|
+
if (!ALLOWED_TYPES[kind].some((allowed) => contentType.startsWith(allowed))) return failure(415, `That URL answered with ${contentType || "no content type"}, which is not ${kind === "asset" ? "an image or media file" : "data"}.`);
|
|
74
|
+
const declared = Number(response.headers.get("content-length"));
|
|
75
|
+
if (Number.isFinite(declared) && declared > maxBytes) return failure(413, "That response is too large to load in a widget.");
|
|
76
|
+
return {
|
|
77
|
+
ok: true,
|
|
78
|
+
contentType,
|
|
79
|
+
contentLength: Number.isFinite(declared) && declared > 0 ? declared : void 0,
|
|
80
|
+
body: response.body
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return failure(502, "It redirected too many times.");
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return failure(504, error instanceof Error && error.name === "AbortError" ? "It timed out." : "unreachable");
|
|
86
|
+
} finally {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
//#endregion
|
|
91
|
+
export { fetchResource };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { sign, verify } from "./sign.js";
|
|
2
|
+
//#region src/modules/mcp/proxy/grant.ts
|
|
3
|
+
/** The single query parameter a proxied URL carries: `<signature>.<kind>.<expiry>.<connection>.<target>`. One
|
|
4
|
+
* parameter rather than several so the URL holds no `&` — these end up inside HTML attributes and markdown
|
|
5
|
+
* links, where an unescaped ampersand is the kind of detail that silently truncates a URL. */
|
|
6
|
+
var PARAM = "i";
|
|
7
|
+
var CODE = {
|
|
8
|
+
asset: "a",
|
|
9
|
+
data: "d"
|
|
10
|
+
};
|
|
11
|
+
var KIND = {
|
|
12
|
+
a: "asset",
|
|
13
|
+
d: "data"
|
|
14
|
+
};
|
|
15
|
+
var isTemplated = (target) => target.includes("{{");
|
|
16
|
+
var originOf = (target) => {
|
|
17
|
+
try {
|
|
18
|
+
return new URL(target).origin;
|
|
19
|
+
} catch {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
var encodeTarget = (target) => encodeURIComponent(target).replace(/%7B/g, "{").replace(/%7D/g, "}");
|
|
24
|
+
var payloadOf = (kind, expiry, identity, target) => `${CODE[kind]}.${expiry.toString(36)}.${identity}.${target}`;
|
|
25
|
+
/** Mint the URL a widget will load this target from: this server's endpoint, carrying the target, the kind it was
|
|
26
|
+
* granted for, when the grant stops working and which connection minted it — all covered by one signature. */
|
|
27
|
+
var grantUrl = (target, proxy, kind = "asset") => {
|
|
28
|
+
const expiry = Math.floor(Date.now() / 1e3) + proxy.ttl;
|
|
29
|
+
const scope = (isTemplated(target) ? originOf(target) : target) ?? target;
|
|
30
|
+
const signature = sign(payloadOf(kind, expiry, proxy.identity, scope), proxy.secret);
|
|
31
|
+
const payload = `${CODE[kind]}.${expiry.toString(36)}.${proxy.identity}.${encodeTarget(target)}`;
|
|
32
|
+
return `${proxy.endpoint}?${PARAM}=${signature}.${payload}`;
|
|
33
|
+
};
|
|
34
|
+
/** What this endpoint was asked to fetch, or undefined when the parameter is missing, malformed, expired or not
|
|
35
|
+
* signed here — which is what keeps it from being an open proxy for anyone who finds it: it serves the URLs this
|
|
36
|
+
* server rewrote, for as long as it said, and nothing else.
|
|
37
|
+
*
|
|
38
|
+
* Takes the parameter as the query parser hands it over — decoded exactly once, which is the form that was
|
|
39
|
+
* signed. Decoding it again would corrupt every target that legitimately carries a percent-escape (an API URL
|
|
40
|
+
* with a nested URL in its query) and reject it as unsigned. */
|
|
41
|
+
var readGrant = (param, secret) => {
|
|
42
|
+
const parts = param?.split(".") ?? [];
|
|
43
|
+
if (parts.length < 5) return;
|
|
44
|
+
const [signature = "", code = "", expiry = "", identity = ""] = parts;
|
|
45
|
+
const kind = KIND[code];
|
|
46
|
+
const target = parts.slice(4).join(".");
|
|
47
|
+
const expiresAt = parseInt(expiry, 36);
|
|
48
|
+
if (!kind || !Number.isFinite(expiresAt) || expiresAt * 1e3 < Date.now()) return;
|
|
49
|
+
const payload = payloadOf(kind, expiresAt, identity, target);
|
|
50
|
+
if (verify(payload, signature, secret)) return {
|
|
51
|
+
kind,
|
|
52
|
+
target
|
|
53
|
+
};
|
|
54
|
+
const origin = originOf(target);
|
|
55
|
+
return origin && verify(payloadOf(kind, expiresAt, identity, origin), signature, secret) ? {
|
|
56
|
+
kind,
|
|
57
|
+
target
|
|
58
|
+
} : void 0;
|
|
59
|
+
};
|
|
60
|
+
/** Is this URL one the widget can already reach? Absolute http(s) URLs are the ones a sandbox CSP blocks;
|
|
61
|
+
* `data:`/`blob:` carry their own bytes, and a relative URL has no origin to reach in the first place. */
|
|
62
|
+
var isRemote = (url) => /^https?:\/\//i.test(url);
|
|
63
|
+
var isGranted = (url, proxy) => url.startsWith(`${proxy.endpoint}?`);
|
|
64
|
+
//#endregion
|
|
65
|
+
export { grantUrl, isGranted, isRemote, readGrant };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
//#region src/modules/mcp/proxy/guard.ts
|
|
4
|
+
var isPrivateIpv4 = (address) => {
|
|
5
|
+
const parts = address.split(".").map(Number);
|
|
6
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
|
|
7
|
+
const [a = 0, b = 0] = parts;
|
|
8
|
+
return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 0 || a === 192 && b === 168 || a === 198 && (b === 18 || b === 19) || a >= 224;
|
|
9
|
+
};
|
|
10
|
+
var isPrivateIpv6 = (address) => {
|
|
11
|
+
const normalized = address.toLowerCase().split("%")[0] ?? "";
|
|
12
|
+
if (normalized.startsWith("::ffff:") && normalized.includes(".")) return isPrivateIpv4(normalized.slice(7));
|
|
13
|
+
return normalized === "::" || normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb");
|
|
14
|
+
};
|
|
15
|
+
var isPrivateAddress = (address) => isIP(address) === 6 ? isPrivateIpv6(address) : isPrivateIpv4(address);
|
|
16
|
+
var PRIVATE_SUFFIXES = [
|
|
17
|
+
".local",
|
|
18
|
+
".internal",
|
|
19
|
+
".localhost",
|
|
20
|
+
".home.arpa"
|
|
21
|
+
];
|
|
22
|
+
/** Does this hostname resolve somewhere on the public internet? The endpoint fetches URLs an agent authored, so
|
|
23
|
+
* without this it would be a way to read whatever the pod itself can reach — the cluster's services, the cloud
|
|
24
|
+
* metadata endpoint, a database on the node. Both the literal address and every address the name resolves to are
|
|
25
|
+
* checked; a name that does not resolve is refused rather than handed to fetch. */
|
|
26
|
+
var isPublicHost = async (hostname) => {
|
|
27
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
28
|
+
if (!host || host === "localhost" || PRIVATE_SUFFIXES.some((suffix) => host.endsWith(suffix))) return false;
|
|
29
|
+
if (isIP(host)) return !isPrivateAddress(host);
|
|
30
|
+
try {
|
|
31
|
+
const addresses = await lookup(host, { all: true });
|
|
32
|
+
return addresses.length > 0 && addresses.every((entry) => !isPrivateAddress(entry.address));
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { isPrivateAddress, isPublicHost };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readGrant } from "./grant.js";
|
|
2
|
+
import { fetchResource } from "./fetch.js";
|
|
3
|
+
//#region src/modules/mcp/proxy/handler.ts
|
|
4
|
+
var CACHE_CONTROL = {
|
|
5
|
+
asset: "public, max-age=86400, immutable",
|
|
6
|
+
data: "no-store"
|
|
7
|
+
};
|
|
8
|
+
var fail = (res, status, reason) => {
|
|
9
|
+
res.setStatus(status);
|
|
10
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
11
|
+
res.setHeader("Cache-Control", "no-store");
|
|
12
|
+
res.send(reason);
|
|
13
|
+
};
|
|
14
|
+
/** Serve one external resource on behalf of a widget: check the grant this server signed, fetch the target, and
|
|
15
|
+
* stream it back under this origin — the one the CSP declares. Read-only, credential-free and CORS-open, because
|
|
16
|
+
* it answers the host's sandboxed iframe: a browser that has no origin of its own and can present nothing. The
|
|
17
|
+
* grant is what stands in for a caller identity — it is unforgeable, it names one target (or one host, for a
|
|
18
|
+
* templated API URL), it names the kind, it expires, and it carries the connection that minted it. */
|
|
19
|
+
var handleProxyRequest = async (req, res, settings) => {
|
|
20
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
21
|
+
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
|
|
22
|
+
if (req.method === "OPTIONS") {
|
|
23
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
|
|
24
|
+
res.setHeader("Access-Control-Allow-Headers", "*");
|
|
25
|
+
res.setStatus(204);
|
|
26
|
+
res.end();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
30
|
+
fail(res, 405, "Only GET is served here.");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const grant = readGrant(req.query["i"], settings.secret);
|
|
34
|
+
if (!grant) {
|
|
35
|
+
fail(res, 403, "This URL is missing a grant, or the one it carries was not issued here or has expired.");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const result = await fetchResource(grant.target, grant.kind, settings.maxBytes);
|
|
39
|
+
if (!result.ok) {
|
|
40
|
+
fail(res, result.status, result.reason);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
res.setStatus(200);
|
|
44
|
+
res.setHeader("Content-Type", result.contentType);
|
|
45
|
+
res.setHeader("Cache-Control", CACHE_CONTROL[grant.kind]);
|
|
46
|
+
if (result.contentLength) res.setHeader("Content-Length", String(result.contentLength));
|
|
47
|
+
if (req.method === "HEAD" || !result.body) {
|
|
48
|
+
res.end();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const reader = result.body.getReader();
|
|
52
|
+
let written = 0;
|
|
53
|
+
try {
|
|
54
|
+
for (;;) {
|
|
55
|
+
const { done, value } = await reader.read();
|
|
56
|
+
if (done) break;
|
|
57
|
+
written += value.byteLength;
|
|
58
|
+
if (written > settings.maxBytes) {
|
|
59
|
+
await reader.cancel();
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
res.write(Buffer.from(value));
|
|
63
|
+
}
|
|
64
|
+
} finally {
|
|
65
|
+
res.end();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
//#endregion
|
|
69
|
+
export { handleProxyRequest };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { grantUrl, isGranted, isRemote } from "./grant.js";
|
|
2
|
+
//#region src/modules/mcp/proxy/rewrite.ts
|
|
3
|
+
var URL_ATTRIBUTES = ["src", "poster"];
|
|
4
|
+
var MARKUP_ATTRIBUTES = ["content", "html"];
|
|
5
|
+
var FETCHING_TYPE = "apiContainer";
|
|
6
|
+
var CSS_URL = /url\(\s*(["']?)(https?:\/\/[^"')\s]+)\1\s*\)/gi;
|
|
7
|
+
var HTML_SRC = /(<[^>]+?\ssrc\s*=\s*)(["'])(https?:\/\/[^"']+)\2/gi;
|
|
8
|
+
var MARKDOWN_IMAGE = /(!\[[^\]]*\]\(\s*)(https?:\/\/[^\s)]+)/gi;
|
|
9
|
+
var unescapeHtml = (url) => url.replace(/&/g, "&");
|
|
10
|
+
var toProxy = (url, proxy) => isGranted(url, proxy) ? url : grantUrl(unescapeHtml(url), proxy);
|
|
11
|
+
/** Rewrite every remote URL embedded in a string: `url(…)` in CSS, `src="…"` in markup, `` in markdown. */
|
|
12
|
+
var rewriteText = (text, proxy) => text.replace(CSS_URL, (_match, quote, url) => `url(${quote}${toProxy(url, proxy)}${quote})`).replace(HTML_SRC, (_match, prefix, quote, url) => `${prefix}${quote}${toProxy(url, proxy)}${quote}`).replace(MARKDOWN_IMAGE, (_match, prefix, url) => `${prefix}${toProxy(url, proxy)}`);
|
|
13
|
+
var rewriteStyleObject = (styleObject, proxy) => {
|
|
14
|
+
for (const [property, value] of Object.entries(styleObject)) if (typeof value === "string" && value.includes("url(")) styleObject[property] = rewriteText(value, proxy);
|
|
15
|
+
};
|
|
16
|
+
var rewriteStyleBlock = (block, proxy) => {
|
|
17
|
+
if (block.default) rewriteStyleObject(block.default, proxy);
|
|
18
|
+
for (const state of Object.values(block.states ?? {})) rewriteStyleObject(state, proxy);
|
|
19
|
+
};
|
|
20
|
+
/** Why a fetching element's URL was left as authored, or undefined when it can be granted. Each case is one this
|
|
21
|
+
* hop cannot reproduce faithfully, and getting it wrong silently would be worse than the request being blocked:
|
|
22
|
+
* a write sent through a GET, or an API answering 401 because its credential was dropped on the way. */
|
|
23
|
+
var unproxyableFetch = (element) => {
|
|
24
|
+
const { method, headers, accessToken } = element.attributes;
|
|
25
|
+
if (typeof method === "string" && method.toLowerCase() !== "get") return `it is a ${method.toUpperCase()} request`;
|
|
26
|
+
if (typeof accessToken === "string" && accessToken !== "") return "it sends its own credentials";
|
|
27
|
+
return headers && Object.keys(headers).length > 0 ? "it sends its own headers" : void 0;
|
|
28
|
+
};
|
|
29
|
+
var rewriteSchema = (schema, proxy, warnings) => {
|
|
30
|
+
for (const element of Object.values(schema.flat)) {
|
|
31
|
+
const attributes = element.attributes;
|
|
32
|
+
for (const name of URL_ATTRIBUTES) {
|
|
33
|
+
const value = attributes[name];
|
|
34
|
+
if (typeof value === "string" && isRemote(value) && !isGranted(value, proxy)) attributes[name] = grantUrl(value, proxy);
|
|
35
|
+
}
|
|
36
|
+
for (const name of MARKUP_ATTRIBUTES) {
|
|
37
|
+
const value = attributes[name];
|
|
38
|
+
if (typeof value === "string" && value.includes("http")) attributes[name] = rewriteText(value, proxy);
|
|
39
|
+
}
|
|
40
|
+
const query = attributes.query;
|
|
41
|
+
if (element.definition.type !== FETCHING_TYPE || typeof query !== "string" || !isRemote(query)) continue;
|
|
42
|
+
const blocked = unproxyableFetch(element);
|
|
43
|
+
if (blocked) {
|
|
44
|
+
warnings.push(`The ${FETCHING_TYPE} "${element.idRef ?? element.id}" calls its endpoint directly because ${blocked}, so that request is subject to the network policy of the surface it renders in, and to the CORS headers of the API, and may not run. A plain GET with no headers is fetched by the widget server instead, which always works.`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!isGranted(query, proxy)) attributes.query = grantUrl(query, proxy, "data");
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
var rewriteStyle = (style, proxy) => {
|
|
51
|
+
for (const items of Object.values(style.platform)) for (const item of Object.values(items)) {
|
|
52
|
+
for (const selector of Object.values(item.attributes)) {
|
|
53
|
+
rewriteStyleBlock(selector, proxy);
|
|
54
|
+
for (const variant of Object.values(selector.variants ?? {})) rewriteStyleBlock(variant, proxy);
|
|
55
|
+
}
|
|
56
|
+
if (item.cache.includes("url(")) item.cache = rewriteText(item.cache, proxy);
|
|
57
|
+
}
|
|
58
|
+
if (style.cache.includes("url(")) style.cache = rewriteText(style.cache, proxy);
|
|
59
|
+
};
|
|
60
|
+
/** Point everything an authored widget loads from outside — images, media, fonts, and the data an apiContainer
|
|
61
|
+
* fetches — at this server's endpoint, in place. The AGENT never sees this: it authors the real URL and this runs
|
|
62
|
+
* afterwards, on the throwaway space a render builds (so nothing shared is mutated) and before the global style
|
|
63
|
+
* cache is compiled (so the concatenated CSS comes out already rewritten). Returns what could not be rewritten,
|
|
64
|
+
* which is the only part the model hears about. */
|
|
65
|
+
var proxifyResources = (space, proxy) => {
|
|
66
|
+
const warnings = [];
|
|
67
|
+
rewriteSchema(space.schema, proxy, warnings);
|
|
68
|
+
rewriteStyle(space.style, proxy);
|
|
69
|
+
return warnings;
|
|
70
|
+
};
|
|
71
|
+
//#endregion
|
|
72
|
+
export { proxifyResources, rewriteText };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
//#region src/modules/mcp/proxy/sign.ts
|
|
3
|
+
var SIGNATURE_LENGTH = 22;
|
|
4
|
+
var sign = (payload, secret) => createHmac("sha256", secret).update(payload).digest("base64url").slice(0, SIGNATURE_LENGTH);
|
|
5
|
+
var verify = (payload, signature, secret) => {
|
|
6
|
+
const expected = Buffer.from(sign(payload, secret));
|
|
7
|
+
const given = Buffer.from(signature);
|
|
8
|
+
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
9
|
+
};
|
|
10
|
+
/** A short, stable fingerprint of the MCP credential a request carried — the connection a grant belongs to.
|
|
11
|
+
* Derived through the same secret, so it identifies without echoing any part of the token; a request with no
|
|
12
|
+
* credential (the public widgets surface) gets the shared anonymous identity. */
|
|
13
|
+
var connectionId = (authorization, secret) => authorization ? sign(`id:${authorization}`, secret).slice(0, 10) : "anon";
|
|
14
|
+
//#endregion
|
|
15
|
+
export { connectionId, sign, verify };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
//#region src/modules/mcp/proxy/types.ts
|
|
2
|
+
/** The tools a proxy may be handed to. `plitzi_render` alone by default, and that default is a guard, not a
|
|
3
|
+
* convenience: a render is a THROWAWAY widget, so rewriting its URLs changes nothing that outlives the call —
|
|
4
|
+
* while plitzi_apply writes to the user's real space, where a rewritten URL would be PERSISTED and the space
|
|
5
|
+
* would come to depend on this server's endpoint for content it owns. Nothing else should be added here without
|
|
6
|
+
* that being the intention. */
|
|
7
|
+
var DEFAULT_PROXY_TOOLS = ["plitzi_render"];
|
|
8
|
+
//#endregion
|
|
9
|
+
export { DEFAULT_PROXY_TOOLS };
|
|
@@ -275,6 +275,22 @@ that is not here (lists, tabs, dialogs, forms, icons…).
|
|
|
275
275
|
base64 raster) — both render with no extra setup. For a **vector** graphic do not encode a \`data:\` URI: draw it
|
|
276
276
|
inline, as below.
|
|
277
277
|
|
|
278
|
+
**Anything loaded from the internet.** Write the real URL and nothing else: images, video, fonts and the endpoint
|
|
279
|
+
an \`apiContainer\` reads are re-pointed at the render server after the widget is built, and it fetches them for
|
|
280
|
+
the widget. That is invisible to you and there is nothing to opt into — it is what settles the redirects, the
|
|
281
|
+
hotlink rules and the missing CORS headers that a widget, running in the surface's sandbox, cannot settle itself.
|
|
282
|
+
What it cannot do is guess:
|
|
283
|
+
|
|
284
|
+
- **Use URLs you have actually seen work.** A direct file URL, not a page about the picture, not a search result,
|
|
285
|
+
not a pattern you assembled from memory. A URL that 404s or answers with HTML instead of an image leaves a grey
|
|
286
|
+
placeholder box in the middle of an otherwise finished layout.
|
|
287
|
+
- **With no URL you trust, do not invent one.** Draw that block instead: a \`linear-gradient\`, a flat colour, an
|
|
288
|
+
inline \`<svg>\` or a \`data:\` URI — none touch the network and all of them always render.
|
|
289
|
+
- **A plain GET is the request that always works.** An \`apiContainer\` with a custom \`method\`, \`headers\` or
|
|
290
|
+
\`accessToken\` is called directly by the widget instead, so it is then subject to the surface's own network
|
|
291
|
+
policy and the API's CORS headers — the render warns you when that happens. \`{{tokens}}\` in the URL are fine.
|
|
292
|
+
When you are showing a shape rather than live data, \`mockData\` needs no network at all.
|
|
293
|
+
|
|
278
294
|
## Draw with inline SVG
|
|
279
295
|
|
|
280
296
|
A widget can draw its own graphics, and it is often the difference between a plain block of text and something
|
|
@@ -2,6 +2,7 @@ import { registerApps } from "./apps/index.js";
|
|
|
2
2
|
import { NoSpaceError, emptySpace, emptySpaceMessage, noSpaceError } from "./helpers/space.js";
|
|
3
3
|
import { serverInstructions, widgetsOnlyInstructions } from "./helpers/guide.js";
|
|
4
4
|
import { createMcpLog } from "./helpers/log.js";
|
|
5
|
+
import { proxyForTool } from "./proxy/config.js";
|
|
5
6
|
import { registerResources } from "./resources/register.js";
|
|
6
7
|
import { tools } from "./tools/index.js";
|
|
7
8
|
import { isCallToolResult } from "../ai/toolkit.js";
|
|
@@ -13,7 +14,7 @@ var asText = (data) => ({ content: [{
|
|
|
13
14
|
type: "text",
|
|
14
15
|
text: JSON.stringify(data)
|
|
15
16
|
}] });
|
|
16
|
-
var createMcpServer = async ({ adapters, getSpaceId, preview, screenshot, logger, renderStreaming = true }) => {
|
|
17
|
+
var createMcpServer = async ({ adapters, getSpaceId, preview, screenshot, logger, renderStreaming = true, proxy }) => {
|
|
17
18
|
const log = createMcpLog(logger);
|
|
18
19
|
const spaceId = await getSpaceId().catch(() => void 0);
|
|
19
20
|
const hasSpace = spaceId !== void 0;
|
|
@@ -44,28 +45,33 @@ var createMcpServer = async ({ adapters, getSpaceId, preview, screenshot, logger
|
|
|
44
45
|
const getSpace = () => spacePromise ??= loadSpace();
|
|
45
46
|
const server = new McpServer({
|
|
46
47
|
name: "plitzi-mcp",
|
|
47
|
-
version: "0.32.
|
|
48
|
+
version: "0.32.22"
|
|
48
49
|
}, { instructions: hasSpace ? serverInstructions : widgetsOnlyInstructions });
|
|
49
50
|
registerResources(server, getSpace, MCP_ENV, log, hasSpace);
|
|
50
|
-
registerApps(server, {
|
|
51
|
-
|
|
51
|
+
registerApps(server, {
|
|
52
|
+
streaming: renderStreaming,
|
|
53
|
+
proxyEndpoint: proxy?.endpoint
|
|
54
|
+
});
|
|
55
|
+
const toolContext = async (tool) => ({
|
|
52
56
|
space: await getSpace(),
|
|
53
57
|
env: MCP_ENV,
|
|
54
58
|
persisters,
|
|
55
59
|
spaceId: requireSpaceId(),
|
|
56
60
|
preview,
|
|
57
|
-
screenshot
|
|
61
|
+
screenshot,
|
|
62
|
+
proxy: proxyForTool(proxy, tool)
|
|
58
63
|
});
|
|
59
|
-
const spacelessContext = () => ({
|
|
64
|
+
const spacelessContext = (tool) => ({
|
|
60
65
|
space: emptySpace(),
|
|
61
66
|
env: MCP_ENV,
|
|
62
67
|
persisters,
|
|
63
68
|
preview,
|
|
64
|
-
screenshot
|
|
69
|
+
screenshot,
|
|
70
|
+
proxy: proxyForTool(proxy, tool)
|
|
65
71
|
});
|
|
66
72
|
const behaviorOf = (tool) => {
|
|
67
|
-
if (tool.spaceless) return (args) => tool.execute(args, spacelessContext());
|
|
68
|
-
if (hasSpace) return async (args) => tool.execute(args, await toolContext());
|
|
73
|
+
if (tool.spaceless) return (args) => tool.execute(args, spacelessContext(tool.name));
|
|
74
|
+
if (hasSpace) return async (args) => tool.execute(args, await toolContext(tool.name));
|
|
69
75
|
return tool.executePublic ? (args) => tool.executePublic?.(args, MCP_ENV) : void 0;
|
|
70
76
|
};
|
|
71
77
|
for (const tool of tools) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { iconFontCss } from "../apps/render/styles.js";
|
|
2
2
|
import { RENDER_APP_URI } from "../apps/render/index.js";
|
|
3
3
|
import { emptySpace } from "../helpers/space.js";
|
|
4
|
+
import { proxifyResources } from "../proxy/rewrite.js";
|
|
4
5
|
import { operations } from "./operations/index.js";
|
|
5
6
|
import { applyOperations } from "./apply/dispatch.js";
|
|
6
7
|
import { expandOperations } from "./shared/expandOperations.js";
|
|
@@ -41,7 +42,7 @@ var renderShape = {
|
|
|
41
42
|
patch: z.boolean().optional().describe("Set true to CHANGE a widget you already rendered instead of rebuilding it: send its `renderId` and ONLY the operations that differ (patchDefinition, patchElement, deleteElement, a new repeatElement…). The widget merges them into the batch it was built from and reports back what it applied. The refs it already has (card-1, blk-2-3) are the ones to address. If that widget cannot be recovered — a surface that renders none, a host that keeps no storage — the answer says so and you re-send the whole batch."),
|
|
42
43
|
renderId: z.string().optional().describe("Handle returned by a previous render. Required with patch:true; it names the widget being changed.")
|
|
43
44
|
};
|
|
44
|
-
var render = (input) => {
|
|
45
|
+
var render = (input, options = {}) => {
|
|
45
46
|
const space = seedSpace();
|
|
46
47
|
const expansion = expandOperations(input.operations);
|
|
47
48
|
if (expansion.errors.length > 0) return {
|
|
@@ -78,6 +79,7 @@ var render = (input) => {
|
|
|
78
79
|
errors: audit.errors,
|
|
79
80
|
warnings: noWarnings(warnings)
|
|
80
81
|
};
|
|
82
|
+
if (options.proxy) warnings.push(...proxifyResources(space, options.proxy));
|
|
81
83
|
space.style.cache = generateCache(space.style);
|
|
82
84
|
return {
|
|
83
85
|
rendered: true,
|
|
@@ -171,12 +173,12 @@ var toRenderResult = (res, renderId) => {
|
|
|
171
173
|
var renderTool = defineTool({
|
|
172
174
|
name: "plitzi_render",
|
|
173
175
|
title: "Render widget",
|
|
174
|
-
description: "Show the user a real, rendered UI widget instead of describing one — cards, hero sections, pricing tables, forms, menus, checklists, profiles, galleries. It runs the Plitzi SDK fully offline: no backend, account, or setup. Reach for it whenever a visual layout beats prose: the user asks you to design/build/show something, OR your answer is naturally visual (a recipe → a card, a comparison → a table, steps → a checklist). Prefer showing over telling.\n\nAuthor the widget as an ordered list of `operations` that build an element tree under the pre-seeded root page \"render\". Three rules:\n1. STRUCTURE — one upsertElement builds the whole tree: set pageRef:\"render\" and give element a nested `children` array. Each element is { ref (unique), type, subType?, props?, style?, children? }; children render in order. (To attach to something you already made, use a top-level parentRef:\"<existing ref>\" instead.)\n1b. REPEATS — the moment two siblings share a shape and differ only in data (list, steps, cards, table, timeline), do NOT copy-paste them: use repeatElement { pageRef, ref (wrapper), style, template, items }. The template is written once with {{item.field}} placeholders and rendered per row; refs come out numbered (\"step-1\", \"step-2\"…). A list INSIDE each row (days with their own steps) is the same op: give the wrapping node repeat:{ items:\"{{item.<list>}}\", template:… } and put the sub-rows in the row data.\n2. STYLE — declare ALL the classes in ONE upsertDefinitions { definitions: { \"<class>\": { desktop:{ …CSS in kebab-case… } }, … } }, then attach via the element style:{ base:[\"<class ref>\"] }. Lay containers out with flex/grid. Keep the call small: one class per look, not per property.\n2a. GRAPHICS — a logo, a sparkline, a badge, a decorative shape: draw it as an INLINE <svg> in a blockHtml element (props.content), keeping a viewBox with width/height \"100%\" so its class sizes it, and fill/stroke \"currentColor\" so it follows the host theme. Budget it: a handful of paths, drawn once and reused, never a data: URI and never a full illustration — a photo-real scene costs more than the whole widget, so use an https image, a flat colour or a gradient for that. Markup only: scripts and on* handlers are rejected.\n2b. LAYOUT — it renders in a side panel, so width is free and HEIGHT is scarce. Plain containers stack children vertically, which is the tall half-empty default to avoid: put peers (metrics, plans, options, image + text) in a wrapping row — display:flex, flex-direction:row, flex-wrap:wrap, children flex-grow:\"1\" + flex-basis:\"0%\" + min-width — or a grid with grid-template-columns:\"repeat(auto-fit, minmax(160px, 1fr))\". Keep padding 12-16px and gap 8-12px, and let the outer container fill the panel. Stack only what reads in order (heading over paragraph, forms, steps, prose). Nothing carries a minimum size, so an element with no content and no size takes none — give a spacer or a rail its own height/width — while heading/paragraph do keep the margins the browser gives them (zero them, space with the parent gap).\n2c. THEME — it is embedded in the host UI, which MAY BE DARK, so never hardcode a light palette. Take colours from the host variables with a light-dark() fallback — background-color:\"var(--color-background-secondary, light-dark(#ffffff, #1f2430))\", color:\"var(--color-text-primary, light-dark(#0f172a, #e8eaed))\", border-color:\"var(--color-border-primary, light-dark(#e2e8f0, #333a48))\" — and always set `color` wherever you set `background-color` (a brand accent states its own text colour too).\n3. CONTENT — visible copy goes in props.content (text, heading, paragraph, button); heading level is the element subType (\"h1\"..\"h6\"); image/video take props.src. An unknown prop comes back as a warning naming the right one.\n\nCommon types: container, heading, paragraph, text, button, link, image, video, list, listItem, markdown (plitzi://render/types lists every built-in type with descriptions). Widgets can also be data-driven and interactive — an apiContainer fetches at runtime, upsertBinding wires data into elements, and upsertInteractionFlow makes them react to clicks (see the guide).\nREAD the resource plitzi://render/guide first — it has the element/prop table, the style model and a full worked example, and following it is the difference between a widget that renders and repeated failed calls.\nITERATING — to change a widget you already rendered, do NOT rebuild it: call again with patch:true, the `renderId` that render answered with, and ONLY the operations that differ (patchDefinition, patchElement, deleteElement…). The widget merges them and reports back what it applied; address rows by the refs you already know. Patch ONLY to modify that widget: a different subject or a different kind of widget is a fresh render, without patch — a patch is merged into the previous batch, so patching a new idea leaves you with both.\nReturns a compact summary including the renderId (the widget itself is shown to the user); on failure it returns teachable errors (path + hint) — read them and retry.",
|
|
176
|
+
description: "Show the user a real, rendered UI widget instead of describing one — cards, hero sections, pricing tables, forms, menus, checklists, profiles, galleries. It runs the Plitzi SDK fully offline: no backend, account, or setup. Reach for it whenever a visual layout beats prose: the user asks you to design/build/show something, OR your answer is naturally visual (a recipe → a card, a comparison → a table, steps → a checklist). Prefer showing over telling.\n\nAuthor the widget as an ordered list of `operations` that build an element tree under the pre-seeded root page \"render\". Three rules:\n1. STRUCTURE — one upsertElement builds the whole tree: set pageRef:\"render\" and give element a nested `children` array. Each element is { ref (unique), type, subType?, props?, style?, children? }; children render in order. (To attach to something you already made, use a top-level parentRef:\"<existing ref>\" instead.)\n1b. REPEATS — the moment two siblings share a shape and differ only in data (list, steps, cards, table, timeline), do NOT copy-paste them: use repeatElement { pageRef, ref (wrapper), style, template, items }. The template is written once with {{item.field}} placeholders and rendered per row; refs come out numbered (\"step-1\", \"step-2\"…). A list INSIDE each row (days with their own steps) is the same op: give the wrapping node repeat:{ items:\"{{item.<list>}}\", template:… } and put the sub-rows in the row data.\n2. STYLE — declare ALL the classes in ONE upsertDefinitions { definitions: { \"<class>\": { desktop:{ …CSS in kebab-case… } }, … } }, then attach via the element style:{ base:[\"<class ref>\"] }. Lay containers out with flex/grid. Keep the call small: one class per look, not per property.\n2a. GRAPHICS — a logo, a sparkline, a badge, a decorative shape: draw it as an INLINE <svg> in a blockHtml element (props.content), keeping a viewBox with width/height \"100%\" so its class sizes it, and fill/stroke \"currentColor\" so it follows the host theme. Budget it: a handful of paths, drawn once and reused, never a data: URI and never a full illustration — a photo-real scene costs more than the whole widget, so use an https image, a flat colour or a gradient for that. Markup only: scripts and on* handlers are rejected.\n2b. LAYOUT — it renders in a side panel, so width is free and HEIGHT is scarce. Plain containers stack children vertically, which is the tall half-empty default to avoid: put peers (metrics, plans, options, image + text) in a wrapping row — display:flex, flex-direction:row, flex-wrap:wrap, children flex-grow:\"1\" + flex-basis:\"0%\" + min-width — or a grid with grid-template-columns:\"repeat(auto-fit, minmax(160px, 1fr))\". Keep padding 12-16px and gap 8-12px, and let the outer container fill the panel. Stack only what reads in order (heading over paragraph, forms, steps, prose). Nothing carries a minimum size, so an element with no content and no size takes none — give a spacer or a rail its own height/width — while heading/paragraph do keep the margins the browser gives them (zero them, space with the parent gap).\n2c. THEME — it is embedded in the host UI, which MAY BE DARK, so never hardcode a light palette. Take colours from the host variables with a light-dark() fallback — background-color:\"var(--color-background-secondary, light-dark(#ffffff, #1f2430))\", color:\"var(--color-text-primary, light-dark(#0f172a, #e8eaed))\", border-color:\"var(--color-border-primary, light-dark(#e2e8f0, #333a48))\" — and always set `color` wherever you set `background-color` (a brand accent states its own text colour too).\n3. CONTENT — visible copy goes in props.content (text, heading, paragraph, button); heading level is the element subType (\"h1\"..\"h6\"); image/video take props.src. Write real https URLs: everything the widget loads from outside (images, media, fonts, an apiContainer endpoint) is fetched for it by the render server, so redirects and hotlink rules are handled and there is nothing to configure. It must still point at the actual file — use a URL you have seen work, and when you have none, draw the block with a gradient or an inline SVG instead of guessing one. An unknown prop comes back as a warning naming the right one.\n\nCommon types: container, heading, paragraph, text, button, link, image, video, list, listItem, markdown (plitzi://render/types lists every built-in type with descriptions). Widgets can also be data-driven and interactive — an apiContainer fetches at runtime, upsertBinding wires data into elements, and upsertInteractionFlow makes them react to clicks (see the guide).\nREAD the resource plitzi://render/guide first — it has the element/prop table, the style model and a full worked example, and following it is the difference between a widget that renders and repeated failed calls.\nITERATING — to change a widget you already rendered, do NOT rebuild it: call again with patch:true, the `renderId` that render answered with, and ONLY the operations that differ (patchDefinition, patchElement, deleteElement…). The widget merges them and reports back what it applied; address rows by the refs you already know. Patch ONLY to modify that widget: a different subject or a different kind of widget is a fresh render, without patch — a patch is merged into the previous batch, so patching a new idea leaves you with both.\nReturns a compact summary including the renderId (the widget itself is shown to the user); on failure it returns teachable errors (path + hint) — read them and retry.",
|
|
175
177
|
inputShape: renderShape,
|
|
176
178
|
access: "read",
|
|
177
179
|
spaceless: true,
|
|
178
180
|
ui: { resourceUri: RENDER_APP_URI },
|
|
179
|
-
run: (input) => input.patch === true ? toPatchResult(input.operations, input.renderId) : toRenderResult(render(input), input.renderId ?? newRenderId())
|
|
181
|
+
run: (input, ctx) => input.patch === true ? toPatchResult(input.operations, input.renderId) : toRenderResult(render(input, { proxy: ctx.proxy }), input.renderId ?? newRenderId())
|
|
180
182
|
});
|
|
181
183
|
//#endregion
|
|
182
184
|
export { render, renderShape, renderTool };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createMcpServer } from './server';
|
|
2
|
+
import { ResourceProxy } from './proxy';
|
|
2
3
|
import { PreviewClient, ScreenshotClient } from './types';
|
|
3
4
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
5
|
import { SSRAdapters, SSRRequest, ServerLogger } from '@plitzi/sdk-shared';
|
|
@@ -11,6 +12,9 @@ export type McpRequestOptions = {
|
|
|
11
12
|
logger?: ServerLogger;
|
|
12
13
|
/** Deployment switch for the plitzi_render view (see `mcpAi.renderStreaming`). Defaults to true. */
|
|
13
14
|
renderStreaming?: boolean;
|
|
15
|
+
/** Where a rendered widget loads everything external from (see `mcpAi.proxy`). Absent → the URLs an agent
|
|
16
|
+
* authored travel to the host exactly as written. */
|
|
17
|
+
proxy?: ResourceProxy;
|
|
14
18
|
};
|
|
15
19
|
export declare const readMcpBody: (req: IncomingMessage) => Promise<unknown>;
|
|
16
20
|
export declare const serveMcp: (raw: IncomingMessage, res: ServerResponse, server: McpServer) => Promise<string | undefined>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ResourceProxy, ResourceProxySettings } from './types';
|
|
2
|
+
import { SSRRequest, SSRServerConfig } from '@plitzi/sdk-shared';
|
|
3
|
+
export declare const PROXY_PATH = "/__proxy";
|
|
4
|
+
/** What this deployment serves at its widget endpoint, or undefined when it serves none. A secret is what turns it
|
|
5
|
+
* on: without one the endpoint could not tell its own widgets' URLs from anyone else's, and an unsigned fetcher
|
|
6
|
+
* on a public origin is an open proxy. */
|
|
7
|
+
export declare const proxySettings: (config: SSRServerConfig) => ResourceProxySettings | undefined;
|
|
8
|
+
/** The endpoint as a widget must address it: absolute, because the page runs on the host's origin and a relative
|
|
9
|
+
* URL there points at the host. Falls back to the origin this request came in on, which is the right one whenever
|
|
10
|
+
* the MCP server owns its sub-domain; a deployment reached under a different public name sets `proxy.baseUrl`.
|
|
11
|
+
* The grants it mints carry the fingerprint of THIS request's credential, so they belong to this connection. */
|
|
12
|
+
export declare const requestProxy: (config: SSRServerConfig, req: SSRRequest) => ResourceProxy | undefined;
|
|
13
|
+
/** The proxy THIS tool may use, or undefined — the single place that decides it, so a tool cannot reach the
|
|
14
|
+
* endpoint by being wired to it later. A render authors a throwaway widget, so rewriting its URLs is invisible
|
|
15
|
+
* and lasts as long as the widget does; a tool that WRITES a space (plitzi_apply) would persist those URLs into
|
|
16
|
+
* content the user owns, which is why it is not on the list unless a deployment puts it there deliberately. */
|
|
17
|
+
export declare const proxyForTool: (proxy: ResourceProxy | undefined, tool: string) => ResourceProxy | undefined;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ProxyKind } from './types';
|
|
2
|
+
export type FetchFailure = {
|
|
3
|
+
ok: false;
|
|
4
|
+
status: number;
|
|
5
|
+
reason: string;
|
|
6
|
+
};
|
|
7
|
+
export type FetchSuccess = {
|
|
8
|
+
ok: true;
|
|
9
|
+
contentType: string;
|
|
10
|
+
contentLength?: number;
|
|
11
|
+
body: Response['body'];
|
|
12
|
+
};
|
|
13
|
+
export type FetchResult = FetchFailure | FetchSuccess;
|
|
14
|
+
/** Fetch a remote resource on the widget's behalf. Redirects are followed by hand rather than by fetch, because
|
|
15
|
+
* each hop is a new host that has to pass the same guard — an allowed URL that redirects into the private network
|
|
16
|
+
* would otherwise walk straight through it. (It is also what makes the redirect-based image services work at
|
|
17
|
+
* all: the sandbox loses the redirect, this does not.) */
|
|
18
|
+
export declare const fetchResource: (target: string, kind: ProxyKind, maxBytes: number) => Promise<FetchResult>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ProxyKind, ResourceProxy } from './types';
|
|
2
|
+
/** What a widget was granted. The kind is signed in too, so a URL minted for an image cannot be replayed as an
|
|
3
|
+
* API call (they answer with different content types, cache rules and request headers). */
|
|
4
|
+
export type Grant = {
|
|
5
|
+
kind: ProxyKind;
|
|
6
|
+
target: string;
|
|
7
|
+
};
|
|
8
|
+
/** Mint the URL a widget will load this target from: this server's endpoint, carrying the target, the kind it was
|
|
9
|
+
* granted for, when the grant stops working and which connection minted it — all covered by one signature. */
|
|
10
|
+
export declare const grantUrl: (target: string, proxy: ResourceProxy, kind?: ProxyKind) => string;
|
|
11
|
+
/** What this endpoint was asked to fetch, or undefined when the parameter is missing, malformed, expired or not
|
|
12
|
+
* signed here — which is what keeps it from being an open proxy for anyone who finds it: it serves the URLs this
|
|
13
|
+
* server rewrote, for as long as it said, and nothing else.
|
|
14
|
+
*
|
|
15
|
+
* Takes the parameter as the query parser hands it over — decoded exactly once, which is the form that was
|
|
16
|
+
* signed. Decoding it again would corrupt every target that legitimately carries a percent-escape (an API URL
|
|
17
|
+
* with a nested URL in its query) and reject it as unsigned. */
|
|
18
|
+
export declare const readGrant: (param: string | undefined, secret: string) => Grant | undefined;
|
|
19
|
+
export declare const PROXY_PARAM = "i";
|
|
20
|
+
/** Is this URL one the widget can already reach? Absolute http(s) URLs are the ones a sandbox CSP blocks;
|
|
21
|
+
* `data:`/`blob:` carry their own bytes, and a relative URL has no origin to reach in the first place. */
|
|
22
|
+
export declare const isRemote: (url: string) => boolean;
|
|
23
|
+
export declare const isGranted: (url: string, proxy: ResourceProxy) => boolean;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const isPrivateAddress: (address: string) => boolean;
|
|
2
|
+
/** Does this hostname resolve somewhere on the public internet? The endpoint fetches URLs an agent authored, so
|
|
3
|
+
* without this it would be a way to read whatever the pod itself can reach — the cluster's services, the cloud
|
|
4
|
+
* metadata endpoint, a database on the node. Both the literal address and every address the name resolves to are
|
|
5
|
+
* checked; a name that does not resolve is refused rather than handed to fetch. */
|
|
6
|
+
export declare const isPublicHost: (hostname: string) => Promise<boolean>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ResourceProxySettings } from './types';
|
|
2
|
+
import { SSRRequest, SSRResponseHelpers } from '@plitzi/sdk-shared';
|
|
3
|
+
/** Serve one external resource on behalf of a widget: check the grant this server signed, fetch the target, and
|
|
4
|
+
* stream it back under this origin — the one the CSP declares. Read-only, credential-free and CORS-open, because
|
|
5
|
+
* it answers the host's sandboxed iframe: a browser that has no origin of its own and can present nothing. The
|
|
6
|
+
* grant is what stands in for a caller identity — it is unforgeable, it names one target (or one host, for a
|
|
7
|
+
* templated API URL), it names the kind, it expires, and it carries the connection that minted it. */
|
|
8
|
+
export declare const handleProxyRequest: (req: SSRRequest, res: SSRResponseHelpers, settings: ResourceProxySettings) => Promise<void>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { PROXY_PATH, proxyForTool, proxySettings, requestProxy } from './config';
|
|
2
|
+
export { grantUrl, isGranted, isRemote, PROXY_PARAM, readGrant } from './grant';
|
|
3
|
+
export { handleProxyRequest } from './handler';
|
|
4
|
+
export { proxifyResources, rewriteText } from './rewrite';
|
|
5
|
+
export { connectionId, sign, verify } from './sign';
|
|
6
|
+
export { DEFAULT_PROXY_TOOLS } from './types';
|
|
7
|
+
export type { ProxyKind, ResourceProxy, ResourceProxySettings } from './types';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ResourceProxy } from './types';
|
|
2
|
+
import { Schema, Style } from '@plitzi/sdk-shared';
|
|
3
|
+
/** Rewrite every remote URL embedded in a string: `url(…)` in CSS, `src="…"` in markup, `` in markdown. */
|
|
4
|
+
export declare const rewriteText: (text: string, proxy: ResourceProxy) => string;
|
|
5
|
+
/** Point everything an authored widget loads from outside — images, media, fonts, and the data an apiContainer
|
|
6
|
+
* fetches — at this server's endpoint, in place. The AGENT never sees this: it authors the real URL and this runs
|
|
7
|
+
* afterwards, on the throwaway space a render builds (so nothing shared is mutated) and before the global style
|
|
8
|
+
* cache is compiled (so the concatenated CSS comes out already rewritten). Returns what could not be rewritten,
|
|
9
|
+
* which is the only part the model hears about. */
|
|
10
|
+
export declare const proxifyResources: (space: {
|
|
11
|
+
schema: Schema;
|
|
12
|
+
style: Style;
|
|
13
|
+
}, proxy: ResourceProxy) => string[];
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const sign: (payload: string, secret: string) => string;
|
|
2
|
+
export declare const verify: (payload: string, signature: string, secret: string) => boolean;
|
|
3
|
+
/** A short, stable fingerprint of the MCP credential a request carried — the connection a grant belongs to.
|
|
4
|
+
* Derived through the same secret, so it identifies without echoing any part of the token; a request with no
|
|
5
|
+
* credential (the public widgets surface) gets the shared anonymous identity. */
|
|
6
|
+
export declare const connectionId: (authorization: string | undefined, secret: string) => string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** What a widget is loading. Both ride the same endpoint but are not the same thing: an asset is immutable and
|
|
2
|
+
* cached hard, data is an API answer that must never be served stale, and each accepts its own content types. */
|
|
3
|
+
export type ProxyKind = 'asset' | 'data';
|
|
4
|
+
/** How a widget reaches ANYTHING outside its sandbox — an image, a font, a video, an API.
|
|
5
|
+
*
|
|
6
|
+
* An MCP App runs in an iframe on the HOST's origin under a CSP the host builds from the domains this server
|
|
7
|
+
* declares, and that declaration belongs to the `ui://` RESOURCE: it is read (and may be cached) before any
|
|
8
|
+
* widget exists, the spec types `csp` as `never` on the tool meta, and plitzi_render is stateless, so the origins
|
|
9
|
+
* an agent is about to author can never be listed there. The one origin that CAN be declared up front is this
|
|
10
|
+
* server's own, so every external URL a render authored is rewritten to point here and this server fetches the
|
|
11
|
+
* original — which also settles the redirects, hotlink rules and missing CORS headers a null-origin sandbox
|
|
12
|
+
* cannot. Nothing is stored: the URL carries its own signed grant, so any replica answers any request. */
|
|
13
|
+
export interface ResourceProxy {
|
|
14
|
+
/** Absolute endpoint the browser fetches from, e.g. `https://mcp.plitzi.app/__proxy`. */
|
|
15
|
+
endpoint: string;
|
|
16
|
+
/** Signs the grant, so the endpoint serves what this server rewrote and not whatever a caller asks for. */
|
|
17
|
+
secret: string;
|
|
18
|
+
/** Fingerprint of the MCP credential the render was called with (see `connectionId`). It is signed into every
|
|
19
|
+
* URL, so the grants one connection minted are distinct from another's and a widget's URLs cannot be pooled. */
|
|
20
|
+
identity: string;
|
|
21
|
+
/** How long a minted URL stays valid, in seconds. A widget outlives the call that made it (it sits in the
|
|
22
|
+
* conversation), so this is days rather than minutes — but not forever, so a leaked URL stops working. */
|
|
23
|
+
ttl: number;
|
|
24
|
+
/** The tools allowed to rewrite through this endpoint (see {@link DEFAULT_PROXY_TOOLS}). Carried here because
|
|
25
|
+
* the decision is made once, where each tool's context is built. */
|
|
26
|
+
tools: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
/** The tools a proxy may be handed to. `plitzi_render` alone by default, and that default is a guard, not a
|
|
29
|
+
* convenience: a render is a THROWAWAY widget, so rewriting its URLs changes nothing that outlives the call —
|
|
30
|
+
* while plitzi_apply writes to the user's real space, where a rewritten URL would be PERSISTED and the space
|
|
31
|
+
* would come to depend on this server's endpoint for content it owns. Nothing else should be added here without
|
|
32
|
+
* that being the intention. */
|
|
33
|
+
export declare const DEFAULT_PROXY_TOOLS: string[];
|
|
34
|
+
/** The endpoint's own side of the same feature: where it answers and what it will carry. */
|
|
35
|
+
export interface ResourceProxySettings {
|
|
36
|
+
path: string;
|
|
37
|
+
secret: string;
|
|
38
|
+
maxBytes: number;
|
|
39
|
+
ttl: number;
|
|
40
|
+
/** Which tools rewrite their URLs through the endpoint (see {@link DEFAULT_PROXY_TOOLS}). */
|
|
41
|
+
tools: readonly string[];
|
|
42
|
+
/** Absolute base to build widget URLs from. Empty → the origin each request arrived on, which is right whenever
|
|
43
|
+
* the MCP server owns its sub-domain. */
|
|
44
|
+
baseUrl?: string;
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { ResourceProxy } from './proxy';
|
|
2
3
|
import { PreviewClient, ScreenshotClient } from './types';
|
|
3
4
|
import { SSRAdapters, ServerLogger } from '@plitzi/sdk-shared';
|
|
4
5
|
/** The MCP service is stateless: every request resolves its own `spaceId` (from the request JWT) and reads the
|
|
@@ -22,5 +23,9 @@ export interface McpServerContext {
|
|
|
22
23
|
/** May the plitzi_render view paint from tool arguments the host is still streaming (see `mcpAi.renderStreaming`)?
|
|
23
24
|
* Defaults to true. */
|
|
24
25
|
renderStreaming?: boolean;
|
|
26
|
+
/** Where a rendered widget loads everything external from (see `mcpAi.proxy`). It reaches two places: the render
|
|
27
|
+
* tool, which rewrites the URLs the agent authored, and the App's CSP, which declares the origin those URLs
|
|
28
|
+
* point at. Absent → neither happens, and a widget's resources load only where the host allows their origin. */
|
|
29
|
+
proxy?: ResourceProxy;
|
|
25
30
|
}
|
|
26
|
-
export declare const createMcpServer: ({ adapters, getSpaceId, preview, screenshot, logger, renderStreaming }: McpServerContext) => Promise<McpServer>;
|
|
31
|
+
export declare const createMcpServer: ({ adapters, getSpaceId, preview, screenshot, logger, renderStreaming, proxy }: McpServerContext) => Promise<McpServer>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { ResourceProxy } from '../proxy';
|
|
2
3
|
import { Operation } from './operations';
|
|
3
4
|
import { OfflineDataRaw } from '@plitzi/sdk-shared';
|
|
4
5
|
export declare const renderShape: {
|
|
@@ -470,6 +471,12 @@ export type RenderInput = {
|
|
|
470
471
|
patch?: boolean;
|
|
471
472
|
renderId?: string;
|
|
472
473
|
};
|
|
474
|
+
/** What the render needs from its host beyond the operations. Optional: a host that wires no resource endpoint
|
|
475
|
+
* still renders a widget — one whose external URLs travel as authored and load only where the surface allows
|
|
476
|
+
* their origin. */
|
|
477
|
+
export type RenderOptions = {
|
|
478
|
+
proxy?: ResourceProxy;
|
|
479
|
+
};
|
|
473
480
|
export type RenderResponse = {
|
|
474
481
|
rendered: false;
|
|
475
482
|
errors: {
|
|
@@ -488,5 +495,5 @@ export type RenderResponse = {
|
|
|
488
495
|
operations: Operation[];
|
|
489
496
|
warnings?: string[];
|
|
490
497
|
};
|
|
491
|
-
export declare const render: (input: RenderInput) => RenderResponse;
|
|
498
|
+
export declare const render: (input: RenderInput, options?: RenderOptions) => RenderResponse;
|
|
492
499
|
export declare const renderTool: import('.').ToolDef;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z, ZodObject, ZodRawShape } from 'zod';
|
|
2
2
|
import { Space } from '../../helpers';
|
|
3
|
+
import { ResourceProxy } from '../../proxy';
|
|
3
4
|
import { PreviewClient, ScreenshotClient, ScreenshotImage, Env, Persisters } from '../../types';
|
|
4
5
|
import { McpUiToolMeta } from '@modelcontextprotocol/ext-apps';
|
|
5
6
|
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
@@ -14,6 +15,10 @@ export interface ToolContext {
|
|
|
14
15
|
spaceId?: number;
|
|
15
16
|
preview?: PreviewClient;
|
|
16
17
|
screenshot?: ScreenshotClient;
|
|
18
|
+
/** Where a rendered widget loads its external resources from, when the host wired one (see `mcpAi.proxy`). Only
|
|
19
|
+
* plitzi_render uses it: a widget renders inside the host's sandbox, where an undeclared origin cannot be
|
|
20
|
+
* reached at all. */
|
|
21
|
+
proxy?: ResourceProxy;
|
|
17
22
|
}
|
|
18
23
|
/** A capability a tool depends on; the host skips registering a tool whose capability it did not wire (so
|
|
19
24
|
* plitzi_screenshot simply does not appear when no browser service is configured). */
|
|
@@ -18,4 +18,9 @@ export interface McpApp {
|
|
|
18
18
|
export interface McpViewSettings {
|
|
19
19
|
/** May the view paint from tool arguments the host is still streaming? See `mcpAi.renderStreaming`. */
|
|
20
20
|
streaming: boolean;
|
|
21
|
+
/** Absolute resource endpoint of the server that served the page, when it serves one (see `mcpAi.proxy`). It
|
|
22
|
+
* shapes the PAGE rather than the view: its origin is what the resource CSP declares, so the URLs a render
|
|
23
|
+
* rewrote to it are allowed to load. The view needs nothing from it — every URL is rewritten server-side,
|
|
24
|
+
* where the signing secret is. */
|
|
25
|
+
proxyEndpoint?: string;
|
|
21
26
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plitzi/sdk-server",
|
|
3
|
-
"version": "0.32.
|
|
3
|
+
"version": "0.32.22",
|
|
4
4
|
"license": "AGPL-3.0",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@modelcontextprotocol/ext-apps": "^1.7.5",
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
33
|
-
"@plitzi/plitzi-sdk": "0.32.
|
|
34
|
-
"@plitzi/sdk-schema": "0.32.
|
|
35
|
-
"@plitzi/sdk-shared": "0.32.
|
|
33
|
+
"@plitzi/plitzi-sdk": "0.32.22",
|
|
34
|
+
"@plitzi/sdk-schema": "0.32.22",
|
|
35
|
+
"@plitzi/sdk-shared": "0.32.22",
|
|
36
36
|
"ejs": "^6.0.1",
|
|
37
37
|
"esbuild": "^0.28.1",
|
|
38
38
|
"zod": "^4.4.3"
|