@nimblebrain/synapse 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -37
- package/dist/check-JEM5PN64.cjs +435 -0
- package/dist/check-JEM5PN64.cjs.map +1 -0
- package/dist/check-YDZUQT6A.js +429 -0
- package/dist/check-YDZUQT6A.js.map +1 -0
- package/dist/{chunk-VDQZQDU3.cjs → chunk-BNF352SA.cjs} +56 -145
- package/dist/chunk-BNF352SA.cjs.map +1 -0
- package/dist/{chunk-HKRTDGXN.js → chunk-DJS2FKBN.js} +21 -89
- package/dist/chunk-DJS2FKBN.js.map +1 -0
- package/dist/chunk-H3INUKA3.js +32 -0
- package/dist/chunk-H3INUKA3.js.map +1 -0
- package/dist/{chunk-FB7GPBEM.cjs → chunk-HQLSVUOR.cjs} +193 -314
- package/dist/chunk-HQLSVUOR.cjs.map +1 -0
- package/dist/chunk-ISV6HFQE.cjs +40 -0
- package/dist/chunk-ISV6HFQE.cjs.map +1 -0
- package/dist/{chunk-3YSXPBEQ.js → chunk-TR2YNWZ5.js} +190 -313
- package/dist/chunk-TR2YNWZ5.js.map +1 -0
- package/dist/{chunk-ZLASWV4N.js → chunk-UKP4W6DI.js} +56 -145
- package/dist/chunk-UKP4W6DI.js.map +1 -0
- package/dist/{chunk-LPWEQCZV.cjs → chunk-YSL7KJNU.cjs} +21 -92
- package/dist/chunk-YSL7KJNU.cjs.map +1 -0
- package/dist/codegen/cli.cjs +38 -1
- package/dist/codegen/cli.cjs.map +1 -1
- package/dist/codegen/cli.js +38 -1
- package/dist/codegen/cli.js.map +1 -1
- package/dist/codegen/index.d.cts +1 -1
- package/dist/codegen/index.d.ts +1 -1
- package/dist/connect.iife.global.js +66 -43
- package/dist/{detect-BHYg26_d.d.cts → detect-DVGaL2bH.d.cts} +39 -33
- package/dist/{detect-Bf8Q_0dK.d.ts → detect-DVGaL2bH.d.ts} +39 -33
- package/dist/host/index.cjs +17 -17
- package/dist/host/index.d.cts +11 -27
- package/dist/host/index.d.ts +11 -27
- package/dist/host/index.js +2 -2
- package/dist/index.cjs +52 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +104 -31
- package/dist/index.d.ts +104 -31
- package/dist/index.js +34 -3
- package/dist/index.js.map +1 -1
- package/dist/react/index.cjs +11 -10
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +49 -12
- package/dist/react/index.d.ts +49 -12
- package/dist/react/index.js +6 -5
- package/dist/react/index.js.map +1 -1
- package/dist/{server-QIYIJHD5.cjs → server-BR5MNFOB.cjs} +40 -29
- package/dist/server-BR5MNFOB.cjs.map +1 -0
- package/dist/{server-5N76YCWC.js → server-OOEEWAEG.js} +40 -30
- package/dist/server-OOEEWAEG.js.map +1 -0
- package/dist/synapse-runtime.iife.global.js +66 -43
- package/dist/synapse-ui.iife.global.js +4 -4
- package/dist/{types-uEO4VFJ2.d.cts → types-BxPfGHKO.d.cts} +48 -125
- package/dist/{types-uEO4VFJ2.d.ts → types-BxPfGHKO.d.ts} +48 -125
- package/dist/vite/index.cjs +77 -33
- package/dist/vite/index.cjs.map +1 -1
- package/dist/vite/index.d.cts +5 -1
- package/dist/vite/index.d.ts +5 -1
- package/dist/vite/index.js +77 -33
- package/dist/vite/index.js.map +1 -1
- package/package.json +12 -6
- package/dist/chunk-3YSXPBEQ.js.map +0 -1
- package/dist/chunk-FB7GPBEM.cjs.map +0 -1
- package/dist/chunk-HKRTDGXN.js.map +0 -1
- package/dist/chunk-LPWEQCZV.cjs.map +0 -1
- package/dist/chunk-VDQZQDU3.cjs.map +0 -1
- package/dist/chunk-ZLASWV4N.js.map +0 -1
- package/dist/server-5N76YCWC.js.map +0 -1
- package/dist/server-QIYIJHD5.cjs.map +0 -1
package/README.md
CHANGED
|
@@ -15,13 +15,13 @@ Synapse is an optional enhancement layer over `@modelcontextprotocol/ext-apps`.
|
|
|
15
15
|
|
|
16
16
|
- **Zero-config handshake** — `await connect()` resolves when the host is ready. You never see `ui/initialize`.
|
|
17
17
|
- **Typed tool calls** — call MCP tools with full TypeScript input/output types
|
|
18
|
-
- **Reactive data sync** —
|
|
18
|
+
- **Reactive data sync** — refresh when your server announces its data changed (`notifications/resources/list_changed`)
|
|
19
19
|
- **Theme tracking** — automatic light/dark mode and custom design tokens
|
|
20
|
-
- **
|
|
20
|
+
- **Agent context** — push what the user is looking at to the model with `useModelContext`
|
|
21
21
|
- **Keyboard forwarding** — forward shortcuts from sandboxed iframes to the host
|
|
22
22
|
- **Code generation** — generate TypeScript types from manifests, running servers, or JSON schemas
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Every call checks what the host declared in `ui/initialize`. Where a capability is missing, the call does a documented thing: it sends nothing, it keeps its initial value, or it rejects with `HostCapabilityError` without sending. See [the portable-app contract](https://synapse.nimblebrain.ai/docs/concepts/degradation/).
|
|
25
25
|
|
|
26
26
|
## Why Synapse?
|
|
27
27
|
|
|
@@ -35,7 +35,7 @@ Synapse handles the plumbing so you can focus on the UI. See **[Why Synapse?](do
|
|
|
35
35
|
npm install @nimblebrain/synapse
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
**Peer
|
|
38
|
+
**Peer dependencies:** `@modelcontextprotocol/ext-apps@^1.7.5`, `@modelcontextprotocol/sdk@^1.29.0`
|
|
39
39
|
|
|
40
40
|
**Building a Python MCP server?** The server half — one self-contained `ui://`
|
|
41
41
|
component rendered across ChatGPT, Claude, and NimbleBrain — ships as the
|
|
@@ -72,7 +72,7 @@ These are the durable decisions behind the library; they rarely change.
|
|
|
72
72
|
- **That includes typography — the SDK ships no fonts.** Font fallbacks are
|
|
73
73
|
web-safe system stacks, so an app renders correctly with no host, no network,
|
|
74
74
|
and no font files. A host that wants its own typeface sends `@font-face`
|
|
75
|
-
|
|
75
|
+
CSS in the host context (`styles.css.fonts`); the library fetches nothing on its own. See
|
|
76
76
|
[Host fonts](#host-fonts).
|
|
77
77
|
- **Theme via CSS, not React.** Components style with token-driven inline-style
|
|
78
78
|
objects whose values are those `var()` refs, so theming — including light/dark —
|
|
@@ -98,32 +98,26 @@ These are the durable decisions behind the library; they rarely change.
|
|
|
98
98
|
A CSS custom property can *name* a font family but cannot *load* one, and an app
|
|
99
99
|
iframe is its own document — it inherits no `@font-face` from the host page. So a
|
|
100
100
|
host that sends only tokens is naming a typeface the app has no way to render.
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
The MCP Apps spec closes that gap with `styles.css.fonts`: the host sends
|
|
102
|
+
`@font-face` CSS in the host context, and the SDK loads it into the app document.
|
|
103
103
|
|
|
104
104
|
```ts
|
|
105
|
-
// Host side —
|
|
105
|
+
// Host side — the hostContext of ui/initialize.
|
|
106
106
|
{
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
107
|
+
theme: "dark",
|
|
108
|
+
styles: {
|
|
109
|
+
variables: { "--font-sans": "'Your Sans', system-ui, sans-serif" },
|
|
110
|
+
css: {
|
|
111
|
+
fonts: "@font-face { font-family: 'Your Sans'; src: url('/fonts/your-sans.woff2') format('woff2'); font-weight: 400 700; font-display: swap; }",
|
|
112
|
+
},
|
|
113
|
+
},
|
|
112
114
|
}
|
|
113
115
|
```
|
|
114
116
|
|
|
115
|
-
| Field | Notes |
|
|
116
|
-
|---|---|
|
|
117
|
-
| `family` | Must match the family named in the host's `--font-*` token value. |
|
|
118
|
-
| `src` | Any CSS `src` descriptor — relative (`url('/fonts/x.woff2')`), absolute (`url('https://cdn.example/x.woff2')`), or `data:`. |
|
|
119
|
-
| `weight` | Single weight (`400`) or a variable range (`400 700`). Optional. |
|
|
120
|
-
| `style` | `normal`, `italic`, … Optional. |
|
|
121
|
-
| `display` | Defaults to `swap`, so text paints in the fallback rather than blocking. Optional. |
|
|
122
|
-
|
|
123
117
|
Three things worth knowing:
|
|
124
118
|
|
|
125
119
|
- **Sending no fonts is a supported configuration, not a degraded one.** Omit
|
|
126
|
-
`
|
|
120
|
+
`styles.css.fonts` and the app renders in the web-safe fallbacks (`system-ui` for
|
|
127
121
|
both body and headings, `ui-monospace` for code).
|
|
128
122
|
- **Give every `--font-*` token value a web-safe tail.** A bare family name with
|
|
129
123
|
no matching face falls through to the browser default, not to your intended
|
|
@@ -274,8 +268,9 @@ app.on("teardown", () => {
|
|
|
274
268
|
saveState();
|
|
275
269
|
});
|
|
276
270
|
|
|
277
|
-
//
|
|
278
|
-
|
|
271
|
+
// Any wire method works as a passthrough event name — here, your server
|
|
272
|
+
// announcing that its data changed (which is what useDataSync listens for)
|
|
273
|
+
app.on("notifications/resources/list_changed", (params) => {
|
|
279
274
|
refreshData();
|
|
280
275
|
});
|
|
281
276
|
|
|
@@ -291,8 +286,6 @@ unsub();
|
|
|
291
286
|
| `"tool-cancelled"` | `ui/notifications/tool-cancelled` | — |
|
|
292
287
|
| `"theme-changed"` | `ui/notifications/host-context-changed` | `Theme` — fires only when the theme actually moves |
|
|
293
288
|
| `"host-context-changed"` | `ui/notifications/host-context-changed` | `McpUiHostContext` — every change, unfiltered |
|
|
294
|
-
| `"data-changed"` | `synapse/data-changed` | `DataChangedEvent` |
|
|
295
|
-
| `"action"` | `synapse/action` | `AgentAction` |
|
|
296
289
|
| `"teardown"` | `ui/resource-teardown` | — |
|
|
297
290
|
| Any custom string | Passed through as-is | `unknown` |
|
|
298
291
|
|
|
@@ -313,7 +306,6 @@ const app = await connect({ name: "my-app", version: "1.0.0" });
|
|
|
313
306
|
| `name` | `string` | App name (must match registered bundle name) |
|
|
314
307
|
| `version` | `string` | Semver version |
|
|
315
308
|
| `autoResize` | `boolean?` | Observe `document.body` and auto-send `size-changed`. Default: `false` |
|
|
316
|
-
| `internal` | `boolean?` | NimbleBrain internal app — `callTool` carries a `server` param for cross-server routing |
|
|
317
309
|
| `forwardKeys` | `boolean \| KeyForwardConfig[]?` | Forward keyboard shortcuts to the host. Only a NimbleBrain host consumes them, so it stays off elsewhere. |
|
|
318
310
|
| `on` | `Record<string, (data) => void>?` | Pre-register handlers before the handshake, so no early message is lost |
|
|
319
311
|
|
|
@@ -338,7 +330,7 @@ const app = await connect({ name: "my-app", version: "1.0.0" });
|
|
|
338
330
|
| `resize(width?, height?)` | Send size to host. Auto-measures `document.body` if no args. |
|
|
339
331
|
| `openLink(url)` | Open a URL (host-aware) |
|
|
340
332
|
| `updateModelContext(state, summary?)` | Push LLM-visible state |
|
|
341
|
-
| `callTool(name, args
|
|
333
|
+
| `callTool(name, args?)` | Call a tool on this app's own MCP server and get a typed result |
|
|
342
334
|
| `readServerResource({ uri })` | Read an MCP resource from the originating server |
|
|
343
335
|
| `sendMessage(text, context?)` | Send a message into the agent conversation |
|
|
344
336
|
| `destroy()` | Clean up all listeners, observers, and timers |
|
|
@@ -360,16 +352,22 @@ action(app, "navigate", { entity: "board", id: "b1" });
|
|
|
360
352
|
| Function | Description |
|
|
361
353
|
|----------|-------------|
|
|
362
354
|
| `callToolAsTask(app, name, args?, opts?)` | Call a long-running tool task-augmented; returns a `Promise<TaskHandle>`. See [Long-running tools](#long-running-tools-tasks). |
|
|
363
|
-
| `action(app, name, params?)` | Trigger a NimbleBrain host action. No-op
|
|
364
|
-
| `pickFile(app, options?)` | Native file picker, single file.
|
|
365
|
-
| `pickFiles(app, options?)` | Native file picker, multiple files.
|
|
366
|
-
| `
|
|
355
|
+
| `action(app, name, params?)` | Trigger a NimbleBrain host action. No-op unless the host declares `ai.nimblebrain/action`. |
|
|
356
|
+
| `pickFile(app, options?)` | Native file picker, single file. Rejects with `HostCapabilityError` unless the host declares `ai.nimblebrain/request-file`. |
|
|
357
|
+
| `pickFiles(app, options?)` | Native file picker, multiple files. Rejects with `HostCapabilityError` unless the host declares `ai.nimblebrain/request-file`. |
|
|
358
|
+
| `hostSupports(app, extension)` | Whether the host declared a NimbleBrain extension (`"action"`, `"requestFile"`, `"keydown"`). |
|
|
359
|
+
| `downloadFile(app, name, content, mime?)` | Hand the user a file to save, over the spec's `ui/download-file`. Resolves with the host's result — `{ isError: true }` when the host declined or the user cancelled — and rejects, without sending, when the host did not advertise the `downloadFile` capability. |
|
|
367
360
|
|
|
368
361
|
`app.supportsTasks` says whether the host negotiated the tasks utility for
|
|
369
362
|
`tools/call`. `callToolAsTask` throws when it is false, so read it to decide
|
|
370
363
|
whether to offer a long-running action at all rather than to discover the answer
|
|
371
364
|
from an exception.
|
|
372
365
|
|
|
366
|
+
A host advertises the capability in
|
|
367
|
+
`hostCapabilities.experimental["io.modelcontextprotocol/tasks"]`, the MCP Tasks
|
|
368
|
+
extension identifier, and nowhere else. A top-level `hostCapabilities.tasks` is
|
|
369
|
+
not read, because a spec client's handshake parse strips it.
|
|
370
|
+
|
|
373
371
|
## React Hooks
|
|
374
372
|
|
|
375
373
|
Wrap your tree in `<AppProvider>` — it takes the same options as `connect()` and
|
|
@@ -390,16 +388,13 @@ import { AppProvider, useApp, useCallTool, useTheme } from "@nimblebrain/synapse
|
|
|
390
388
|
| `useResize()` | `(w?, h?) => void` | Resize helper — auto-measures body if no args |
|
|
391
389
|
| `useCallTool(name)` | `{ call, data, isPending, error }` | Call a tool with loading/error state |
|
|
392
390
|
| `useCallToolAsTask(name)` | `{ fire, task, result, error, isWorking, isTerminal, cancel }` | The full task lifecycle for a long-running tool. See below. |
|
|
393
|
-
| `useDataSync(cb)` | — | Run `cb` when
|
|
391
|
+
| `useDataSync(cb)` | — | Run `cb` when your server announces its data changed (`notifications/resources/list_changed`); `cb` gets the notification's params |
|
|
394
392
|
| `useModelContext()` | `(state, summary?) => void` | Push LLM-visible state, debounced 250ms |
|
|
395
393
|
| `useModelContext(factory, deps)` | — | The same, pushed whenever `deps` change |
|
|
396
394
|
| `useSendMessage()` | `(text, context?) => void` | Send a message into the agent conversation |
|
|
397
395
|
| `useAction()` | `(name, params?) => void` | Trigger a NimbleBrain host action |
|
|
398
396
|
| `useFileUpload()` | `{ pickFile, pickFiles, isPending }` | The host's native file picker (NB-only) |
|
|
399
397
|
|
|
400
|
-
To *receive* the actions a tool emits, subscribe through the app:
|
|
401
|
-
`useApp().on("action", cb)`.
|
|
402
|
-
|
|
403
398
|
## Long-running tools (tasks)
|
|
404
399
|
|
|
405
400
|
For tools whose work exceeds the stock MCP request timeout (~60s) — research runs, batch imports, multi-stage analyses — use `callToolAsTask` (or `useCallToolAsTask` in React) instead of `callTool`. The host returns a `CreateTaskResult` immediately; the actual `CallToolResult` is fetched via `tasks/result` when the task reaches a terminal state.
|
|
@@ -433,7 +428,7 @@ async def start_research(query: str, ctx: Context) -> dict:
|
|
|
433
428
|
|
|
434
429
|
`TasksExtension` is what serves the task methods; a task-enabled tool with no extension registered aborts the server at startup, before it binds. `mode="optional"` lets the same tool run inline (`callTool`) or as a task (`callToolAsTask`) — the client decides. `mode="required"` rejects a call from a client that has not negotiated the extension with JSON-RPC `-32021` (`MISSING_REQUIRED_CLIENT_CAPABILITY`), whose `data.requiredCapabilities` names the extension the client is missing.
|
|
435
430
|
|
|
436
|
-
**Dual-channel pattern.** When a task creates a domain entity (a research run, an import job), the entity
|
|
431
|
+
**Dual-channel pattern.** When a task creates a domain entity (a research run, an import job), the UI learns the entity exists from the server, **not** from the task result: the server announces the write with `notifications/resources/list_changed`, and `useDataSync` re-reads the list the entity now appears in. The task channel signals "started / running / done / cancelled"; the server's data carries the durable record. UIs that need to navigate to the new entity should re-read on `useDataSync` rather than awaiting `result()`.
|
|
437
432
|
|
|
438
433
|
**Capability detection.** Hosts that don't support tasks won't advertise the `tasks.requests.tools.call` capability. `callToolAsTask` throws on hosts without the capability — wrap in a try/catch and fall back to `callTool` if you want graceful degradation:
|
|
439
434
|
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var server = require('@modelcontextprotocol/ext-apps/server');
|
|
4
|
+
var auth_js = require('@modelcontextprotocol/sdk/client/auth.js');
|
|
5
|
+
var index_js = require('@modelcontextprotocol/sdk/client/index.js');
|
|
6
|
+
var streamableHttp_js = require('@modelcontextprotocol/sdk/client/streamableHttp.js');
|
|
7
|
+
var types_js = require('@modelcontextprotocol/sdk/types.js');
|
|
8
|
+
|
|
9
|
+
// src/check/index.ts
|
|
10
|
+
|
|
11
|
+
// src/check/profiles.ts
|
|
12
|
+
var SPEC_MIME = "UI resources are served as text/html;profile=mcp-app.";
|
|
13
|
+
var RESOLVES = "A tool's ui.resourceUri must name a ui:// resource the server serves.";
|
|
14
|
+
var UI_META = "ui.visibility is a non-empty subset of model/app; csp and permissions belong on the resource, not the tool.";
|
|
15
|
+
var EXTENSION = "A client uses MCP Apps only when both sides declare io.modelcontextprotocol/ui.";
|
|
16
|
+
var CHALLENGE = "Sign-in starts from a 401 whose WWW-Authenticate carries resource_metadata.";
|
|
17
|
+
var PRM_RESOURCE = "The protected-resource metadata's resource must equal the connector URL exactly.";
|
|
18
|
+
var ISSUER = "The authorization server's metadata issuer must equal the advertised authorization server exactly (RFC 8414).";
|
|
19
|
+
var PROFILES = {
|
|
20
|
+
nimblebrain: {
|
|
21
|
+
name: "nimblebrain",
|
|
22
|
+
description: "The NimbleBrain host.",
|
|
23
|
+
rules: {
|
|
24
|
+
"ui-resource-mime": { severity: "error", why: SPEC_MIME },
|
|
25
|
+
"tool-resource-uri": { severity: "error", why: RESOLVES },
|
|
26
|
+
"tool-ui-meta": { severity: "error", why: UI_META },
|
|
27
|
+
// The host resolves an app by its ui:// scheme and does not gate on the
|
|
28
|
+
// declaration, so a missing one is a portability risk, not a break.
|
|
29
|
+
"extension-declared": { severity: "warn", why: EXTENSION },
|
|
30
|
+
// Only reported when the server challenges at all.
|
|
31
|
+
"auth-challenge": { severity: "warn", why: CHALLENGE },
|
|
32
|
+
"auth-resource-metadata": { severity: "warn", why: PRM_RESOURCE },
|
|
33
|
+
"auth-issuer": { severity: "warn", why: ISSUER }
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
claude: {
|
|
37
|
+
name: "claude",
|
|
38
|
+
description: "claude.ai and Claude Desktop.",
|
|
39
|
+
rules: {
|
|
40
|
+
"ui-resource-mime": { severity: "error", why: SPEC_MIME },
|
|
41
|
+
"tool-resource-uri": { severity: "error", why: RESOLVES },
|
|
42
|
+
"tool-ui-meta": { severity: "error", why: UI_META },
|
|
43
|
+
"extension-declared": { severity: "error", why: EXTENSION },
|
|
44
|
+
"resource-csp": {
|
|
45
|
+
severity: "warn",
|
|
46
|
+
why: "Claude builds the frame's security policy from ui.csp; an undeclared origin is blocked."
|
|
47
|
+
},
|
|
48
|
+
"resource-data-fonts": {
|
|
49
|
+
severity: "error",
|
|
50
|
+
why: "Claude's default security policy blocks data: fonts unless the resource declares them."
|
|
51
|
+
},
|
|
52
|
+
"auth-challenge": { severity: "error", why: CHALLENGE },
|
|
53
|
+
"auth-resource-metadata": { severity: "error", why: PRM_RESOURCE },
|
|
54
|
+
// Claude has been observed to accept an issuer that differs by a trailing
|
|
55
|
+
// slash, so this is a correctness warning for Claude rather than a break.
|
|
56
|
+
"auth-issuer": { severity: "warn", why: ISSUER }
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
chatgpt: {
|
|
60
|
+
name: "chatgpt",
|
|
61
|
+
description: "ChatGPT apps.",
|
|
62
|
+
rules: {
|
|
63
|
+
"ui-resource-mime": { severity: "error", why: SPEC_MIME },
|
|
64
|
+
"tool-resource-uri": { severity: "error", why: RESOLVES },
|
|
65
|
+
"tool-ui-meta": { severity: "error", why: UI_META },
|
|
66
|
+
"extension-declared": { severity: "error", why: EXTENSION },
|
|
67
|
+
"resource-csp": {
|
|
68
|
+
severity: "error",
|
|
69
|
+
why: "ChatGPT reads the frame's security policy from ui.csp, and app submission requires it."
|
|
70
|
+
},
|
|
71
|
+
"resource-data-fonts": {
|
|
72
|
+
severity: "warn",
|
|
73
|
+
why: "A data: font is blocked by a security policy that does not declare it."
|
|
74
|
+
},
|
|
75
|
+
"auth-challenge": { severity: "error", why: CHALLENGE },
|
|
76
|
+
"auth-resource-metadata": { severity: "error", why: PRM_RESOURCE },
|
|
77
|
+
"auth-issuer": { severity: "error", why: ISSUER },
|
|
78
|
+
"tool-security-schemes": {
|
|
79
|
+
severity: "error",
|
|
80
|
+
why: "ChatGPT prompts for sign-in mid-conversation only for tools that declare securitySchemes."
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
function isTargetName(value) {
|
|
86
|
+
return Object.hasOwn(PROFILES, value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/check/index.ts
|
|
90
|
+
var UI_SCHEME = "ui://";
|
|
91
|
+
var VISIBILITY = /* @__PURE__ */ new Set(["model", "app"]);
|
|
92
|
+
async function runChecks(url, options = {}) {
|
|
93
|
+
const results = [];
|
|
94
|
+
const add = (id, status, detail) => results.push({ id, status, detail });
|
|
95
|
+
let challenge;
|
|
96
|
+
let session;
|
|
97
|
+
let connectError;
|
|
98
|
+
try {
|
|
99
|
+
session = await openSession(url, void 0, (res) => {
|
|
100
|
+
if (res.status === 401) challenge ??= res;
|
|
101
|
+
});
|
|
102
|
+
} catch (err) {
|
|
103
|
+
connectError = err;
|
|
104
|
+
}
|
|
105
|
+
const authRequired = challenge !== void 0;
|
|
106
|
+
if (authRequired && options.token) {
|
|
107
|
+
try {
|
|
108
|
+
session = await openSession(url, options.token);
|
|
109
|
+
connectError = void 0;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
connectError = err;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!session && (!authRequired || options.token)) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`could not connect to ${url}${authRequired ? " with the given token" : ""}: ${message(connectError)}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
await checkAuth(url, challenge, add);
|
|
120
|
+
if (!session) {
|
|
121
|
+
for (const id of SESSION_CHECKS) {
|
|
122
|
+
add(id, "skip", "the server requires auth; pass --token to run it");
|
|
123
|
+
}
|
|
124
|
+
return results;
|
|
125
|
+
}
|
|
126
|
+
checkSession(session, authRequired, add);
|
|
127
|
+
return results;
|
|
128
|
+
}
|
|
129
|
+
function applyProfiles(results, targets) {
|
|
130
|
+
return targets.map((target) => {
|
|
131
|
+
const rules = PROFILES[target].rules;
|
|
132
|
+
const scored = results.flatMap((r) => {
|
|
133
|
+
const rule = rules[r.id];
|
|
134
|
+
return rule ? [{ ...r, severity: rule.severity, why: rule.why }] : [];
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
target,
|
|
138
|
+
results: scored,
|
|
139
|
+
failed: scored.some((r) => r.status === "fail" && r.severity === "error")
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
function formatReport(url, reports) {
|
|
144
|
+
const width = Math.max(...reports.flatMap((r) => r.results.map((c) => c.id.length)), 0);
|
|
145
|
+
const lines = [`synapse check ${url}`];
|
|
146
|
+
for (const report of reports) {
|
|
147
|
+
lines.push("", `${report.target}: ${report.failed ? "FAIL" : "ok"}`);
|
|
148
|
+
for (const r of report.results) {
|
|
149
|
+
const label = r.status === "fail" ? r.severity === "error" ? "FAIL" : "WARN" : r.status.toUpperCase();
|
|
150
|
+
lines.push(` ${label.padEnd(4)} ${r.id.padEnd(width)} ${r.detail}`);
|
|
151
|
+
if (r.status === "fail") lines.push(` ${"".padEnd(width)} ${r.why}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return lines.join("\n");
|
|
155
|
+
}
|
|
156
|
+
var SESSION_CHECKS = [
|
|
157
|
+
"extension-declared",
|
|
158
|
+
"ui-resource-mime",
|
|
159
|
+
"tool-resource-uri",
|
|
160
|
+
"tool-ui-meta",
|
|
161
|
+
"resource-csp",
|
|
162
|
+
"resource-data-fonts",
|
|
163
|
+
"tool-security-schemes"
|
|
164
|
+
];
|
|
165
|
+
async function openSession(url, token, onResponse) {
|
|
166
|
+
const ui = { mimeTypes: [server.RESOURCE_MIME_TYPE] };
|
|
167
|
+
const client = new index_js.Client(
|
|
168
|
+
{ name: "synapse-check", version: "1" },
|
|
169
|
+
{ capabilities: { extensions: { [server.EXTENSION_ID]: ui } } }
|
|
170
|
+
);
|
|
171
|
+
const transport = new streamableHttp_js.StreamableHTTPClientTransport(new URL(url), {
|
|
172
|
+
requestInit: token ? { headers: { Authorization: `Bearer ${token}` } } : void 0,
|
|
173
|
+
fetch: async (input, init) => {
|
|
174
|
+
const res = await fetch(input, init);
|
|
175
|
+
onResponse?.(res);
|
|
176
|
+
return res;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
await client.connect(transport);
|
|
180
|
+
try {
|
|
181
|
+
const capabilities = client.getServerCapabilities();
|
|
182
|
+
const tools = [];
|
|
183
|
+
const resources = [];
|
|
184
|
+
let cursor;
|
|
185
|
+
if (capabilities?.tools) {
|
|
186
|
+
do {
|
|
187
|
+
const raw = await client.request(
|
|
188
|
+
{ method: "tools/list", params: cursor ? { cursor } : {} },
|
|
189
|
+
types_js.ResultSchema
|
|
190
|
+
);
|
|
191
|
+
const page = types_js.ListToolsResultSchema.parse(raw);
|
|
192
|
+
const rawTools = raw.tools ?? [];
|
|
193
|
+
page.tools.forEach((tool, i) => {
|
|
194
|
+
const schemes = rawTools[i]?.securitySchemes;
|
|
195
|
+
if (schemes !== void 0) tool.securitySchemes = schemes;
|
|
196
|
+
});
|
|
197
|
+
tools.push(...page.tools);
|
|
198
|
+
cursor = page.nextCursor;
|
|
199
|
+
} while (cursor);
|
|
200
|
+
}
|
|
201
|
+
if (capabilities?.resources) {
|
|
202
|
+
do {
|
|
203
|
+
const page = await client.listResources({ cursor });
|
|
204
|
+
resources.push(...page.resources);
|
|
205
|
+
cursor = page.nextCursor;
|
|
206
|
+
} while (cursor);
|
|
207
|
+
}
|
|
208
|
+
const uris = new Set(resources.map((r) => r.uri).filter((u) => u.startsWith(UI_SCHEME)));
|
|
209
|
+
for (const tool of tools) {
|
|
210
|
+
const uri = toolUi(tool)?.resourceUri;
|
|
211
|
+
if (typeof uri === "string") uris.add(uri);
|
|
212
|
+
}
|
|
213
|
+
const reads = /* @__PURE__ */ new Map();
|
|
214
|
+
for (const uri of uris) {
|
|
215
|
+
try {
|
|
216
|
+
reads.set(uri, (await client.readResource({ uri })).contents);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
reads.set(uri, err instanceof Error ? err : new Error(String(err)));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { capabilities, tools, resources, reads };
|
|
222
|
+
} finally {
|
|
223
|
+
await client.close();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function checkSession(s, authRequired, add) {
|
|
227
|
+
const boundTools = s.tools.filter((t) => toolUi(t)?.resourceUri !== void 0);
|
|
228
|
+
const uiResources = s.resources.filter((r) => r.uri.startsWith(UI_SCHEME));
|
|
229
|
+
const hasUi = boundTools.length > 0 || uiResources.length > 0 || s.tools.some(legacyBinding);
|
|
230
|
+
if (!hasUi) {
|
|
231
|
+
for (const id of SESSION_CHECKS) {
|
|
232
|
+
if (id !== "tool-security-schemes") add(id, "skip", "the server exposes no UI");
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
const declared = s.capabilities?.extensions?.[server.EXTENSION_ID] !== void 0;
|
|
236
|
+
add(
|
|
237
|
+
"extension-declared",
|
|
238
|
+
declared ? "pass" : "fail",
|
|
239
|
+
declared ? `${server.EXTENSION_ID} is declared` : `the server does not declare ${server.EXTENSION_ID} in capabilities.extensions`
|
|
240
|
+
);
|
|
241
|
+
const wrongMime = [];
|
|
242
|
+
for (const r of uiResources) {
|
|
243
|
+
if (r.mimeType !== void 0 && r.mimeType !== server.RESOURCE_MIME_TYPE) {
|
|
244
|
+
wrongMime.push(`${r.uri} is listed as ${r.mimeType}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
for (const [uri, contents] of s.reads) {
|
|
248
|
+
if (contents instanceof Error) continue;
|
|
249
|
+
for (const c of contents) {
|
|
250
|
+
if (c.mimeType !== server.RESOURCE_MIME_TYPE) {
|
|
251
|
+
wrongMime.push(`${uri} is served as ${c.mimeType ?? "no MIME type"}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
add(
|
|
256
|
+
"ui-resource-mime",
|
|
257
|
+
wrongMime.length ? "fail" : "pass",
|
|
258
|
+
wrongMime.length ? `${wrongMime.join("; ")}; expected ${server.RESOURCE_MIME_TYPE}` : `every ui:// resource is ${server.RESOURCE_MIME_TYPE}`
|
|
259
|
+
);
|
|
260
|
+
const unresolved = [];
|
|
261
|
+
for (const tool of s.tools) {
|
|
262
|
+
const uri = toolUi(tool)?.resourceUri;
|
|
263
|
+
if (uri === void 0) {
|
|
264
|
+
if (legacyBinding(tool)) {
|
|
265
|
+
unresolved.push(`${tool.name} binds its UI only through the deprecated flat key`);
|
|
266
|
+
}
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (typeof uri !== "string" || !uri.startsWith(UI_SCHEME)) {
|
|
270
|
+
unresolved.push(`${tool.name}: ui.resourceUri ${JSON.stringify(uri)} is not a ui:// URI`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const read = s.reads.get(uri);
|
|
274
|
+
if (read instanceof Error || !read?.length) {
|
|
275
|
+
unresolved.push(
|
|
276
|
+
`${tool.name}: ${uri} cannot be read${read instanceof Error ? ` (${read.message})` : ""}`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (boundTools.length === 0 && unresolved.length === 0) {
|
|
281
|
+
unresolved.push("no tool binds a UI through ui.resourceUri");
|
|
282
|
+
}
|
|
283
|
+
add(
|
|
284
|
+
"tool-resource-uri",
|
|
285
|
+
unresolved.length ? "fail" : "pass",
|
|
286
|
+
unresolved.length ? unresolved.join("; ") : `${boundTools.length} tool(s) bind a UI that resolves`
|
|
287
|
+
);
|
|
288
|
+
const badMeta = [];
|
|
289
|
+
for (const tool of s.tools) {
|
|
290
|
+
const ui = toolUi(tool);
|
|
291
|
+
if (!ui) continue;
|
|
292
|
+
const vis = ui.visibility;
|
|
293
|
+
if (vis !== void 0 && (!Array.isArray(vis) || vis.length === 0 || !vis.every((v) => VISIBILITY.has(v)))) {
|
|
294
|
+
badMeta.push(`${tool.name}: ui.visibility ${JSON.stringify(vis)}`);
|
|
295
|
+
}
|
|
296
|
+
for (const key of ["csp", "permissions"]) {
|
|
297
|
+
if (ui[key] !== void 0) {
|
|
298
|
+
badMeta.push(`${tool.name}: ui.${key} is on the tool, where hosts ignore it`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
add(
|
|
303
|
+
"tool-ui-meta",
|
|
304
|
+
badMeta.length ? "fail" : "pass",
|
|
305
|
+
badMeta.length ? badMeta.join("; ") : "tool ui metadata is well-formed"
|
|
306
|
+
);
|
|
307
|
+
const noCsp = [];
|
|
308
|
+
const dataFonts = [];
|
|
309
|
+
for (const [uri, contents] of s.reads) {
|
|
310
|
+
if (contents instanceof Error) continue;
|
|
311
|
+
for (const c of contents) {
|
|
312
|
+
const listed = s.resources.find((r) => r.uri === uri);
|
|
313
|
+
const ui = resourceUi(c) ?? resourceUi(listed);
|
|
314
|
+
const csp = ui?.csp;
|
|
315
|
+
if (csp === void 0) {
|
|
316
|
+
noCsp.push(`${uri} declares no ui.csp`);
|
|
317
|
+
} else if (!isCsp(csp)) {
|
|
318
|
+
noCsp.push(`${uri}: ui.csp is not a map of origin lists`);
|
|
319
|
+
}
|
|
320
|
+
const html = "text" in c ? c.text : Buffer.from(c.blob, "base64").toString("utf-8");
|
|
321
|
+
const declaresData = isCsp(csp) && (csp.resourceDomains ?? []).includes("data:");
|
|
322
|
+
if (hasDataFont(html) && !declaresData) {
|
|
323
|
+
dataFonts.push(`${uri} loads a data: font, and ui.csp.resourceDomains omits data:`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
add(
|
|
328
|
+
"resource-csp",
|
|
329
|
+
noCsp.length ? "fail" : "pass",
|
|
330
|
+
noCsp.length ? noCsp.join("; ") : "every UI resource declares ui.csp"
|
|
331
|
+
);
|
|
332
|
+
add(
|
|
333
|
+
"resource-data-fonts",
|
|
334
|
+
dataFonts.length ? "fail" : "pass",
|
|
335
|
+
dataFonts.length ? dataFonts.join("; ") : "no undeclared data: fonts"
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (!authRequired) {
|
|
339
|
+
add("tool-security-schemes", "skip", "the server does not require auth");
|
|
340
|
+
} else {
|
|
341
|
+
const missing = s.tools.filter((t) => !hasSecuritySchemes(t)).map((t) => t.name);
|
|
342
|
+
add(
|
|
343
|
+
"tool-security-schemes",
|
|
344
|
+
missing.length ? "fail" : "pass",
|
|
345
|
+
missing.length ? `no securitySchemes on: ${missing.join(", ")}` : "every tool declares securitySchemes"
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
async function checkAuth(url, challenge, add) {
|
|
350
|
+
if (!challenge) {
|
|
351
|
+
const reason = "the server answered without a 401 challenge";
|
|
352
|
+
add("auth-challenge", "skip", reason);
|
|
353
|
+
add("auth-resource-metadata", "skip", reason);
|
|
354
|
+
add("auth-issuer", "skip", reason);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const { resourceMetadataUrl } = auth_js.extractWWWAuthenticateParams(challenge);
|
|
358
|
+
add(
|
|
359
|
+
"auth-challenge",
|
|
360
|
+
resourceMetadataUrl ? "pass" : "fail",
|
|
361
|
+
resourceMetadataUrl ? `401 names resource_metadata ${resourceMetadataUrl.href}` : `401 WWW-Authenticate carries no resource_metadata (${JSON.stringify(challenge.headers.get("www-authenticate"))})`
|
|
362
|
+
);
|
|
363
|
+
let prm;
|
|
364
|
+
try {
|
|
365
|
+
prm = await auth_js.discoverOAuthProtectedResourceMetadata(url, { resourceMetadataUrl });
|
|
366
|
+
} catch (err) {
|
|
367
|
+
add(
|
|
368
|
+
"auth-resource-metadata",
|
|
369
|
+
"fail",
|
|
370
|
+
`protected-resource metadata unreadable: ${message(err)}`
|
|
371
|
+
);
|
|
372
|
+
add("auth-issuer", "skip", "no protected-resource metadata");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
add(
|
|
376
|
+
"auth-resource-metadata",
|
|
377
|
+
prm.resource === url ? "pass" : "fail",
|
|
378
|
+
prm.resource === url ? `resource equals ${url}` : `resource is ${JSON.stringify(prm.resource)}, not ${JSON.stringify(url)}`
|
|
379
|
+
);
|
|
380
|
+
const advertised = prm.authorization_servers?.[0];
|
|
381
|
+
if (!advertised) {
|
|
382
|
+
add("auth-issuer", "fail", "protected-resource metadata lists no authorization_servers");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
const metadata = await auth_js.discoverAuthorizationServerMetadata(advertised);
|
|
387
|
+
if (!metadata) {
|
|
388
|
+
add("auth-issuer", "fail", `no authorization server metadata found for ${advertised}`);
|
|
389
|
+
} else if (metadata.issuer !== advertised) {
|
|
390
|
+
add(
|
|
391
|
+
"auth-issuer",
|
|
392
|
+
"fail",
|
|
393
|
+
`issuer is ${JSON.stringify(metadata.issuer)}, advertised as ${JSON.stringify(advertised)}`
|
|
394
|
+
);
|
|
395
|
+
} else {
|
|
396
|
+
add("auth-issuer", "pass", `issuer equals ${advertised}`);
|
|
397
|
+
}
|
|
398
|
+
} catch (err) {
|
|
399
|
+
add("auth-issuer", "fail", `authorization server metadata unreadable: ${message(err)}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function toolUi(tool) {
|
|
403
|
+
const ui = tool._meta?.ui;
|
|
404
|
+
return ui && typeof ui === "object" ? ui : void 0;
|
|
405
|
+
}
|
|
406
|
+
function legacyBinding(tool) {
|
|
407
|
+
return tool._meta?.[server.RESOURCE_URI_META_KEY] !== void 0 && toolUi(tool)?.resourceUri === void 0;
|
|
408
|
+
}
|
|
409
|
+
function resourceUi(item) {
|
|
410
|
+
const ui = item?._meta?.ui;
|
|
411
|
+
return ui && typeof ui === "object" ? ui : void 0;
|
|
412
|
+
}
|
|
413
|
+
function isCsp(value) {
|
|
414
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((v) => Array.isArray(v) && v.every((o) => typeof o === "string"));
|
|
415
|
+
}
|
|
416
|
+
function hasSecuritySchemes(tool) {
|
|
417
|
+
const schemes = tool._meta?.securitySchemes ?? tool.securitySchemes;
|
|
418
|
+
return Array.isArray(schemes) && schemes.length > 0;
|
|
419
|
+
}
|
|
420
|
+
var DATA_FONT_MIME = /data:(?:font\/|application\/(?:x-)?font|application\/vnd\.ms-fontobject)/i;
|
|
421
|
+
var FONT_FACE_DATA_URL = /@font-face\s*{[^}]*url\(\s*["']?data:/i;
|
|
422
|
+
function hasDataFont(html) {
|
|
423
|
+
return DATA_FONT_MIME.test(html) || FONT_FACE_DATA_URL.test(html);
|
|
424
|
+
}
|
|
425
|
+
function message(err) {
|
|
426
|
+
return err instanceof Error ? err.message : String(err);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
exports.PROFILES = PROFILES;
|
|
430
|
+
exports.applyProfiles = applyProfiles;
|
|
431
|
+
exports.formatReport = formatReport;
|
|
432
|
+
exports.isTargetName = isTargetName;
|
|
433
|
+
exports.runChecks = runChecks;
|
|
434
|
+
//# sourceMappingURL=check-JEM5PN64.cjs.map
|
|
435
|
+
//# sourceMappingURL=check-JEM5PN64.cjs.map
|