@tuongaz/seeflow 0.1.76 → 0.1.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/dist/web/assets/{architectureDiagram-3BPJPVTR-CVOsL8en.js → architectureDiagram-3BPJPVTR-id0XTZQC.js} +1 -1
- package/dist/web/assets/{blockDiagram-GPEHLZMM-C4ln3pHP.js → blockDiagram-GPEHLZMM-Cjvfg0ZP.js} +1 -1
- package/dist/web/assets/{c4Diagram-AAUBKEIU-COBq_O0m.js → c4Diagram-AAUBKEIU-Dyq-0e8Q.js} +1 -1
- package/dist/web/assets/channel-Ajb6KiL3.js +1 -0
- package/dist/web/assets/{chart-DD5RfLtm.js → chart-DuTGW-Dj.js} +1 -1
- package/dist/web/assets/{chunk-2J33WTMH-Bdm-YMMv.js → chunk-2J33WTMH-DsD65OzD.js} +1 -1
- package/dist/web/assets/{chunk-4BX2VUAB-BV9NRSCp.js → chunk-4BX2VUAB-BpytKE8P.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-D-Xe7eUY.js → chunk-55IACEB6-DIILAUq9.js} +1 -1
- package/dist/web/assets/{chunk-727SXJPM-DB6EZ4tw.js → chunk-727SXJPM-C4ih-gTo.js} +1 -1
- package/dist/web/assets/{chunk-AQP2D5EJ-DDlp8cdl.js → chunk-AQP2D5EJ-BsYoWdVM.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-qmkSV1r1.js → chunk-FMBD7UC4-Db6L0z4p.js} +1 -1
- package/dist/web/assets/{chunk-ND2GUHAM-BFkM9OZ5.js → chunk-ND2GUHAM-BNLqZYMx.js} +1 -1
- package/dist/web/assets/{chunk-QZHKN3VN-Cye5hUth.js → chunk-QZHKN3VN-DL5PK45j.js} +1 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-Cgw6ezRo.js +1 -0
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-Cgw6ezRo.js +1 -0
- package/dist/web/assets/{code-block-B2Jj4pwe.js → code-block-C1SJv-Al.js} +1 -1
- package/dist/web/assets/{cose-bilkent-S5V4N54A-B8Q15-m-.js → cose-bilkent-S5V4N54A-ChX5nR0f.js} +1 -1
- package/dist/web/assets/{dagre-BM42HDAG-BEYccL0v.js → dagre-BM42HDAG-BXeL3fEN.js} +1 -1
- package/dist/web/assets/{diagram-2AECGRRQ-BHPvhIzV.js → diagram-2AECGRRQ-B6WtmEP-.js} +1 -1
- package/dist/web/assets/{diagram-5GNKFQAL-Csqbxt3l.js → diagram-5GNKFQAL-SXs7ALwM.js} +1 -1
- package/dist/web/assets/{diagram-KO2AKTUF-BR6CIJyT.js → diagram-KO2AKTUF-D5zylPYo.js} +1 -1
- package/dist/web/assets/{diagram-LMA3HP47-CNtClLgB.js → diagram-LMA3HP47-CByIUlQF.js} +1 -1
- package/dist/web/assets/{diagram-OG6HWLK6-6C-uLOFp.js → diagram-OG6HWLK6-BH1MfUqV.js} +1 -1
- package/dist/web/assets/{erDiagram-TEJ5UH35-WbZOwkSY.js → erDiagram-TEJ5UH35-BOOnRFBh.js} +1 -1
- package/dist/web/assets/{flowDiagram-I6XJVG4X-DkXzWb1H.js → flowDiagram-I6XJVG4X-BynWDHJP.js} +1 -1
- package/dist/web/assets/{ganttDiagram-6RSMTGT7-CUvI4hDd.js → ganttDiagram-6RSMTGT7-Cgq_djyN.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-PVQCEYII-DQ5Z5DTr.js → gitGraphDiagram-PVQCEYII-ciGSgmfT.js} +1 -1
- package/dist/web/assets/index-DiakpHyc.js +8619 -0
- package/dist/web/assets/{index-DljfurDC.css → index-fl8DS9WO.css} +1 -1
- package/dist/web/assets/{index.es-DU_7oRoK.js → index.es-C7TtaIfa.js} +1 -1
- package/dist/web/assets/{infoDiagram-5YYISTIA-CVl3bX4h.js → infoDiagram-5YYISTIA-DqMb3_c-.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-CQs9MZtd.js → ishikawaDiagram-YF4QCWOH-CAO6KqQU.js} +1 -1
- package/dist/web/assets/{journeyDiagram-JHISSGLW-B9emRTSL.js → journeyDiagram-JHISSGLW-Di8MsLTo.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-DO9eBtyx.js → jspdf.es.min-Cq4dY-lT.js} +3 -3
- package/dist/web/assets/{kanban-definition-UN3LZRKU-CU4PXBkO.js → kanban-definition-UN3LZRKU-ClOmVNcX.js} +1 -1
- package/dist/web/assets/{linear-Djd98ym6.js → linear-B3OKBKaT.js} +1 -1
- package/dist/web/assets/{markdown-Dtbihta2.js → markdown-Dg8NEx1K.js} +1 -1
- package/dist/web/assets/{mermaid.core--5B4uu7H.js → mermaid.core-Bw-m7bH-.js} +4 -4
- package/dist/web/assets/{mindmap-definition-RKZ34NQL-DBa-qOvW.js → mindmap-definition-RKZ34NQL-CUBA1zfc.js} +1 -1
- package/dist/web/assets/{pieDiagram-4H26LBE5-BgO03eBD.js → pieDiagram-4H26LBE5-Dux5HvSU.js} +1 -1
- package/dist/web/assets/{quadrantDiagram-W4KKPZXB-D5qnQqxO.js → quadrantDiagram-W4KKPZXB-DU3gQGo3.js} +1 -1
- package/dist/web/assets/{requirementDiagram-4Y6WPE33-CvFTU_QP.js → requirementDiagram-4Y6WPE33-CD3A_U9j.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-5OEKKPKP-BSwt6J0r.js → sankeyDiagram-5OEKKPKP-Cd4mc26P.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-3UESZ5HK-B6xwt7gx.js → sequenceDiagram-3UESZ5HK-Da0iOMgq.js} +1 -1
- package/dist/web/assets/{stateDiagram-AJRCARHV-B4aBLe9A.js → stateDiagram-AJRCARHV-P94LaOD2.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU--JLHF28o.js +1 -0
- package/dist/web/assets/{time-VmDaOXzG.js → time-0JEErjjJ.js} +1 -1
- package/dist/web/assets/{timeline-definition-PNZ67QCA-Bp46ZbZu.js → timeline-definition-PNZ67QCA-BqAYomix.js} +1 -1
- package/dist/web/assets/{vennDiagram-CIIHVFJN-ObB0ozDF.js → vennDiagram-CIIHVFJN-BWuPhfIM.js} +1 -1
- package/dist/web/assets/{wardley-L42UT6IY-DZQGppGX.js → wardley-L42UT6IY-iiGkgUQj.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-YWT4CUSO-B3dCDfV5.js → wardleyDiagram-YWT4CUSO-CtqzFQXL.js} +1 -1
- package/dist/web/assets/{xychartDiagram-2RQKCTM6-CpxmkRi4.js → xychartDiagram-2RQKCTM6-BGrOXndI.js} +1 -1
- package/dist/web/index.html +2 -2
- package/examples/component-showcase/seeflow.json +6 -0
- package/examples/ecommerce-platform/seeflow.json +6 -0
- package/examples/order-pipeline/seeflow.json +6 -0
- package/package.json +1 -1
- package/src/api.ts +739 -94
- package/src/cli-e2e.ts +24 -13
- package/src/cli-helpers.ts +26 -0
- package/src/cli-manifest.ts +330 -87
- package/src/cli-ops.ts +56 -2
- package/src/cli.ts +228 -81
- package/src/cors.ts +93 -0
- package/src/jq-filter.ts +253 -0
- package/src/mcp-shim.ts +114 -7
- package/src/mcp-ui.ts +126 -0
- package/src/mcp.ts +258 -97
- package/src/node-files.ts +18 -7
- package/src/operations.ts +68 -32
- package/src/project-scanner.ts +105 -0
- package/src/registry.ts +79 -18
- package/src/route-resolve.ts +41 -0
- package/src/schema.ts +54 -0
- package/src/server.ts +24 -3
- package/src/slugify.ts +16 -0
- package/dist/web/assets/channel-BpDUSI6-.js +0 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-po1qHJgX.js +0 -1
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-po1qHJgX.js +0 -1
- package/dist/web/assets/index-DJa2Qm_q.js +0 -8614
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-D1iGL3Mj.js +0 -1
- /package/examples/component-showcase/{flow.json → flows/main/flow.json} +0 -0
- /package/examples/component-showcase/{nodes → flows/main/nodes}/chart/spec.json +0 -0
- /package/examples/component-showcase/{nodes → flows/main/nodes}/counter/spec.json +0 -0
- /package/examples/component-showcase/{nodes → flows/main/nodes}/fetcher/actions/refresh.ts +0 -0
- /package/examples/component-showcase/{nodes → flows/main/nodes}/fetcher/spec.json +0 -0
- /package/examples/component-showcase/{nodes → flows/main/nodes}/form/spec.json +0 -0
- /package/examples/component-showcase/{style.json → flows/main/style.json} +0 -0
- /package/examples/ecommerce-platform/{flow.json → flows/main/flow.json} +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-3zFtHg6ENc/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-5F424NWbEu/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-CbwYqb7NfB/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-XwygzfKPZ5/view.html +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-fkptXw7uvs/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-kwBY8YPmYM/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-mPqan8rFYN/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-yKrg9DV5fJ/detail.md +0 -0
- /package/examples/ecommerce-platform/{scripts → flows/main/scripts}/play.ts +0 -0
- /package/examples/ecommerce-platform/{style.json → flows/main/style.json} +0 -0
- /package/examples/order-pipeline/{flow.json → flows/main/flow.json} +0 -0
- /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-GXTKUcE3ye/detail.md +0 -0
- /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-XKIyds0TDg/detail.md +0 -0
- /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-YOYiHJpY0i/detail.md +0 -0
- /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-zUIH7WFnhK/detail.md +0 -0
- /package/examples/order-pipeline/{scripts → flows/main/scripts}/play.ts +0 -0
- /package/examples/order-pipeline/{style.json → flows/main/style.json} +0 -0
package/src/cors.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Studio CORS + per-process token gate.
|
|
2
|
+
//
|
|
3
|
+
// The MCP App iframe (Claude Desktop) lives in a sandboxed `Origin: null`
|
|
4
|
+
// frame. To prevent drive-by access from other localhost software, every
|
|
5
|
+
// `null`-origin request must carry the per-process token in the
|
|
6
|
+
// `X-Seeflow-Token` header. The token is delivered to the iframe via
|
|
7
|
+
// `_meta['openai/widgetState'].backendToken` from the MCP tool response —
|
|
8
|
+
// no other channel exists, so other localhost processes can't observe it.
|
|
9
|
+
//
|
|
10
|
+
// Other origins:
|
|
11
|
+
// - Missing Origin (server-side fetches, integration tests, top-level
|
|
12
|
+
// navigation): pass through with no CORS headers.
|
|
13
|
+
// - Localhost / 127.0.0.1 / [::1] (any port, dev SPA): allow with CORS
|
|
14
|
+
// headers so cross-origin XHR (Tailscale + multi-port dev) still works.
|
|
15
|
+
// - Other origins: pass through with no CORS headers — the browser's
|
|
16
|
+
// own CORS policy blocks cross-origin XHR. We don't 403 them because
|
|
17
|
+
// the dev workflow runs over Tailscale hostnames and we don't want to
|
|
18
|
+
// hard-code that allowlist server-side.
|
|
19
|
+
//
|
|
20
|
+
// Preflight rules: `OPTIONS` from a null origin is allowed unconditionally
|
|
21
|
+
// (the browser can't put `X-Seeflow-Token` on a preflight — it goes in
|
|
22
|
+
// `Access-Control-Request-Headers` instead). The browser will then issue
|
|
23
|
+
// the actual request carrying the token, and THAT one is gated.
|
|
24
|
+
|
|
25
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
26
|
+
import type { Context, Next } from 'hono';
|
|
27
|
+
|
|
28
|
+
const LOCALHOST_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
29
|
+
const ALLOWED_HEADERS = 'Content-Type, X-Seeflow-Token';
|
|
30
|
+
const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
|
31
|
+
const TOKEN_HEADER = 'x-seeflow-token';
|
|
32
|
+
|
|
33
|
+
const isLocalhostOrigin = (origin: string): boolean => {
|
|
34
|
+
try {
|
|
35
|
+
const url = new URL(origin);
|
|
36
|
+
return LOCALHOST_HOSTS.has(url.hostname);
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Constant-time comparison to keep timing attacks off the table. 128-bit
|
|
43
|
+
// UUIDs make this overkill in practice, but `timingSafeEqual` is cheap and
|
|
44
|
+
// removes the question entirely.
|
|
45
|
+
const tokensMatch = (a: string, b: string): boolean => {
|
|
46
|
+
if (a.length !== b.length) return false;
|
|
47
|
+
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const setAllowedHeaders = (c: Context, origin: string): void => {
|
|
51
|
+
c.header('Access-Control-Allow-Origin', origin);
|
|
52
|
+
c.header('Vary', 'Origin');
|
|
53
|
+
c.header('Access-Control-Allow-Headers', ALLOWED_HEADERS);
|
|
54
|
+
c.header('Access-Control-Allow-Methods', ALLOWED_METHODS);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const createCorsMiddleware = (token: string | undefined) => {
|
|
58
|
+
return async (c: Context, next: Next) => {
|
|
59
|
+
const origin = c.req.header('origin');
|
|
60
|
+
if (!origin) {
|
|
61
|
+
await next();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (origin === 'null') {
|
|
66
|
+
if (c.req.method === 'OPTIONS') {
|
|
67
|
+
setAllowedHeaders(c, origin);
|
|
68
|
+
return c.body(null, 204);
|
|
69
|
+
}
|
|
70
|
+
const reqToken = c.req.header(TOKEN_HEADER);
|
|
71
|
+
if (!token || !reqToken || !tokensMatch(reqToken, token)) {
|
|
72
|
+
return c.text('Forbidden', 403);
|
|
73
|
+
}
|
|
74
|
+
setAllowedHeaders(c, origin);
|
|
75
|
+
await next();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (isLocalhostOrigin(origin)) {
|
|
80
|
+
setAllowedHeaders(c, origin);
|
|
81
|
+
if (c.req.method === 'OPTIONS') {
|
|
82
|
+
return c.body(null, 204);
|
|
83
|
+
}
|
|
84
|
+
await next();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Non-localhost, non-null origin: no CORS headers. Browser blocks the
|
|
89
|
+
// cross-origin read; the request still reaches the route handler for
|
|
90
|
+
// server-side / same-origin-by-port scenarios.
|
|
91
|
+
await next();
|
|
92
|
+
};
|
|
93
|
+
};
|
package/src/jq-filter.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Minimal jq-style path filter for `seeflow schema --jq <filter>`. Path
|
|
2
|
+
// subset only: identity, field access (`.foo.bar`), bracket access
|
|
3
|
+
// (`.["foo"]`, `.[3]`), iteration (`.[]`), optional (`?`), and pipe (`|`).
|
|
4
|
+
// No comma, no `length`, no functions — keep the surface tight so behaviour
|
|
5
|
+
// matches the real jq tool for the subset we do support. Throws JqError on
|
|
6
|
+
// parse failures and on type errors that the trailing `?` did not suppress.
|
|
7
|
+
|
|
8
|
+
export class JqError extends Error {
|
|
9
|
+
constructor(message: string) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'JqError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Step =
|
|
16
|
+
| { kind: 'field'; name: string; optional: boolean }
|
|
17
|
+
| { kind: 'index'; index: number; optional: boolean }
|
|
18
|
+
| { kind: 'key'; key: string; optional: boolean }
|
|
19
|
+
| { kind: 'iter'; optional: boolean };
|
|
20
|
+
|
|
21
|
+
type Term = Step[];
|
|
22
|
+
type Filter = Term[];
|
|
23
|
+
|
|
24
|
+
const isIdentStart = (ch: string): boolean => /[A-Za-z_]/.test(ch);
|
|
25
|
+
const isIdent = (ch: string): boolean => /[A-Za-z0-9_]/.test(ch);
|
|
26
|
+
|
|
27
|
+
function parseFilter(src: string): Filter {
|
|
28
|
+
const input = src;
|
|
29
|
+
let i = 0;
|
|
30
|
+
const len = input.length;
|
|
31
|
+
|
|
32
|
+
const skipSpace = (): void => {
|
|
33
|
+
while (i < len && /\s/.test(input[i] as string)) i++;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const expect = (ch: string): void => {
|
|
37
|
+
if (input[i] !== ch) {
|
|
38
|
+
throw new JqError(
|
|
39
|
+
`Expected '${ch}' at position ${i} in filter '${src}' (got '${input[i] ?? '<end>'}')`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
i++;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const parseString = (): string => {
|
|
46
|
+
const quote = input[i];
|
|
47
|
+
if (quote !== '"' && quote !== "'") {
|
|
48
|
+
throw new JqError(`Expected string at position ${i} in filter '${src}'`);
|
|
49
|
+
}
|
|
50
|
+
i++;
|
|
51
|
+
let out = '';
|
|
52
|
+
while (i < len && input[i] !== quote) {
|
|
53
|
+
const ch = input[i] as string;
|
|
54
|
+
if (ch === '\\') {
|
|
55
|
+
const next = input[i + 1];
|
|
56
|
+
if (next === undefined) throw new JqError(`Unterminated escape in filter '${src}'`);
|
|
57
|
+
out += next;
|
|
58
|
+
i += 2;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
out += ch;
|
|
62
|
+
i++;
|
|
63
|
+
}
|
|
64
|
+
if (input[i] !== quote) throw new JqError(`Unterminated string in filter '${src}'`);
|
|
65
|
+
i++;
|
|
66
|
+
return out;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const parseNumber = (): number => {
|
|
70
|
+
const start = i;
|
|
71
|
+
if (input[i] === '-') i++;
|
|
72
|
+
while (i < len && /[0-9]/.test(input[i] as string)) i++;
|
|
73
|
+
const raw = input.slice(start, i);
|
|
74
|
+
const n = Number.parseInt(raw, 10);
|
|
75
|
+
if (!Number.isFinite(n)) {
|
|
76
|
+
throw new JqError(`Invalid number '${raw}' at position ${start} in filter '${src}'`);
|
|
77
|
+
}
|
|
78
|
+
return n;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const parseOptional = (): boolean => {
|
|
82
|
+
if (input[i] === '?') {
|
|
83
|
+
i++;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const parseTerm = (): Term => {
|
|
90
|
+
skipSpace();
|
|
91
|
+
if (input[i] !== '.') {
|
|
92
|
+
throw new JqError(
|
|
93
|
+
`Filter term must start with '.' at position ${i} in filter '${src}' (got '${input[i] ?? '<end>'}')`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
i++;
|
|
97
|
+
const steps: Step[] = [];
|
|
98
|
+
// `.` followed by ident, `[`, or end/operator → first step.
|
|
99
|
+
while (i < len) {
|
|
100
|
+
const ch = input[i] as string;
|
|
101
|
+
if (ch === '.') {
|
|
102
|
+
// Chained field: ".foo.bar" — consume the dot and read ident.
|
|
103
|
+
i++;
|
|
104
|
+
if (i >= len || !isIdentStart(input[i] as string)) {
|
|
105
|
+
throw new JqError(`Expected identifier after '.' at position ${i} in filter '${src}'`);
|
|
106
|
+
}
|
|
107
|
+
const start = i;
|
|
108
|
+
while (i < len && isIdent(input[i] as string)) i++;
|
|
109
|
+
const name = input.slice(start, i);
|
|
110
|
+
const optional = parseOptional();
|
|
111
|
+
steps.push({ kind: 'field', name, optional });
|
|
112
|
+
} else if (isIdentStart(ch)) {
|
|
113
|
+
// First step right after leading '.', e.g. ".foo".
|
|
114
|
+
if (steps.length > 0) {
|
|
115
|
+
throw new JqError(`Unexpected identifier at position ${i} in filter '${src}'`);
|
|
116
|
+
}
|
|
117
|
+
const start = i;
|
|
118
|
+
while (i < len && isIdent(input[i] as string)) i++;
|
|
119
|
+
const name = input.slice(start, i);
|
|
120
|
+
const optional = parseOptional();
|
|
121
|
+
steps.push({ kind: 'field', name, optional });
|
|
122
|
+
} else if (ch === '[') {
|
|
123
|
+
i++;
|
|
124
|
+
skipSpace();
|
|
125
|
+
if (input[i] === ']') {
|
|
126
|
+
i++;
|
|
127
|
+
const optional = parseOptional();
|
|
128
|
+
steps.push({ kind: 'iter', optional });
|
|
129
|
+
} else if (input[i] === '"' || input[i] === "'") {
|
|
130
|
+
const key = parseString();
|
|
131
|
+
skipSpace();
|
|
132
|
+
expect(']');
|
|
133
|
+
const optional = parseOptional();
|
|
134
|
+
steps.push({ kind: 'key', key, optional });
|
|
135
|
+
} else if (input[i] === '-' || /[0-9]/.test(input[i] ?? '')) {
|
|
136
|
+
const index = parseNumber();
|
|
137
|
+
skipSpace();
|
|
138
|
+
expect(']');
|
|
139
|
+
const optional = parseOptional();
|
|
140
|
+
steps.push({ kind: 'index', index, optional });
|
|
141
|
+
} else {
|
|
142
|
+
throw new JqError(`Expected index, string, or ']' at position ${i} in filter '${src}'`);
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return steps;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const terms: Term[] = [];
|
|
152
|
+
skipSpace();
|
|
153
|
+
terms.push(parseTerm());
|
|
154
|
+
skipSpace();
|
|
155
|
+
while (i < len) {
|
|
156
|
+
if (input[i] !== '|') {
|
|
157
|
+
throw new JqError(`Unexpected character '${input[i]}' at position ${i} in filter '${src}'`);
|
|
158
|
+
}
|
|
159
|
+
i++;
|
|
160
|
+
skipSpace();
|
|
161
|
+
terms.push(parseTerm());
|
|
162
|
+
skipSpace();
|
|
163
|
+
}
|
|
164
|
+
if (terms.length === 0) {
|
|
165
|
+
throw new JqError(`Empty filter '${src}'`);
|
|
166
|
+
}
|
|
167
|
+
return terms;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function evaluateStep(step: Step, value: unknown): unknown[] {
|
|
171
|
+
if (step.kind === 'field') {
|
|
172
|
+
if (value === null || value === undefined) {
|
|
173
|
+
if (step.optional) return [];
|
|
174
|
+
return [null];
|
|
175
|
+
}
|
|
176
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
177
|
+
if (step.optional) return [];
|
|
178
|
+
throw new JqError(
|
|
179
|
+
`Cannot index ${describeType(value)} with field '${step.name}' (use '?' to suppress)`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
const obj = value as Record<string, unknown>;
|
|
183
|
+
return [Object.hasOwn(obj, step.name) ? obj[step.name] : null];
|
|
184
|
+
}
|
|
185
|
+
if (step.kind === 'key') {
|
|
186
|
+
if (value === null || value === undefined) {
|
|
187
|
+
if (step.optional) return [];
|
|
188
|
+
return [null];
|
|
189
|
+
}
|
|
190
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
191
|
+
if (step.optional) return [];
|
|
192
|
+
throw new JqError(
|
|
193
|
+
`Cannot index ${describeType(value)} with key "${step.key}" (use '?' to suppress)`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const obj = value as Record<string, unknown>;
|
|
197
|
+
return [Object.hasOwn(obj, step.key) ? obj[step.key] : null];
|
|
198
|
+
}
|
|
199
|
+
if (step.kind === 'index') {
|
|
200
|
+
if (value === null || value === undefined) {
|
|
201
|
+
if (step.optional) return [];
|
|
202
|
+
return [null];
|
|
203
|
+
}
|
|
204
|
+
if (!Array.isArray(value)) {
|
|
205
|
+
if (step.optional) return [];
|
|
206
|
+
throw new JqError(
|
|
207
|
+
`Cannot index ${describeType(value)} with number ${step.index} (use '?' to suppress)`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const idx = step.index < 0 ? value.length + step.index : step.index;
|
|
211
|
+
return [idx >= 0 && idx < value.length ? value[idx] : null];
|
|
212
|
+
}
|
|
213
|
+
// iter
|
|
214
|
+
if (value === null || value === undefined) {
|
|
215
|
+
if (step.optional) return [];
|
|
216
|
+
throw new JqError(`Cannot iterate over ${describeType(value)} (use '?' to suppress)`);
|
|
217
|
+
}
|
|
218
|
+
if (Array.isArray(value)) return [...value];
|
|
219
|
+
if (typeof value === 'object') return Object.values(value as Record<string, unknown>);
|
|
220
|
+
if (step.optional) return [];
|
|
221
|
+
throw new JqError(`Cannot iterate over ${describeType(value)} (use '?' to suppress)`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function evaluateTerm(term: Term, input: unknown): unknown[] {
|
|
225
|
+
let stream: unknown[] = [input];
|
|
226
|
+
for (const step of term) {
|
|
227
|
+
const next: unknown[] = [];
|
|
228
|
+
for (const v of stream) {
|
|
229
|
+
for (const out of evaluateStep(step, v)) next.push(out);
|
|
230
|
+
}
|
|
231
|
+
stream = next;
|
|
232
|
+
}
|
|
233
|
+
return stream;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function describeType(value: unknown): string {
|
|
237
|
+
if (value === null) return 'null';
|
|
238
|
+
if (Array.isArray(value)) return 'array';
|
|
239
|
+
return typeof value;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function applyJq(input: unknown, filterStr: string): unknown[] {
|
|
243
|
+
const filter = parseFilter(filterStr);
|
|
244
|
+
let stream: unknown[] = [input];
|
|
245
|
+
for (const term of filter) {
|
|
246
|
+
const next: unknown[] = [];
|
|
247
|
+
for (const v of stream) {
|
|
248
|
+
for (const out of evaluateTerm(term, v)) next.push(out);
|
|
249
|
+
}
|
|
250
|
+
stream = next;
|
|
251
|
+
}
|
|
252
|
+
return stream;
|
|
253
|
+
}
|
package/src/mcp-shim.ts
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// SeeFlow MCP stdio shim.
|
|
3
3
|
//
|
|
4
|
-
// Bridges an MCP stdio client (e.g. Claude Code via .mcp.json) to
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// Bridges an MCP stdio client (e.g. Claude Code via .mcp.json) to a studio
|
|
5
|
+
// backend. Two modes:
|
|
6
|
+
// 1. Embedded (default): boots an in-process Hono studio on an ephemeral
|
|
7
|
+
// loopback port and proxies stdio JSON-RPC to its /mcp endpoint. The
|
|
8
|
+
// iframe canvas rendered by MCP-Apps-capable hosts also connects to
|
|
9
|
+
// this same port. One process, one install.
|
|
10
|
+
// 2. Proxy (when SEEFLOW_STUDIO_URL is set): forwards stdio JSON-RPC to
|
|
11
|
+
// an externally-running studio. Backward-compatible with the existing
|
|
12
|
+
// shim tests and dev workflows where `seeflow studio` already runs.
|
|
9
13
|
//
|
|
10
|
-
//
|
|
14
|
+
// In embedded mode the port is bound BEFORE the stdio transport starts so
|
|
15
|
+
// downstream tool handlers (US-008) can attach the backendUrl to _meta. If
|
|
16
|
+
// port binding fails, the shim emits an MCP `notifications/message` over
|
|
17
|
+
// stdout and falls back to the default proxy URL — the canvas won't render
|
|
18
|
+
// but tool calls still flow (degraded, not crashed).
|
|
19
|
+
//
|
|
20
|
+
// Lifecycle: SIGINT, SIGTERM, beforeExit, and the stdio transport's
|
|
21
|
+
// onclose all release the ephemeral port via server.stop(true). Integration
|
|
22
|
+
// coverage lives in apps/studio/integration/mcp-shim-lifecycle.it.ts.
|
|
11
23
|
|
|
12
24
|
import {
|
|
13
25
|
StreamableHTTPClientTransport,
|
|
@@ -15,12 +27,81 @@ import {
|
|
|
15
27
|
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
16
28
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
17
29
|
import { type JSONRPCMessage, isJSONRPCRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
30
|
+
import type { Server as BunHttpServer } from 'bun';
|
|
31
|
+
import { type CreateAppOptions, createApp } from './server.ts';
|
|
32
|
+
|
|
33
|
+
// Bun's generic Server requires a websocket-data type argument at the type
|
|
34
|
+
// level; we don't attach a websocket handler so `unknown` is the right slot.
|
|
35
|
+
type EphemeralServer = BunHttpServer<unknown>;
|
|
18
36
|
|
|
19
37
|
const DEFAULT_URL = 'http://127.0.0.1:4321/mcp';
|
|
20
38
|
const STUDIO_NOT_RUNNING_MSG = 'SeeFlow studio is not running. Start it with `bun run dev` first.';
|
|
21
39
|
const STUDIO_WITHOUT_MCP_MSG = 'This studio version does not expose MCP. Upgrade required.';
|
|
22
40
|
|
|
23
|
-
|
|
41
|
+
// Treat empty string the same as unset. Spawning subprocesses with an
|
|
42
|
+
// explicit `SEEFLOW_STUDIO_URL: ''` is the cleanest way to clear an
|
|
43
|
+
// inherited value from the parent env without `delete` semantics, so the
|
|
44
|
+
// shim has to handle that path the same as truly absent.
|
|
45
|
+
const explicitStudioUrl = process.env.SEEFLOW_STUDIO_URL?.trim() || undefined;
|
|
46
|
+
|
|
47
|
+
// Emit an MCP `notifications/message` over stdout. JSON-RPC notifications
|
|
48
|
+
// have no id; clients route them to a logging sink. Used by the bind-
|
|
49
|
+
// failure path so the host surfaces a real error instead of silently
|
|
50
|
+
// proxying to a dead address.
|
|
51
|
+
const emitMcpNotification = (level: 'error' | 'warning' | 'info', data: string): void => {
|
|
52
|
+
const notification = {
|
|
53
|
+
jsonrpc: '2.0',
|
|
54
|
+
method: 'notifications/message',
|
|
55
|
+
params: { level, logger: 'seeflow-mcp', data },
|
|
56
|
+
};
|
|
57
|
+
process.stdout.write(`${JSON.stringify(notification)}\n`);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Embedded studio handle. Undefined in proxy mode (SEEFLOW_STUDIO_URL set)
|
|
61
|
+
// and when port binding fails. Lifecycle hooks below null-check before
|
|
62
|
+
// calling .stop().
|
|
63
|
+
let embeddedServer: EphemeralServer | undefined;
|
|
64
|
+
let embeddedUrl: string | undefined;
|
|
65
|
+
|
|
66
|
+
if (!explicitStudioUrl) {
|
|
67
|
+
try {
|
|
68
|
+
// Per-process token: generated once at shim boot, held in memory,
|
|
69
|
+
// never persisted, never logged. The MCP App iframe receives it via
|
|
70
|
+
// `_meta['openai/widgetState'].backendToken` (US-008) and replays it
|
|
71
|
+
// on every cross-origin request as `X-Seeflow-Token`. Anything else
|
|
72
|
+
// hitting the ephemeral port from `Origin: null` (other localhost
|
|
73
|
+
// software, drive-by tabs) gets 403'd by the CORS middleware.
|
|
74
|
+
const token = crypto.randomUUID();
|
|
75
|
+
// Hold a mutable options reference so we can fill in `httpUrl` AFTER
|
|
76
|
+
// `Bun.serve` binds — the per-request `/mcp` handler captures
|
|
77
|
+
// `options` by closure, so the canvas-bearing tools (US-008) read
|
|
78
|
+
// the live URL when building their `_meta.backendUrl`. Without this
|
|
79
|
+
// back-fill the closure would observe `httpUrl: undefined` and skip
|
|
80
|
+
// `_meta` entirely.
|
|
81
|
+
const appOptions: CreateAppOptions = { token };
|
|
82
|
+
const app = createApp(appOptions);
|
|
83
|
+
embeddedServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: app.fetch });
|
|
84
|
+
embeddedUrl = `http://${embeddedServer.hostname}:${embeddedServer.port}`;
|
|
85
|
+
appOptions.httpUrl = embeddedUrl;
|
|
86
|
+
// Log the bound URL to stderr so the integration test (and humans
|
|
87
|
+
// debugging) can discover the port without interleaving with the
|
|
88
|
+
// JSON-RPC stdout stream. Intentionally omits the token — it MUST
|
|
89
|
+
// stay in-memory only; printing it to stderr would leak it into any
|
|
90
|
+
// log scrape that captures the subprocess's stderr.
|
|
91
|
+
process.stderr.write(`[seeflow-mcp] studio listening on ${embeddedUrl}\n`);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
94
|
+
emitMcpNotification(
|
|
95
|
+
'error',
|
|
96
|
+
`Failed to bind ephemeral HTTP port for studio: ${msg}. Falling back to ${DEFAULT_URL} (canvas widgets will not render).`,
|
|
97
|
+
);
|
|
98
|
+
embeddedServer = undefined;
|
|
99
|
+
embeddedUrl = undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const targetUrl = explicitStudioUrl ?? (embeddedUrl ? `${embeddedUrl}/mcp` : DEFAULT_URL);
|
|
104
|
+
const url = new URL(targetUrl);
|
|
24
105
|
|
|
25
106
|
const stdio = new StdioServerTransport();
|
|
26
107
|
const http = new StreamableHTTPClientTransport(url);
|
|
@@ -81,13 +162,39 @@ stdio.onmessage = async (msg) => {
|
|
|
81
162
|
}
|
|
82
163
|
};
|
|
83
164
|
|
|
165
|
+
// Tear-down: release the ephemeral port on every exit path so the loopback
|
|
166
|
+
// slot doesn't stay claimed after the parent host kills the subprocess.
|
|
167
|
+
// Bun.Server.stop(true) is synchronous + idempotent — safe to call from
|
|
168
|
+
// multiple lifecycle hooks.
|
|
169
|
+
let serverStopped = false;
|
|
170
|
+
const stopEmbeddedServer = (): void => {
|
|
171
|
+
if (serverStopped || !embeddedServer) return;
|
|
172
|
+
serverStopped = true;
|
|
173
|
+
try {
|
|
174
|
+
embeddedServer.stop(true);
|
|
175
|
+
} catch {
|
|
176
|
+
/* already stopped */
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
84
180
|
await http.start();
|
|
85
181
|
await stdio.start();
|
|
86
182
|
|
|
183
|
+
// The stdio transport sets `onclose` to undefined by default; chain our
|
|
184
|
+
// teardown so a parent killing the pipe still frees the port.
|
|
185
|
+
const stdioOnClose = stdio.onclose;
|
|
186
|
+
stdio.onclose = () => {
|
|
187
|
+
stopEmbeddedServer();
|
|
188
|
+
stdioOnClose?.();
|
|
189
|
+
};
|
|
190
|
+
|
|
87
191
|
const shutdown = async () => {
|
|
88
192
|
await stdio.close().catch(() => undefined);
|
|
89
193
|
await http.close().catch(() => undefined);
|
|
194
|
+
stopEmbeddedServer();
|
|
90
195
|
process.exit(0);
|
|
91
196
|
};
|
|
197
|
+
|
|
92
198
|
process.on('SIGINT', () => void shutdown());
|
|
93
199
|
process.on('SIGTERM', () => void shutdown());
|
|
200
|
+
process.on('beforeExit', () => stopEmbeddedServer());
|
package/src/mcp-ui.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// MCP Apps UI resource glue for SeeFlow.
|
|
2
|
+
//
|
|
3
|
+
// The MCP-Apps host (Claude Desktop, ChatGPT) renders an iframe whose HTML
|
|
4
|
+
// payload is served via the MCP `resources/read` channel under a stable
|
|
5
|
+
// `ui://` URI. Canvas-bearing tool handlers attach an `_meta` block to their
|
|
6
|
+
// CallToolResult pointing the host at that URI plus an initial widget state.
|
|
7
|
+
//
|
|
8
|
+
// This module centralises three concerns so the wiring in mcp.ts stays thin:
|
|
9
|
+
// 1. The single canonical resource URI for the canvas bundle.
|
|
10
|
+
// 2. A cached reader for the built `apps/mcp-app/dist/index.html` (the
|
|
11
|
+
// single-file Vite output produced by US-001..US-004).
|
|
12
|
+
// 3. `canvasMeta(state)` — the `_meta` payload the host expects for a
|
|
13
|
+
// canvas-bearing tool result.
|
|
14
|
+
//
|
|
15
|
+
// The actual `_meta` attachment on each of the 5 canvas-bearing tools lives
|
|
16
|
+
// in US-008. Non-canvas tools never see this module.
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Canonical resource URI for the SeeFlow canvas iframe bundle. Hosts that
|
|
23
|
+
* speak MCP Apps fetch this via `resources/read` to obtain the HTML payload
|
|
24
|
+
* for the embedded canvas. Tool results reference it via
|
|
25
|
+
* `_meta['openai/outputTemplate']`.
|
|
26
|
+
*/
|
|
27
|
+
export const CANVAS_RESOURCE_URI = 'ui://seeflow/canvas';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* MIME type the MCP-Apps host expects for the canvas bundle. The
|
|
31
|
+
* `+skybridge` suffix opts the iframe into the host's structured message
|
|
32
|
+
* channel (`window.openai.sendMessage` / `updateModelContext`).
|
|
33
|
+
*/
|
|
34
|
+
export const CANVAS_RESOURCE_MIME = 'text/html+skybridge';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Widget state delivered to the iframe via `_meta['openai/widgetState']`.
|
|
38
|
+
*
|
|
39
|
+
* Mirrors `WidgetState` in `apps/mcp-app/src/bridge.ts` — kept in sync but
|
|
40
|
+
* duplicated (rather than imported across the workspace boundary) so the
|
|
41
|
+
* studio doesn't pick up `react`/`react-dom` as a transitive dependency.
|
|
42
|
+
* When you change one, change the other in the same commit.
|
|
43
|
+
*
|
|
44
|
+
* - `kind: 'navigate'` — the model just inspected a flow/node, render it.
|
|
45
|
+
* `projectSlug` + `flowSlug` are both required so the iframe addresses
|
|
46
|
+
* the flow via `/api/projects/:project/flows/:flow` without an extra
|
|
47
|
+
* lookup round-trip.
|
|
48
|
+
* - `kind: 'create'` — the model just scaffolded a project or registered
|
|
49
|
+
* a flow. Either or both slugs may be present (project-only when there's
|
|
50
|
+
* no flow yet); `justCreated: true` enables the brief mount-time pill.
|
|
51
|
+
*/
|
|
52
|
+
export type CanvasWidgetState =
|
|
53
|
+
| {
|
|
54
|
+
kind: 'navigate';
|
|
55
|
+
projectSlug: string;
|
|
56
|
+
flowSlug: string;
|
|
57
|
+
nodeId?: string;
|
|
58
|
+
backendUrl: string;
|
|
59
|
+
backendToken: string;
|
|
60
|
+
}
|
|
61
|
+
| {
|
|
62
|
+
kind: 'create';
|
|
63
|
+
projectSlug?: string;
|
|
64
|
+
flowSlug?: string;
|
|
65
|
+
nodeId?: string;
|
|
66
|
+
backendUrl: string;
|
|
67
|
+
backendToken: string;
|
|
68
|
+
justCreated?: boolean;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Lazy-resolved absolute path to the built iframe HTML. Resolved through
|
|
72
|
+
* `import.meta.resolve` so the path is stable whether the studio runs from
|
|
73
|
+
* the source tree (dev) or from the published `@tuongaz/seeflow` package
|
|
74
|
+
* (where `apps/mcp-app/dist/index.html` is bundled into the tarball). */
|
|
75
|
+
const resolveCanvasHtmlPath = (): string =>
|
|
76
|
+
fileURLToPath(import.meta.resolve('../../mcp-app/dist/index.html'));
|
|
77
|
+
|
|
78
|
+
let cachedHtml: string | undefined;
|
|
79
|
+
let cachedPath: string | undefined;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read the built MCP App iframe bundle. Cached after the first successful
|
|
83
|
+
* read — the bundle is immutable for the lifetime of the studio process
|
|
84
|
+
* (the only way to refresh it is to rebuild and restart).
|
|
85
|
+
*
|
|
86
|
+
* Throws a clear error pointing at the build step when the dist file is
|
|
87
|
+
* missing, so a user who forgot `bun run --filter @seeflow/mcp-app build`
|
|
88
|
+
* gets a one-line fix instead of an ENOENT trace.
|
|
89
|
+
*/
|
|
90
|
+
export function readCanvasHtml(): string {
|
|
91
|
+
if (cachedHtml !== undefined) return cachedHtml;
|
|
92
|
+
if (cachedPath === undefined) cachedPath = resolveCanvasHtmlPath();
|
|
93
|
+
if (!existsSync(cachedPath)) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`MCP App bundle not found at ${cachedPath}. Run 'bun run --filter @seeflow/mcp-app build' to produce it.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
cachedHtml = readFileSync(cachedPath, 'utf8');
|
|
99
|
+
return cachedHtml;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Test seam: drop the cached HTML so the next `readCanvasHtml()` call
|
|
103
|
+
* re-reads the file from disk. Exported for unit tests that mock the
|
|
104
|
+
* filesystem; never called in production code. */
|
|
105
|
+
export function __resetCanvasHtmlCache(): void {
|
|
106
|
+
cachedHtml = undefined;
|
|
107
|
+
cachedPath = undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build the `_meta` payload for a canvas-bearing tool result. The three
|
|
112
|
+
* keys are the contract the MCP-Apps host introspects:
|
|
113
|
+
*
|
|
114
|
+
* - `openai/outputTemplate` → which UI resource to render.
|
|
115
|
+
* - `openai/widgetState` → initial state injected as
|
|
116
|
+
* `window.openai.widgetState` in the iframe.
|
|
117
|
+
* - `openai/widgetAccessible` → declares the widget reads the model
|
|
118
|
+
* context (so the host wires the bridge).
|
|
119
|
+
*/
|
|
120
|
+
export function canvasMeta(state: CanvasWidgetState): Record<string, unknown> {
|
|
121
|
+
return {
|
|
122
|
+
'openai/outputTemplate': CANVAS_RESOURCE_URI,
|
|
123
|
+
'openai/widgetState': state,
|
|
124
|
+
'openai/widgetAccessible': true,
|
|
125
|
+
};
|
|
126
|
+
}
|