@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.
- package/.mcp-broker.example/CONFIGURATION-EN.md +861 -0
- package/.mcp-broker.example/CONFIGURATION-FR.md +871 -0
- package/.mcp-broker.example/README.md +8 -3
- package/.mcp-broker.example/config.json +86 -0
- package/README.md +122 -3
- package/dist/bin.d.ts +0 -1
- package/dist/bin.js +180 -208
- package/dist/bin.js.map +1 -1
- package/dist/chunk-FTDKH2C4.js +3670 -0
- package/dist/chunk-FTDKH2C4.js.map +1 -0
- package/dist/{broker/grammars → grammars}/claude/en.json +1 -1
- package/dist/{broker/grammars → grammars}/claude/fr.json +1 -1
- package/dist/index.d.ts +1790 -18
- package/dist/index.js +2 -14
- package/dist/index.js.map +1 -1
- package/package.json +14 -8
- package/scripts/copy-assets.mjs +16 -10
- package/scripts/gen-cert.mjs +5 -5
- package/scripts/pack-mcpb.mjs +5 -5
- package/scripts/sign-bundle.mjs +6 -6
- package/src/auth/auth.config.ts +98 -0
- package/src/auth/auth.types.ts +120 -0
- package/src/auth/http.auth.ts +128 -0
- package/src/auth/index.ts +34 -0
- package/src/auth/jwt.validator.ts +63 -0
- package/src/auth/provider.auth.ts +114 -0
- package/src/auth/resource.metadata.ts +34 -0
- package/src/authorization/audit.ts +35 -0
- package/src/authorization/capability.classifier.ts +114 -0
- package/src/authorization/index.ts +37 -0
- package/src/authorization/policy.engine.ts +212 -0
- package/src/authorization/policy.types.ts +105 -0
- package/src/authorization/resource.path.ts +126 -0
- package/src/authorization/runtime.ts +56 -0
- package/src/authorization/slot.resource.ts +50 -0
- package/src/authorization/subject.mapper.ts +81 -0
- package/src/bin.ts +90 -7
- package/src/broker/adapters/broker.adapter.info.ts +3 -3
- package/src/broker/adapters/broker.adapter.providers.ts +3 -3
- package/src/broker/aggregate/aggregate.catalog.ts +49 -21
- package/src/broker/aggregate/aggregate.server.ts +149 -19
- package/src/broker/aggregate/provider.client.session.ts +22 -22
- package/src/broker/behaviors/broker.behavior.info.ts +4 -4
- package/src/broker/behaviors/broker.behavior.providers.ts +6 -6
- package/src/broker/broker.context.ts +10 -4
- package/src/broker/broker.grammars.ts +16 -13
- package/src/broker/broker.server.ts +12 -9
- package/src/broker/grammars/claude/en.json +1 -1
- package/src/broker/grammars/claude/fr.json +1 -1
- package/src/broker/index.ts +9 -9
- package/src/config.ts +81 -8
- package/src/index.ts +121 -20
- package/src/{mcpb.loader.ts → mcpb/mcpb.loader.ts} +24 -21
- package/src/{mcpb.unzip.ts → mcpb/mcpb.unzip.ts} +2 -2
- package/src/remote.transports.ts +11 -8
- package/src/remote.upstream.ts +11 -8
- package/src/stdio.upstream.ts +9 -6
- package/src/upstream.ts +6 -3
- package/src/ws/ws.interfaces.ts +357 -0
- package/src/{ws.tunnel.builder.ts → ws/ws.tunnel.builder.ts} +112 -9
- package/src/{ws.tunnel.ts → ws/ws.tunnel.ts} +646 -457
- package/web/README.md +94 -0
- package/web/assets/logo.png +0 -0
- package/web/broker-self-mcp.html +338 -0
- package/web/css/styles.css +580 -0
- package/web/demos/DemoPlaceholder.html +258 -0
- package/web/demos/broker-explorer/css/app.css +393 -0
- package/web/demos/broker-explorer/index.html +94 -0
- package/web/demos/broker-explorer/js/app.js +271 -0
- package/web/demos/broker-explorer/js/mcp-ws-client.js +132 -0
- package/web/demos/oauth-lab/README.md +94 -0
- package/web/demos/oauth-lab/config.json +117 -0
- package/web/demos/oauth-lab/css/app.css +1097 -0
- package/web/demos/oauth-lab/index.html +323 -0
- package/web/demos/oauth-lab/js/app.js +654 -0
- package/web/demos/oauth-lab/server/auth-server.mjs +426 -0
- package/web/demos/oauth-lab/server/factory-provider.mjs +269 -0
- package/web/demos/oauth-lab/server/smoke-test.mjs +308 -0
- package/web/demos/oauth-lab/server/start.mjs +106 -0
- package/web/demos/provider-tunnel/css/app.css +384 -0
- package/web/demos/provider-tunnel/index.html +99 -0
- package/web/demos/provider-tunnel/js/app.js +226 -0
- package/web/demos/provider-tunnel/js/toolbox-server.js +186 -0
- package/web/index.html +558 -0
- package/web/js/lib/broker-tunnel.js +173 -0
- package/dist/broker/adapters/broker.adapter.info.d.ts +0 -16
- package/dist/broker/adapters/broker.adapter.info.js +0 -43
- package/dist/broker/adapters/broker.adapter.info.js.map +0 -1
- package/dist/broker/adapters/broker.adapter.providers.d.ts +0 -18
- package/dist/broker/adapters/broker.adapter.providers.js +0 -61
- package/dist/broker/adapters/broker.adapter.providers.js.map +0 -1
- package/dist/broker/aggregate/aggregate.catalog.d.ts +0 -54
- package/dist/broker/aggregate/aggregate.catalog.js +0 -105
- package/dist/broker/aggregate/aggregate.catalog.js.map +0 -1
- package/dist/broker/aggregate/aggregate.server.d.ts +0 -47
- package/dist/broker/aggregate/aggregate.server.js +0 -151
- package/dist/broker/aggregate/aggregate.server.js.map +0 -1
- package/dist/broker/aggregate/provider.client.session.d.ts +0 -52
- package/dist/broker/aggregate/provider.client.session.js +0 -140
- package/dist/broker/aggregate/provider.client.session.js.map +0 -1
- package/dist/broker/behaviors/broker.behavior.info.d.ts +0 -15
- package/dist/broker/behaviors/broker.behavior.info.js +0 -41
- package/dist/broker/behaviors/broker.behavior.info.js.map +0 -1
- package/dist/broker/behaviors/broker.behavior.providers.d.ts +0 -19
- package/dist/broker/behaviors/broker.behavior.providers.js +0 -69
- package/dist/broker/behaviors/broker.behavior.providers.js.map +0 -1
- package/dist/broker/broker.context.d.ts +0 -59
- package/dist/broker/broker.context.js +0 -2
- package/dist/broker/broker.context.js.map +0 -1
- package/dist/broker/broker.grammars.d.ts +0 -130
- package/dist/broker/broker.grammars.js +0 -229
- package/dist/broker/broker.grammars.js.map +0 -1
- package/dist/broker/broker.server.d.ts +0 -66
- package/dist/broker/broker.server.js +0 -73
- package/dist/broker/broker.server.js.map +0 -1
- package/dist/broker/index.d.ts +0 -9
- package/dist/broker/index.js +0 -7
- package/dist/broker/index.js.map +0 -1
- package/dist/config.d.ts +0 -136
- package/dist/config.js +0 -61
- package/dist/config.js.map +0 -1
- package/dist/mcpb.loader.d.ts +0 -24
- package/dist/mcpb.loader.js +0 -161
- package/dist/mcpb.loader.js.map +0 -1
- package/dist/mcpb.unzip.d.ts +0 -6
- package/dist/mcpb.unzip.js +0 -95
- package/dist/mcpb.unzip.js.map +0 -1
- package/dist/remote.transports.d.ts +0 -16
- package/dist/remote.transports.js +0 -297
- package/dist/remote.transports.js.map +0 -1
- package/dist/remote.upstream.d.ts +0 -36
- package/dist/remote.upstream.js +0 -52
- package/dist/remote.upstream.js.map +0 -1
- package/dist/stdio.upstream.d.ts +0 -45
- package/dist/stdio.upstream.js +0 -85
- package/dist/stdio.upstream.js.map +0 -1
- package/dist/upstream.d.ts +0 -33
- package/dist/upstream.js +0 -2
- package/dist/upstream.js.map +0 -1
- package/dist/version.d.ts +0 -2
- package/dist/version.js +0 -9
- package/dist/version.js.map +0 -1
- package/dist/ws.tunnel.builder.d.ts +0 -139
- package/dist/ws.tunnel.builder.js +0 -205
- package/dist/ws.tunnel.builder.js.map +0 -1
- package/dist/ws.tunnel.d.ts +0 -373
- package/dist/ws.tunnel.js +0 -1090
- package/dist/ws.tunnel.js.map +0 -1
- /package/dist/{broker/grammars → grammars}/default/en.json +0 -0
- /package/dist/{broker/grammars → grammars}/default/fr.json +0 -0
- /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>/<slot></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`.
|