@cyanmycelium/mcp-broker 0.4.0 → 1.2.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.
Files changed (151) hide show
  1. package/.mcp-broker.example/CONFIGURATION-EN.md +861 -0
  2. package/.mcp-broker.example/CONFIGURATION-FR.md +871 -0
  3. package/.mcp-broker.example/README.md +8 -3
  4. package/.mcp-broker.example/config.json +86 -0
  5. package/README.md +122 -3
  6. package/dist/bin.d.ts +0 -1
  7. package/dist/bin.js +180 -208
  8. package/dist/bin.js.map +1 -1
  9. package/dist/chunk-FTDKH2C4.js +3670 -0
  10. package/dist/chunk-FTDKH2C4.js.map +1 -0
  11. package/dist/{broker/grammars → grammars}/claude/en.json +1 -1
  12. package/dist/{broker/grammars → grammars}/claude/fr.json +1 -1
  13. package/dist/index.d.ts +1790 -18
  14. package/dist/index.js +2 -14
  15. package/dist/index.js.map +1 -1
  16. package/package.json +14 -8
  17. package/scripts/copy-assets.mjs +16 -10
  18. package/scripts/gen-cert.mjs +5 -5
  19. package/scripts/pack-mcpb.mjs +5 -5
  20. package/scripts/sign-bundle.mjs +6 -6
  21. package/src/auth/auth.config.ts +98 -0
  22. package/src/auth/auth.types.ts +120 -0
  23. package/src/auth/http.auth.ts +128 -0
  24. package/src/auth/index.ts +34 -0
  25. package/src/auth/jwt.validator.ts +63 -0
  26. package/src/auth/provider.auth.ts +114 -0
  27. package/src/auth/resource.metadata.ts +34 -0
  28. package/src/authorization/audit.ts +35 -0
  29. package/src/authorization/capability.classifier.ts +114 -0
  30. package/src/authorization/index.ts +37 -0
  31. package/src/authorization/policy.engine.ts +212 -0
  32. package/src/authorization/policy.types.ts +105 -0
  33. package/src/authorization/resource.path.ts +126 -0
  34. package/src/authorization/runtime.ts +56 -0
  35. package/src/authorization/slot.resource.ts +50 -0
  36. package/src/authorization/subject.mapper.ts +81 -0
  37. package/src/bin.ts +90 -7
  38. package/src/broker/adapters/broker.adapter.info.ts +3 -3
  39. package/src/broker/adapters/broker.adapter.providers.ts +3 -3
  40. package/src/broker/aggregate/aggregate.catalog.ts +49 -21
  41. package/src/broker/aggregate/aggregate.server.ts +149 -19
  42. package/src/broker/aggregate/provider.client.session.ts +22 -22
  43. package/src/broker/behaviors/broker.behavior.info.ts +4 -4
  44. package/src/broker/behaviors/broker.behavior.providers.ts +6 -6
  45. package/src/broker/broker.context.ts +10 -4
  46. package/src/broker/broker.grammars.ts +16 -13
  47. package/src/broker/broker.server.ts +12 -9
  48. package/src/broker/grammars/claude/en.json +1 -1
  49. package/src/broker/grammars/claude/fr.json +1 -1
  50. package/src/broker/index.ts +9 -9
  51. package/src/config.ts +81 -8
  52. package/src/index.ts +121 -20
  53. package/src/{mcpb.loader.ts → mcpb/mcpb.loader.ts} +24 -21
  54. package/src/{mcpb.unzip.ts → mcpb/mcpb.unzip.ts} +2 -2
  55. package/src/remote.transports.ts +11 -8
  56. package/src/remote.upstream.ts +11 -8
  57. package/src/stdio.upstream.ts +9 -6
  58. package/src/upstream.ts +6 -3
  59. package/src/ws/ws.interfaces.ts +357 -0
  60. package/src/{ws.tunnel.builder.ts → ws/ws.tunnel.builder.ts} +112 -9
  61. package/src/{ws.tunnel.ts → ws/ws.tunnel.ts} +646 -457
  62. package/web/README.md +94 -0
  63. package/web/assets/logo.png +0 -0
  64. package/web/broker-self-mcp.html +338 -0
  65. package/web/css/styles.css +580 -0
  66. package/web/demos/DemoPlaceholder.html +258 -0
  67. package/web/demos/broker-explorer/css/app.css +393 -0
  68. package/web/demos/broker-explorer/index.html +94 -0
  69. package/web/demos/broker-explorer/js/app.js +271 -0
  70. package/web/demos/broker-explorer/js/mcp-ws-client.js +132 -0
  71. package/web/demos/oauth-lab/README.md +94 -0
  72. package/web/demos/oauth-lab/config.json +117 -0
  73. package/web/demos/oauth-lab/css/app.css +1097 -0
  74. package/web/demos/oauth-lab/index.html +323 -0
  75. package/web/demos/oauth-lab/js/app.js +654 -0
  76. package/web/demos/oauth-lab/server/auth-server.mjs +426 -0
  77. package/web/demos/oauth-lab/server/factory-provider.mjs +269 -0
  78. package/web/demos/oauth-lab/server/smoke-test.mjs +308 -0
  79. package/web/demos/oauth-lab/server/start.mjs +106 -0
  80. package/web/demos/provider-tunnel/css/app.css +384 -0
  81. package/web/demos/provider-tunnel/index.html +99 -0
  82. package/web/demos/provider-tunnel/js/app.js +226 -0
  83. package/web/demos/provider-tunnel/js/toolbox-server.js +186 -0
  84. package/web/index.html +558 -0
  85. package/web/js/lib/broker-tunnel.js +173 -0
  86. package/dist/broker/adapters/broker.adapter.info.d.ts +0 -16
  87. package/dist/broker/adapters/broker.adapter.info.js +0 -43
  88. package/dist/broker/adapters/broker.adapter.info.js.map +0 -1
  89. package/dist/broker/adapters/broker.adapter.providers.d.ts +0 -18
  90. package/dist/broker/adapters/broker.adapter.providers.js +0 -61
  91. package/dist/broker/adapters/broker.adapter.providers.js.map +0 -1
  92. package/dist/broker/aggregate/aggregate.catalog.d.ts +0 -54
  93. package/dist/broker/aggregate/aggregate.catalog.js +0 -105
  94. package/dist/broker/aggregate/aggregate.catalog.js.map +0 -1
  95. package/dist/broker/aggregate/aggregate.server.d.ts +0 -47
  96. package/dist/broker/aggregate/aggregate.server.js +0 -151
  97. package/dist/broker/aggregate/aggregate.server.js.map +0 -1
  98. package/dist/broker/aggregate/provider.client.session.d.ts +0 -52
  99. package/dist/broker/aggregate/provider.client.session.js +0 -140
  100. package/dist/broker/aggregate/provider.client.session.js.map +0 -1
  101. package/dist/broker/behaviors/broker.behavior.info.d.ts +0 -15
  102. package/dist/broker/behaviors/broker.behavior.info.js +0 -41
  103. package/dist/broker/behaviors/broker.behavior.info.js.map +0 -1
  104. package/dist/broker/behaviors/broker.behavior.providers.d.ts +0 -19
  105. package/dist/broker/behaviors/broker.behavior.providers.js +0 -69
  106. package/dist/broker/behaviors/broker.behavior.providers.js.map +0 -1
  107. package/dist/broker/broker.context.d.ts +0 -59
  108. package/dist/broker/broker.context.js +0 -2
  109. package/dist/broker/broker.context.js.map +0 -1
  110. package/dist/broker/broker.grammars.d.ts +0 -130
  111. package/dist/broker/broker.grammars.js +0 -229
  112. package/dist/broker/broker.grammars.js.map +0 -1
  113. package/dist/broker/broker.server.d.ts +0 -66
  114. package/dist/broker/broker.server.js +0 -73
  115. package/dist/broker/broker.server.js.map +0 -1
  116. package/dist/broker/index.d.ts +0 -9
  117. package/dist/broker/index.js +0 -7
  118. package/dist/broker/index.js.map +0 -1
  119. package/dist/config.d.ts +0 -136
  120. package/dist/config.js +0 -61
  121. package/dist/config.js.map +0 -1
  122. package/dist/mcpb.loader.d.ts +0 -24
  123. package/dist/mcpb.loader.js +0 -161
  124. package/dist/mcpb.loader.js.map +0 -1
  125. package/dist/mcpb.unzip.d.ts +0 -6
  126. package/dist/mcpb.unzip.js +0 -95
  127. package/dist/mcpb.unzip.js.map +0 -1
  128. package/dist/remote.transports.d.ts +0 -16
  129. package/dist/remote.transports.js +0 -297
  130. package/dist/remote.transports.js.map +0 -1
  131. package/dist/remote.upstream.d.ts +0 -36
  132. package/dist/remote.upstream.js +0 -52
  133. package/dist/remote.upstream.js.map +0 -1
  134. package/dist/stdio.upstream.d.ts +0 -45
  135. package/dist/stdio.upstream.js +0 -85
  136. package/dist/stdio.upstream.js.map +0 -1
  137. package/dist/upstream.d.ts +0 -33
  138. package/dist/upstream.js +0 -2
  139. package/dist/upstream.js.map +0 -1
  140. package/dist/version.d.ts +0 -2
  141. package/dist/version.js +0 -9
  142. package/dist/version.js.map +0 -1
  143. package/dist/ws.tunnel.builder.d.ts +0 -139
  144. package/dist/ws.tunnel.builder.js +0 -205
  145. package/dist/ws.tunnel.builder.js.map +0 -1
  146. package/dist/ws.tunnel.d.ts +0 -373
  147. package/dist/ws.tunnel.js +0 -1090
  148. package/dist/ws.tunnel.js.map +0 -1
  149. /package/dist/{broker/grammars → grammars}/default/en.json +0 -0
  150. /package/dist/{broker/grammars → grammars}/default/fr.json +0 -0
  151. /package/dist/{broker/grammars → grammars}/default/zh.json +0 -0
@@ -0,0 +1,94 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>MCP Broker, Broker Explorer Demo</title>
7
+ <link rel="icon" type="image/png" href="../../assets/logo.png" />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link
11
+ rel="stylesheet"
12
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
13
+ />
14
+ <link rel="stylesheet" href="../../css/styles.css" />
15
+ <link rel="stylesheet" href="css/app.css" />
16
+ </head>
17
+ <body>
18
+ <!-- Topbar -->
19
+ <header class="topbar">
20
+ <a class="brand" href="https://github.com/pandaGaume/mcp-broker" target="_blank" rel="noopener">
21
+ <img class="brand-mark" src="../../assets/logo.png" alt="" aria-hidden="true" />
22
+ <span>@cyanmycelium/mcp-broker</span>
23
+ </a>
24
+ <span class="topbar-tag">Broker Explorer Demo</span>
25
+ <span class="topbar-spacer"></span>
26
+ <a class="topbar-link" href="../../broker-self-mcp.html">Explainer →</a>
27
+ <span id="status-badge" class="badge disconnected">Disconnected</span>
28
+ </header>
29
+
30
+ <div class="layout">
31
+ <!-- Left: client connection -->
32
+ <aside class="left-panel panel">
33
+ <div class="panel-title">Client connection</div>
34
+ <div class="panel-body">
35
+ <div class="field">
36
+ <label for="ws-url">Broker client base URL</label>
37
+ <input type="text" id="ws-url" />
38
+ </div>
39
+ <div class="field">
40
+ <label for="slot-name">Slot to explore</label>
41
+ <input type="text" id="slot-name" value="_broker" />
42
+ </div>
43
+ <div class="slot-presets">
44
+ <button type="button" class="preset" data-slot="_broker">_broker</button>
45
+ <button type="button" class="preset" data-slot="_all">_all</button>
46
+ </div>
47
+ <div class="btn-row">
48
+ <button id="start-btn" class="btn btn-primary">Connect</button>
49
+ <button id="stop-btn" class="btn btn-secondary" disabled>Disconnect</button>
50
+ </div>
51
+
52
+ <div class="endpoints" id="endpoints" hidden>
53
+ <div class="field">
54
+ <label for="ep-http">Streamable HTTP (MCP Inspector)</label>
55
+ <input type="text" id="ep-http" readonly />
56
+ </div>
57
+ <div class="field">
58
+ <label for="ep-sse">Legacy SSE</label>
59
+ <input type="text" id="ep-sse" readonly />
60
+ </div>
61
+ <div class="field">
62
+ <label for="ep-ws">Raw WebSocket</label>
63
+ <input type="text" id="ep-ws" readonly />
64
+ </div>
65
+ </div>
66
+
67
+ <p class="hint">
68
+ This page is an MCP <strong>client</strong>. Connecting opens a raw WebSocket to the
69
+ broker's <code>/&lt;slot&gt;</code> endpoint, runs the MCP <code>initialize</code>
70
+ handshake and lists the slot's tools. Try <code>_broker</code> for the broker's own
71
+ introspection tools, or <code>_all</code> for the aggregate of every provider. The MCP
72
+ client itself is isolated in <code>js/mcp-ws-client.js</code>.
73
+ </p>
74
+ </div>
75
+ </aside>
76
+
77
+ <!-- Right: catalog + console -->
78
+ <div class="right-column">
79
+ <section class="panel catalog-panel">
80
+ <div class="panel-title">Live catalog, call a tool on the connected slot</div>
81
+ <div class="panel-body" id="catalog">
82
+ <p class="empty-note">Not connected. Press Connect to list the slot's tools.</p>
83
+ </div>
84
+ </section>
85
+ <section class="panel console-panel">
86
+ <div class="panel-title">Console</div>
87
+ <div class="panel-body" id="console-output"></div>
88
+ </section>
89
+ </div>
90
+ </div>
91
+
92
+ <script type="module" src="js/app.js"></script>
93
+ </body>
94
+ </html>
@@ -0,0 +1,271 @@
1
+ /**
2
+ * app.js, UI wiring for the broker explorer demo.
3
+ *
4
+ * Owns the DOM: console log, status badge, live catalog, and the connect /
5
+ * disconnect lifecycle. The MCP-over-WebSocket client is imported from
6
+ * ./mcp-ws-client.js (standard MCP, nothing broker-specific).
7
+ */
8
+ import { McpWebSocketClient } from "./mcp-ws-client.js";
9
+
10
+ // =====================================================================
11
+ // Console + status helpers
12
+ // =====================================================================
13
+ const consoleEl = document.getElementById("console-output");
14
+
15
+ function log(msg, level = "info") {
16
+ const ts = new Date().toLocaleTimeString("en", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
17
+ const div = document.createElement("div");
18
+ div.className = `log-line ${level}`;
19
+ div.innerHTML = `<span class="ts">${ts}</span><span class="msg"></span>`;
20
+ div.querySelector(".msg").textContent = msg;
21
+ consoleEl.appendChild(div);
22
+ consoleEl.scrollTop = consoleEl.scrollHeight;
23
+ }
24
+
25
+ const badgeEl = document.getElementById("status-badge");
26
+
27
+ function setStatus(next) {
28
+ badgeEl.className = `badge ${next}`;
29
+ badgeEl.textContent = { disconnected: "Disconnected", connecting: "Connecting…", connected: "Connected" }[next] ?? next;
30
+ }
31
+
32
+ // =====================================================================
33
+ // Catalog panel, rendered live from the slot's tools/list response.
34
+ // =====================================================================
35
+ const catalogEl = document.getElementById("catalog");
36
+
37
+ function clearCatalog(note) {
38
+ catalogEl.innerHTML = "";
39
+ if (note) {
40
+ const p = document.createElement("p");
41
+ p.className = "empty-note";
42
+ p.textContent = note;
43
+ catalogEl.appendChild(p);
44
+ }
45
+ }
46
+
47
+ function renderCatalog(tools) {
48
+ catalogEl.innerHTML = "";
49
+ if (tools.length === 0) {
50
+ clearCatalog("This slot exposes no tools.");
51
+ return;
52
+ }
53
+
54
+ const group = document.createElement("div");
55
+ group.className = "cat-group";
56
+ group.innerHTML = `<div class="cat-group-title">Tools (${tools.length})</div>`;
57
+
58
+ for (const tool of tools) {
59
+ const schema = tool.inputSchema ?? {};
60
+ const props = schema.properties ?? {};
61
+ const required = schema.required ?? [];
62
+ const paramText = Object.entries(props)
63
+ .map(([pn, pd]) => {
64
+ const req = required.includes(pn);
65
+ const type = (pd && pd.type) || "any";
66
+ return `<span class="pname">${pn}</span>${req ? '<span class="preq">*</span>' : ""}: ${type}`;
67
+ })
68
+ .join(", ");
69
+
70
+ const item = document.createElement("div");
71
+ item.className = "cat-item";
72
+ item.innerHTML =
73
+ `<div class="cat-item-name"></div>` +
74
+ `<div class="cat-item-desc"></div>` +
75
+ (paramText ? `<div class="cat-item-params">(${paramText})</div>` : "") +
76
+ `<div class="cat-item-call">` +
77
+ `<input type="text" class="args-input" spellcheck="false" />` +
78
+ `<button class="call-btn">Call</button>` +
79
+ `</div>`;
80
+ item.querySelector(".cat-item-name").textContent = tool.name;
81
+ item.querySelector(".cat-item-desc").textContent = tool.description ?? "";
82
+
83
+ // Seed the args field with a skeleton object of the declared properties.
84
+ const skeleton = {};
85
+ for (const pn of Object.keys(props)) skeleton[pn] = "";
86
+ const argsInput = item.querySelector(".args-input");
87
+ argsInput.value = JSON.stringify(skeleton);
88
+
89
+ const callBtn = item.querySelector(".call-btn");
90
+ callBtn.addEventListener("click", () => void callTool(tool.name, argsInput, callBtn, item));
91
+
92
+ group.appendChild(item);
93
+ }
94
+ catalogEl.appendChild(group);
95
+ }
96
+
97
+ /**
98
+ * Renders an MCP `tools/call` result into readable text.
99
+ *
100
+ * Prefers `structuredContent` (MCP 2025-06-18) when the tool provides it.
101
+ * Otherwise falls back to the `content` blocks: a `text` block holding JSON is
102
+ * parsed and pretty-printed (rather than shown double-escaped); non-JSON text
103
+ * is kept verbatim.
104
+ */
105
+ function formatToolResult(result) {
106
+ if (result && result.structuredContent !== undefined) {
107
+ return { text: JSON.stringify(result.structuredContent, null, 2), isError: result.isError === true };
108
+ }
109
+
110
+ const blocks = Array.isArray(result?.content) ? result.content : [];
111
+ const parts = blocks.map((block) => {
112
+ if (block && block.type === "text" && typeof block.text === "string") {
113
+ try {
114
+ return JSON.stringify(JSON.parse(block.text), null, 2);
115
+ } catch {
116
+ return block.text;
117
+ }
118
+ }
119
+ return JSON.stringify(block, null, 2);
120
+ });
121
+ const text = parts.length > 0 ? parts.join("\n\n") : JSON.stringify(result, null, 2);
122
+ return { text, isError: result?.isError === true };
123
+ }
124
+
125
+ /** Calls one tool and renders its result inline on the card. */
126
+ async function callTool(name, argsInput, callBtn, item) {
127
+ if (!client) return;
128
+
129
+ let args;
130
+ try {
131
+ args = argsInput.value.trim() ? JSON.parse(argsInput.value) : {};
132
+ } catch {
133
+ log(`Invalid JSON arguments for "${name}".`, "error");
134
+ return;
135
+ }
136
+
137
+ let resultBox = item.querySelector(".cat-result");
138
+ if (!resultBox) {
139
+ resultBox = document.createElement("div");
140
+ resultBox.className = "cat-result";
141
+ item.appendChild(resultBox);
142
+ }
143
+
144
+ callBtn.disabled = true;
145
+ resultBox.classList.remove("error");
146
+ resultBox.textContent = "Calling…";
147
+ log(`tools/call → ${name} ${JSON.stringify(args)}`, "dim");
148
+
149
+ try {
150
+ const result = await client.callTool(name, args);
151
+ const formatted = formatToolResult(result);
152
+ resultBox.classList.toggle("error", formatted.isError);
153
+ resultBox.textContent = formatted.text;
154
+ log(`tools/call ← ${name} ${formatted.isError ? "returned an error result." : "returned."}`, formatted.isError ? "warn" : "ok");
155
+ } catch (err) {
156
+ resultBox.classList.add("error");
157
+ resultBox.textContent = err.message;
158
+ log(`tools/call ← ${name} failed: ${err.message}`, "error");
159
+ } finally {
160
+ callBtn.disabled = false;
161
+ }
162
+ }
163
+
164
+ // =====================================================================
165
+ // Lifecycle
166
+ // =====================================================================
167
+ let client = null;
168
+ let intentionalClose = false;
169
+
170
+ const startBtn = document.getElementById("start-btn");
171
+ const stopBtn = document.getElementById("stop-btn");
172
+ const wsUrlInput = document.getElementById("ws-url");
173
+ const slotInput = document.getElementById("slot-name");
174
+ const endpointsEl = document.getElementById("endpoints");
175
+
176
+ // Derive the client WebSocket base from the page origin: ws:// over HTTP,
177
+ // wss:// over HTTPS, avoids mixed-content errors under TLS.
178
+ (function deriveClientUrl() {
179
+ const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
180
+ wsUrlInput.value = `${scheme}//${window.location.host}`;
181
+ })();
182
+
183
+ // Slot preset chips fill the slot input.
184
+ for (const preset of document.querySelectorAll(".preset")) {
185
+ preset.addEventListener("click", () => {
186
+ slotInput.value = preset.dataset.slot;
187
+ });
188
+ }
189
+
190
+ function teardown() {
191
+ client = null;
192
+ intentionalClose = false;
193
+ setStatus("disconnected");
194
+ startBtn.disabled = false;
195
+ stopBtn.disabled = true;
196
+ endpointsEl.hidden = true;
197
+ }
198
+
199
+ function showEndpoints(slot) {
200
+ const httpScheme = window.location.protocol === "https:" ? "https:" : "http:";
201
+ const wsScheme = window.location.protocol === "https:" ? "wss:" : "ws:";
202
+ const host = window.location.host;
203
+ const enc = encodeURIComponent(slot);
204
+ document.getElementById("ep-http").value = `${httpScheme}//${host}/${enc}/mcp`;
205
+ document.getElementById("ep-sse").value = `${httpScheme}//${host}/${enc}/sse`;
206
+ document.getElementById("ep-ws").value = `${wsScheme}//${host}/${enc}`;
207
+ endpointsEl.hidden = false;
208
+ }
209
+
210
+ startBtn.addEventListener("click", async () => {
211
+ const base = wsUrlInput.value.trim().replace(/\/+$/, "");
212
+ const slot = slotInput.value.trim();
213
+ if (!base || !slot) {
214
+ log("Enter the broker base URL and a slot name first.", "error");
215
+ return;
216
+ }
217
+ const url = `${base}/${encodeURIComponent(slot)}`;
218
+
219
+ setStatus("connecting");
220
+ startBtn.disabled = true;
221
+ intentionalClose = false;
222
+ clearCatalog("Connecting…");
223
+ log(`Opening WebSocket to ${url} …`, "dim");
224
+
225
+ const c = new McpWebSocketClient(url);
226
+ c.onError = (err) => log(`Socket error: ${err.message}`, "error");
227
+ c.onClose = () => {
228
+ if (intentionalClose) return;
229
+ if (client === c) {
230
+ log("Connection closed by the broker.", "warn");
231
+ clearCatalog("Disconnected.");
232
+ teardown();
233
+ }
234
+ };
235
+
236
+ try {
237
+ await c.open();
238
+ client = c;
239
+ const info = await c.initialize();
240
+ const serverName = info?.serverInfo?.name ?? slot;
241
+ log(`Connected, initialized against "${serverName}".`, "ok");
242
+
243
+ const tools = await c.listTools();
244
+ renderCatalog(tools);
245
+ log(`tools/list → ${tools.length} tool(s) on slot "${slot}".`, "ok");
246
+
247
+ setStatus("connected");
248
+ stopBtn.disabled = false;
249
+ showEndpoints(slot);
250
+ } catch (err) {
251
+ log(`Connection failed: ${err.message}`, "error");
252
+ c.close();
253
+ clearCatalog("Not connected. Press Connect to list the slot's tools.");
254
+ teardown();
255
+ }
256
+ });
257
+
258
+ stopBtn.addEventListener("click", () => {
259
+ if (!client) return;
260
+ intentionalClose = true;
261
+ stopBtn.disabled = true;
262
+ client.close();
263
+ log("Disconnected.", "warn");
264
+ clearCatalog("Not connected. Press Connect to list the slot's tools.");
265
+ teardown();
266
+ });
267
+
268
+ // =====================================================================
269
+ // Boot
270
+ // =====================================================================
271
+ log("Ready. Pick a slot (_broker or _all) and press Connect.", "dim");
@@ -0,0 +1,132 @@
1
+ /**
2
+ * mcp-ws-client.js: a minimal MCP client over a raw WebSocket.
3
+ *
4
+ * The broker exposes every slot as a raw-WebSocket client transport at
5
+ * `ws://<host>/<slot>`: the frames are plain newline-free JSON-RPC, relayed
6
+ * untouched to whatever server backs the slot (`_broker`, `_all`, or any
7
+ * tunnelled provider). There is therefore nothing broker-specific here: this
8
+ * is a standard MCP client. It is kept separate from `app.js` the same way the
9
+ * provider-tunnel demo keeps its MCP server in `toolbox-server.js`.
10
+ *
11
+ * Zero dependencies; speaks just enough of MCP for the explorer demo:
12
+ * `initialize`, `tools/list`, `tools/call`.
13
+ */
14
+
15
+ /** MCP protocol version negotiated with the slot. */
16
+ const PROTOCOL_VERSION = "2024-11-05";
17
+
18
+ /** Per-request timeout. */
19
+ const REQUEST_TIMEOUT_MS = 15_000;
20
+
21
+ export class McpWebSocketClient {
22
+ /**
23
+ * @param {string} url Full slot URL, e.g. `ws://host:3000/_broker`.
24
+ */
25
+ constructor(url) {
26
+ this.url = url;
27
+ this._ws = null;
28
+ this._pending = new Map();
29
+ this._nextId = 0;
30
+
31
+ /** @type {null | (() => void)} Fires when the socket closes. */
32
+ this.onClose = null;
33
+ /** @type {null | ((error: Error) => void)} Fires on a socket error. */
34
+ this.onError = null;
35
+ }
36
+
37
+ /**
38
+ * Opens the WebSocket. Resolves once the broker accepts the upgrade.
39
+ * @returns {Promise<void>}
40
+ */
41
+ open() {
42
+ return new Promise((resolve, reject) => {
43
+ let opened = false;
44
+ const ws = new WebSocket(this.url);
45
+ this._ws = ws;
46
+
47
+ ws.onopen = () => {
48
+ opened = true;
49
+ resolve();
50
+ };
51
+ ws.onerror = () => {
52
+ const err = new Error(`WebSocket error on ${this.url}`);
53
+ this.onError?.(err);
54
+ if (!opened) reject(err);
55
+ };
56
+ ws.onclose = () => {
57
+ this._rejectAll("connection closed");
58
+ this.onClose?.();
59
+ };
60
+ ws.onmessage = (ev) => {
61
+ let msg;
62
+ try {
63
+ msg = JSON.parse(typeof ev.data === "string" ? ev.data : "");
64
+ } catch {
65
+ return;
66
+ }
67
+ if (msg && typeof msg.id === "number" && this._pending.has(msg.id)) {
68
+ const pending = this._pending.get(msg.id);
69
+ this._pending.delete(msg.id);
70
+ clearTimeout(pending.timer);
71
+ pending.resolve(msg);
72
+ }
73
+ };
74
+ });
75
+ }
76
+
77
+ /** Runs the MCP `initialize` handshake. */
78
+ async initialize() {
79
+ const res = await this._request("initialize", {
80
+ protocolVersion: PROTOCOL_VERSION,
81
+ capabilities: {},
82
+ clientInfo: { name: "broker-explorer", version: "1.0.0" },
83
+ });
84
+ this._send({ jsonrpc: "2.0", method: "notifications/initialized" });
85
+ return res.result;
86
+ }
87
+
88
+ /** Lists the slot's tools. */
89
+ async listTools() {
90
+ const res = await this._request("tools/list", {});
91
+ if (res.error) throw new Error(res.error.message || "tools/list failed");
92
+ return res.result?.tools ?? [];
93
+ }
94
+
95
+ /** Calls a tool and returns the raw JSON-RPC result (or throws on error). */
96
+ async callTool(name, args) {
97
+ const res = await this._request("tools/call", { name, arguments: args });
98
+ if (res.error) throw new Error(res.error.message || "tools/call failed");
99
+ return res.result;
100
+ }
101
+
102
+ /** Closes the WebSocket. */
103
+ close() {
104
+ this._ws?.close();
105
+ }
106
+
107
+ _send(obj) {
108
+ if (this._ws && this._ws.readyState === WebSocket.OPEN) {
109
+ this._ws.send(JSON.stringify(obj));
110
+ }
111
+ }
112
+
113
+ _request(method, params) {
114
+ return new Promise((resolve, reject) => {
115
+ const id = ++this._nextId;
116
+ const timer = setTimeout(() => {
117
+ this._pending.delete(id);
118
+ reject(new Error(`request "${method}" timed out`));
119
+ }, REQUEST_TIMEOUT_MS);
120
+ this._pending.set(id, { resolve, reject, timer });
121
+ this._send({ jsonrpc: "2.0", id, method, params });
122
+ });
123
+ }
124
+
125
+ _rejectAll(reason) {
126
+ for (const pending of this._pending.values()) {
127
+ clearTimeout(pending.timer);
128
+ pending.reject(new Error(reason));
129
+ }
130
+ this._pending.clear();
131
+ }
132
+ }
@@ -0,0 +1,94 @@
1
+ # OAuth Policy Lab
2
+
3
+ A self-contained browser demonstration of the broker's OAuth 2.1 resource
4
+ server and hierarchical authorization features.
5
+
6
+ Its industrial resource paths use the compact ISA-95 / IEC 62264-aligned
7
+ profile documented in the
8
+ [hierarchical authorization guide](../../../../docs/hierarchical-authorization.md).
9
+ The policy engine remains domain-neutral and does not claim full ISA-95
10
+ compliance.
11
+
12
+ ## Run
13
+
14
+ From the `node/` directory:
15
+
16
+ ```sh
17
+ npm run demo:oauth
18
+ ```
19
+
20
+ The command builds the broker, starts the local authorization server on
21
+ `http://127.0.0.1:4100`, starts the broker and three stdio MCP providers on
22
+ `http://127.0.0.1:3001`, then opens:
23
+
24
+ ```text
25
+ http://127.0.0.1:3001/demos/oauth-lab/
26
+ ```
27
+
28
+ Set `OAUTH_LAB_NO_OPEN=1` to keep the command from opening a browser.
29
+
30
+ While the demo is running, its automated end-to-end check can be launched
31
+ from another terminal:
32
+
33
+ ```sh
34
+ npm run demo:oauth:smoke
35
+ ```
36
+
37
+ This example is for local development only. Its authorization server presents
38
+ four passwordless demo identities and generates an in-memory signing key on
39
+ every start.
40
+
41
+ ## What it demonstrates
42
+
43
+ - OAuth 2.0 Protected Resource Metadata discovery
44
+ - Authorization Code with PKCE S256
45
+ - authorization server metadata discovery
46
+ - signed JWT access tokens and JWKS validation
47
+ - issuer, expiration, OAuth scope, and RFC 8707 audience validation
48
+ - tab-scoped token caching with silent per-resource token acquisition
49
+ - claim mapping to users, groups, and client identities
50
+ - role inheritance and resource-scoped assignments
51
+ - explicit deny precedence
52
+ - per-slot scope requirements for `_broker`
53
+ - capability mapping for MCP tool calls
54
+ - live allow and deny audit events
55
+
56
+ ## Demo identities
57
+
58
+ | Identity | Group | Effective access |
59
+ | --- | --- | --- |
60
+ | Geordi La Forge, Chief Engineer | `starfleet-engineering` | Read, diagnose, and reset in Area A. Reset is explicitly denied on the critical furnace. |
61
+ | Worf, Chief Security Officer | `starfleet-security` | Read and start machines in Area A. |
62
+ | Seven of Nine, Astrometrics Specialist | `astrometrics` | Read-only access across the Paris site. |
63
+ | Jean-Luc Picard, Captain | `starfleet-command` | Full resource access and the `broker:admin` OAuth scope. |
64
+
65
+ Each token is audience-bound to the selected MCP resource. Picard's policy
66
+ grants access everywhere, but a token issued for `_broker` cannot be reused for
67
+ `motor-7`, `critical-furnace`, or `site-energy`. The browser caches one token
68
+ per resource for the lifetime of the tab. After the first interactive identity
69
+ selection, the local authorization server reuses its opaque identity session to
70
+ add tokens silently without presenting the identity page again.
71
+
72
+ The private signing key, authorization codes, identity sessions, and audit
73
+ events are held only in server memory. Access tokens are held in the browser's
74
+ tab-scoped session storage. Stop the demo with `Ctrl+C`.
75
+
76
+ ## Reading the results
77
+
78
+ The two layers of refusal look different on the wire, and telling them apart is
79
+ the point of the lab:
80
+
81
+ | Refusal | Where it happens | What comes back |
82
+ | --- | --- | --- |
83
+ | No token, invalid token, missing scope | Before the request reaches the transport | HTTP `401` or `403` with `WWW-Authenticate` |
84
+ | A policy denies the operation | Once the session is open and the frame is read | HTTP `200` with JSON-RPC `-32001 Forbidden` |
85
+
86
+ A policy denial is not a transport failure: the request arrived, the session was
87
+ valid, the operation was refused. So the status stays `200` and the refusal
88
+ travels inside the JSON-RPC body, which is also what the raw WebSocket transport
89
+ has always done.
90
+
91
+ `/<slot>/mcp` runs the real Streamable HTTP state machine: `initialize` opens a
92
+ session and returns `Mcp-Session-Id`, which every later request must send back.
93
+ Requests from a browser also carry an `Origin`, refused by default, so
94
+ `config.json` lists the lab's own origin under `allowedOrigins`.