aiohttp-tiny-mcp 0.1.0__py3-none-any.whl
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.
- aiohttp_tiny_mcp/__init__.py +76 -0
- aiohttp_tiny_mcp/adapter.py +321 -0
- aiohttp_tiny_mcp/auth.py +129 -0
- aiohttp_tiny_mcp/client.py +111 -0
- aiohttp_tiny_mcp/client_base.py +295 -0
- aiohttp_tiny_mcp/console/__init__.py +98 -0
- aiohttp_tiny_mcp/console/console.css +525 -0
- aiohttp_tiny_mcp/console/console.js +1273 -0
- aiohttp_tiny_mcp/console/index.html +100 -0
- aiohttp_tiny_mcp/core.py +327 -0
- aiohttp_tiny_mcp/dispatcher.py +309 -0
- aiohttp_tiny_mcp/endpoint.py +531 -0
- aiohttp_tiny_mcp/exchange.py +279 -0
- aiohttp_tiny_mcp/http_sse.py +267 -0
- aiohttp_tiny_mcp/hub.py +109 -0
- aiohttp_tiny_mcp/models.py +346 -0
- aiohttp_tiny_mcp/namespaces.py +36 -0
- aiohttp_tiny_mcp/postgres.py +454 -0
- aiohttp_tiny_mcp/protocol/__init__.py +0 -0
- aiohttp_tiny_mcp/protocol/selection.py +92 -0
- aiohttp_tiny_mcp/protocol/v2024_11_05.py +30 -0
- aiohttp_tiny_mcp/protocol/v2025_03_26.py +165 -0
- aiohttp_tiny_mcp/protocol/v2025_06_18.py +11 -0
- aiohttp_tiny_mcp/protocol/v2025_11_25.py +164 -0
- aiohttp_tiny_mcp/protocol/v2026_07_28.py +363 -0
- aiohttp_tiny_mcp/py.typed +0 -0
- aiohttp_tiny_mcp/redis.py +195 -0
- aiohttp_tiny_mcp/registry.py +162 -0
- aiohttp_tiny_mcp/request_state.py +107 -0
- aiohttp_tiny_mcp/schema.py +131 -0
- aiohttp_tiny_mcp/sessions.py +347 -0
- aiohttp_tiny_mcp/specs.py +268 -0
- aiohttp_tiny_mcp/sqlite.py +236 -0
- aiohttp_tiny_mcp/sse.py +260 -0
- aiohttp_tiny_mcp/stdio.py +187 -0
- aiohttp_tiny_mcp/stdio_client.py +91 -0
- aiohttp_tiny_mcp/subscriptions.py +124 -0
- aiohttp_tiny_mcp/testing.py +200 -0
- aiohttp_tiny_mcp-0.1.0.dist-info/METADATA +12 -0
- aiohttp_tiny_mcp-0.1.0.dist-info/RECORD +41 -0
- aiohttp_tiny_mcp-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1273 @@
|
|
|
1
|
+
/* Browser MCP client; revision differences are defined in REVISIONS. */
|
|
2
|
+
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
const REVISIONS = {
|
|
7
|
+
"2026-07-28": {
|
|
8
|
+
describe: "server/discover",
|
|
9
|
+
handshake: false, // no initialize, and nothing to complete
|
|
10
|
+
meta: true, // identity and capabilities on every request
|
|
11
|
+
versionHeader: true,
|
|
12
|
+
methodHeader: true, // Mcp-Method, and Mcp-Name where it applies
|
|
13
|
+
ask: "result", // the question comes back as the result
|
|
14
|
+
},
|
|
15
|
+
"2025-11-25": {
|
|
16
|
+
describe: "initialize",
|
|
17
|
+
handshake: true, // stated once, and remembered
|
|
18
|
+
meta: false,
|
|
19
|
+
versionHeader: true,
|
|
20
|
+
methodHeader: false,
|
|
21
|
+
ask: "push", // pushed on the open stream, answered separately
|
|
22
|
+
},
|
|
23
|
+
"2025-06-18": {
|
|
24
|
+
describe: "initialize",
|
|
25
|
+
handshake: true,
|
|
26
|
+
meta: false,
|
|
27
|
+
versionHeader: true,
|
|
28
|
+
methodHeader: false,
|
|
29
|
+
ask: "push",
|
|
30
|
+
},
|
|
31
|
+
"2025-03-26": {
|
|
32
|
+
describe: "initialize",
|
|
33
|
+
handshake: true,
|
|
34
|
+
meta: false,
|
|
35
|
+
versionHeader: false, // its absence is what implies this revision
|
|
36
|
+
methodHeader: false,
|
|
37
|
+
ask: "arguments", // the question rides in the tool call itself
|
|
38
|
+
},
|
|
39
|
+
"2024-11-05": {
|
|
40
|
+
// The first published revision. It predates Streamable HTTP, so a real
|
|
41
|
+
// client of it speaks the two-endpoint HTTP+SSE transport instead; this
|
|
42
|
+
// page reaches a server that accepts the revision over the transport it
|
|
43
|
+
// has, which is what makes it useful for seeing the projection.
|
|
44
|
+
describe: "initialize",
|
|
45
|
+
handshake: true,
|
|
46
|
+
meta: false,
|
|
47
|
+
versionHeader: false,
|
|
48
|
+
methodHeader: false,
|
|
49
|
+
ask: "arguments",
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const NAME_HEADER_METHODS = new Set(["tools/call", "resources/read", "prompts/get"]);
|
|
54
|
+
|
|
55
|
+
// Package-specific retry fields for the pre-elicitation revision.
|
|
56
|
+
const ASKED_META = "dev.aiohttp-tiny-mcp/inputRequired";
|
|
57
|
+
const ANSWERS_KEY = "mcpAnswers";
|
|
58
|
+
const STATE_KEY = "mcpState";
|
|
59
|
+
|
|
60
|
+
const CLIENT = { name: "aiohttp-tiny-mcp-console", version: "0.1.0" };
|
|
61
|
+
|
|
62
|
+
const DRIVEN = new Set([ANSWERS_KEY, STATE_KEY]);
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Client {
|
|
66
|
+
constructor(url, version, options) {
|
|
67
|
+
this.url = url;
|
|
68
|
+
this.version = version;
|
|
69
|
+
this.rules = REVISIONS[version];
|
|
70
|
+
this.answerable = options.answerable;
|
|
71
|
+
this.onFrame = options.onFrame; // every message, either way
|
|
72
|
+
this.onNotify = options.onNotify; // progress, logging, changes
|
|
73
|
+
this.onQuestion = options.onQuestion; // returns an ElicitResult
|
|
74
|
+
// Return the server-issued session id on subsequent requests.
|
|
75
|
+
this.sessionId = null;
|
|
76
|
+
this.counter = 0;
|
|
77
|
+
this.tools = new Map();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get capabilities() {
|
|
81
|
+
return this.answerable ? { elicitation: {} } : {};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
nextId() {
|
|
85
|
+
this.counter += 1;
|
|
86
|
+
return this.counter;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
decorate(params, method) {
|
|
90
|
+
const shaped = { ...params };
|
|
91
|
+
if (this.rules.meta) {
|
|
92
|
+
shaped._meta = {
|
|
93
|
+
...(shaped._meta || {}),
|
|
94
|
+
"io.modelcontextprotocol/protocolVersion": this.version,
|
|
95
|
+
"io.modelcontextprotocol/clientInfo": CLIENT,
|
|
96
|
+
"io.modelcontextprotocol/clientCapabilities": this.capabilities,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return shaped;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
headers(method, name) {
|
|
103
|
+
const headers = {
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
Accept: "application/json, text/event-stream",
|
|
106
|
+
};
|
|
107
|
+
if (this.rules.versionHeader) headers["MCP-Protocol-Version"] = this.version;
|
|
108
|
+
if (this.rules.methodHeader) {
|
|
109
|
+
headers["Mcp-Method"] = method;
|
|
110
|
+
if (name && NAME_HEADER_METHODS.has(method)) headers["Mcp-Name"] = encodeHeader(name);
|
|
111
|
+
}
|
|
112
|
+
if (this.sessionId) headers["Mcp-Session-Id"] = this.sessionId;
|
|
113
|
+
return headers;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* Read frames before EOF so pushed questions can be answered while the stream stays open. */
|
|
117
|
+
async *frames(envelope, method, name) {
|
|
118
|
+
const headers = this.headers(method, name);
|
|
119
|
+
this.onFrame("sent", envelope);
|
|
120
|
+
const response = await fetch(this.url, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers,
|
|
123
|
+
body: JSON.stringify(envelope),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const issued = response.headers.get("Mcp-Session-Id");
|
|
127
|
+
if (issued) this.sessionId = issued;
|
|
128
|
+
|
|
129
|
+
const kind = (response.headers.get("Content-Type") || "").split(";")[0].trim();
|
|
130
|
+
if (kind !== "text/event-stream") {
|
|
131
|
+
const text = await response.text();
|
|
132
|
+
if (!text) return;
|
|
133
|
+
const body = JSON.parse(text);
|
|
134
|
+
this.onFrame("received", body);
|
|
135
|
+
yield body;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const reader = response.body.getReader();
|
|
140
|
+
const decoder = new TextDecoder();
|
|
141
|
+
let buffer = "";
|
|
142
|
+
while (true) {
|
|
143
|
+
const { value, done } = await reader.read();
|
|
144
|
+
if (done) break;
|
|
145
|
+
buffer += decoder.decode(value, { stream: true });
|
|
146
|
+
let cut;
|
|
147
|
+
while ((cut = buffer.indexOf("\n")) >= 0) {
|
|
148
|
+
const line = buffer.slice(0, cut).trim();
|
|
149
|
+
buffer = buffer.slice(cut + 1);
|
|
150
|
+
if (!line.startsWith("data:")) continue;
|
|
151
|
+
const text = line.slice(5).trim();
|
|
152
|
+
// A data line with no payload primes or keeps the stream alive.
|
|
153
|
+
if (!text) continue;
|
|
154
|
+
const body = JSON.parse(text);
|
|
155
|
+
this.onFrame("received", body);
|
|
156
|
+
yield body;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
answers(frame, id) {
|
|
162
|
+
if (!("result" in frame) && !("error" in frame)) return false;
|
|
163
|
+
// Pre-decode errors have id: null but still answer this request.
|
|
164
|
+
return frame.id === id || ("error" in frame && frame.id === null);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async request(method, params, name) {
|
|
168
|
+
const envelope = {
|
|
169
|
+
jsonrpc: "2.0",
|
|
170
|
+
id: this.nextId(),
|
|
171
|
+
method,
|
|
172
|
+
params: this.decorate(params || {}, method),
|
|
173
|
+
};
|
|
174
|
+
let body = null;
|
|
175
|
+
for await (const frame of this.frames(envelope, method, name)) {
|
|
176
|
+
if (this.answers(frame, envelope.id)) {
|
|
177
|
+
body = frame;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
await this.incoming(frame);
|
|
181
|
+
}
|
|
182
|
+
if (body === null) throw new Error(`${method} ended without a reply`);
|
|
183
|
+
if (body.error) {
|
|
184
|
+
const failure = new Error(body.error.message);
|
|
185
|
+
failure.code = body.error.code;
|
|
186
|
+
failure.data = body.error.data;
|
|
187
|
+
throw failure;
|
|
188
|
+
}
|
|
189
|
+
return body.result;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async notify(method, params) {
|
|
193
|
+
const envelope = { jsonrpc: "2.0", method, params: this.decorate(params || {}, method) };
|
|
194
|
+
this.onFrame("sent", envelope);
|
|
195
|
+
await fetch(this.url, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: this.headers(method),
|
|
198
|
+
body: JSON.stringify(envelope),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async incoming(frame) {
|
|
203
|
+
if (frame.id === undefined || frame.id === null) {
|
|
204
|
+
this.onNotify(frame);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!frame.method) return;
|
|
208
|
+
// Pushed questions receive a separate bare JSON-RPC response.
|
|
209
|
+
const reply = this.answerable
|
|
210
|
+
? { jsonrpc: "2.0", id: frame.id, result: await this.onQuestion(frame.params || {}) }
|
|
211
|
+
: {
|
|
212
|
+
jsonrpc: "2.0",
|
|
213
|
+
id: frame.id,
|
|
214
|
+
error: { code: -32601, message: `unsupported: ${frame.method}` },
|
|
215
|
+
};
|
|
216
|
+
this.onFrame("sent", reply);
|
|
217
|
+
await fetch(this.url, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: this.headers("elicitation/create"),
|
|
220
|
+
body: JSON.stringify(reply),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async initialize() {
|
|
226
|
+
const params = this.rules.handshake
|
|
227
|
+
? { protocolVersion: this.version, clientInfo: CLIENT, capabilities: this.capabilities }
|
|
228
|
+
: {};
|
|
229
|
+
const result = await this.request(this.rules.describe, params);
|
|
230
|
+
if (this.rules.handshake) await this.notify("notifications/initialized", {});
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async listTools() {
|
|
235
|
+
const result = await this.request("tools/list", {});
|
|
236
|
+
this.tools = new Map((result.tools || []).map((tool) => [tool.name, tool]));
|
|
237
|
+
return result.tools || [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async listResources() {
|
|
241
|
+
const fixed = await this.request("resources/list", {});
|
|
242
|
+
let templated = { resourceTemplates: [] };
|
|
243
|
+
try {
|
|
244
|
+
templated = await this.request("resources/templates/list", {});
|
|
245
|
+
} catch (error) {
|
|
246
|
+
/* A server with no templates may not expose the method. */
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
resources: fixed.resources || [],
|
|
250
|
+
templates: templated.resourceTemplates || [],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async listPrompts() {
|
|
255
|
+
const result = await this.request("prompts/list", {});
|
|
256
|
+
return result.prompts || [];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async readResource(uri) {
|
|
260
|
+
return this.request("resources/read", { uri }, uri);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async getPrompt(name, argumentValues) {
|
|
264
|
+
return this.request("prompts/get", { name, arguments: argumentValues }, name);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async callTool(name, argumentValues) {
|
|
268
|
+
let answers = {};
|
|
269
|
+
let state = null;
|
|
270
|
+
for (let round = 0; round < 8; round += 1) {
|
|
271
|
+
const params = { name, arguments: { ...argumentValues } };
|
|
272
|
+
if (Object.keys(answers).length) {
|
|
273
|
+
// Resend previous answers because the handler restarts on each round.
|
|
274
|
+
if (this.rules.ask === "arguments") {
|
|
275
|
+
params.arguments[ANSWERS_KEY] = answers;
|
|
276
|
+
if (state) params.arguments[STATE_KEY] = state;
|
|
277
|
+
} else {
|
|
278
|
+
params.inputResponses = answers;
|
|
279
|
+
if (state) params.requestState = state;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const result = await this.request("tools/call", params, name);
|
|
283
|
+
const asked = this.asked(result);
|
|
284
|
+
if (!asked) return result;
|
|
285
|
+
if (!this.answerable) return result;
|
|
286
|
+
state = asked.state;
|
|
287
|
+
for (const [key, request] of Object.entries(asked.requests)) {
|
|
288
|
+
answers[key] = await this.onQuestion(request.params || {});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
throw new Error(`${name} is still asking after 8 rounds`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
asked(result) {
|
|
295
|
+
if (this.rules.ask === "result" && result.resultType === "input_required") {
|
|
296
|
+
return { requests: result.inputRequests || {}, state: result.requestState || null };
|
|
297
|
+
}
|
|
298
|
+
if (this.rules.ask === "arguments") {
|
|
299
|
+
const carried = (result._meta || {})[ASKED_META];
|
|
300
|
+
if (carried) {
|
|
301
|
+
return { requests: carried.inputRequests || {}, state: carried.requestState || null };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function encodeHeader(value) {
|
|
309
|
+
const text = String(value);
|
|
310
|
+
const plain = /^[\x20-\x7E]*$/.test(text) && text === text.trim();
|
|
311
|
+
if (plain) return text;
|
|
312
|
+
const bytes = new TextEncoder().encode(text);
|
|
313
|
+
let binary = "";
|
|
314
|
+
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
|
|
315
|
+
return `=?base64?${btoa(binary)}?=`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
const page = {
|
|
320
|
+
endpoint: document.getElementById("endpoint"),
|
|
321
|
+
revision: document.getElementById("revision"),
|
|
322
|
+
answerable: document.getElementById("answerable"),
|
|
323
|
+
connect: document.getElementById("connect"),
|
|
324
|
+
refresh: document.getElementById("refresh"),
|
|
325
|
+
status: document.getElementById("status"),
|
|
326
|
+
catalogue: document.getElementById("catalogue"),
|
|
327
|
+
subject: document.getElementById("subject"),
|
|
328
|
+
about: document.getElementById("about"),
|
|
329
|
+
args: document.getElementById("arguments"),
|
|
330
|
+
invoke: document.getElementById("invoke"),
|
|
331
|
+
outcome: document.getElementById("outcome"),
|
|
332
|
+
traffic: document.getElementById("traffic"),
|
|
333
|
+
follow: document.getElementById("follow"),
|
|
334
|
+
clear: document.getElementById("clear"),
|
|
335
|
+
greeting: document.getElementById("greeting"),
|
|
336
|
+
trafficPanel: document.getElementById("traffic-panel"),
|
|
337
|
+
trafficToggle: document.getElementById("traffic-toggle"),
|
|
338
|
+
cataloguePanel: document.getElementById("catalogue-panel"),
|
|
339
|
+
catalogueToggle: document.getElementById("catalogue-toggle"),
|
|
340
|
+
question: document.getElementById("question"),
|
|
341
|
+
questionText: document.getElementById("question-text"),
|
|
342
|
+
questionFields: document.getElementById("question-fields"),
|
|
343
|
+
answerForm: document.getElementById("answer"),
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
let client = null;
|
|
347
|
+
let chosen = null;
|
|
348
|
+
let endpointUrl = null;
|
|
349
|
+
|
|
350
|
+
function element(tag, className, text) {
|
|
351
|
+
const made = document.createElement(tag);
|
|
352
|
+
if (className) made.className = className;
|
|
353
|
+
if (text !== undefined) made.textContent = text;
|
|
354
|
+
return made;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function say(text, state) {
|
|
358
|
+
page.status.textContent = text;
|
|
359
|
+
page.status.className = `status ${state || ""}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
function record(direction, body) {
|
|
364
|
+
if (page.traffic.firstElementChild && page.traffic.firstElementChild.className === "empty") {
|
|
365
|
+
page.traffic.textContent = "";
|
|
366
|
+
}
|
|
367
|
+
const frame = element("details", `frame ${direction}`);
|
|
368
|
+
const label = body.method || (body.error ? `error ${body.error.code}` : "result");
|
|
369
|
+
const summary = element("summary", null, `${label}${body.id !== undefined ? ` #${body.id}` : ""}`);
|
|
370
|
+
frame.append(summary, valueViews(body));
|
|
371
|
+
page.traffic.append(frame);
|
|
372
|
+
if (page.follow.checked) page.traffic.scrollTop = page.traffic.scrollHeight;
|
|
373
|
+
markTraffic();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function noticed(frame) {
|
|
377
|
+
const params = frame.params || {};
|
|
378
|
+
if (frame.method === "notifications/progress") {
|
|
379
|
+
let bar = page.outcome.querySelector("progress");
|
|
380
|
+
if (!bar) {
|
|
381
|
+
bar = element("progress");
|
|
382
|
+
page.outcome.prepend(bar);
|
|
383
|
+
}
|
|
384
|
+
bar.max = params.total || 1;
|
|
385
|
+
bar.value = params.progress || 0;
|
|
386
|
+
bar.title = params.message || "";
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (frame.method === "notifications/message") {
|
|
390
|
+
const line = element("div", "note-line");
|
|
391
|
+
line.append(element("span", "level", `${params.level} `), document.createTextNode(
|
|
392
|
+
typeof params.data === "string" ? params.data : JSON.stringify(params.data)
|
|
393
|
+
));
|
|
394
|
+
page.outcome.append(line);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const line = element("div", "note-line", `${frame.method} ${summarize(params)}`);
|
|
398
|
+
page.outcome.append(line);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
function askPerson(request) {
|
|
403
|
+
const schema = request.requestedSchema || { type: "object", properties: {} };
|
|
404
|
+
prose(request.message || "The server needs an answer.", page.questionText);
|
|
405
|
+
page.questionFields.textContent = "";
|
|
406
|
+
const fields = buildFields(schema, page.questionFields);
|
|
407
|
+
page.question.showModal();
|
|
408
|
+
|
|
409
|
+
return new Promise((resolve) => {
|
|
410
|
+
page.answerForm.onsubmit = null;
|
|
411
|
+
page.question.addEventListener(
|
|
412
|
+
"close",
|
|
413
|
+
() => {
|
|
414
|
+
const action = page.question.returnValue || "cancel";
|
|
415
|
+
if (action !== "accept") {
|
|
416
|
+
resolve({ action });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
resolve({ action: "accept", content: readFields(fields) });
|
|
420
|
+
},
|
|
421
|
+
{ once: true }
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
function buildFields(schema, into, omit) {
|
|
428
|
+
const properties = schema.properties || {};
|
|
429
|
+
const required = new Set(schema.required || []);
|
|
430
|
+
const skip = omit || new Set();
|
|
431
|
+
const fields = [];
|
|
432
|
+
for (const [name, property] of Object.entries(properties)) {
|
|
433
|
+
if (skip.has(name)) continue;
|
|
434
|
+
fields.push(buildField(name, property, required.has(name), into));
|
|
435
|
+
}
|
|
436
|
+
if (!fields.length) {
|
|
437
|
+
into.append(element("p", "empty", "Nothing to fill in."));
|
|
438
|
+
}
|
|
439
|
+
return fields;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function buildField(name, property, isRequired, into) {
|
|
443
|
+
const kinds = [].concat(property.type || inferType(property));
|
|
444
|
+
const kind = kinds.find((one) => one !== "null") || "string";
|
|
445
|
+
const row = element("div", kind === "boolean" ? "argument flag" : "argument");
|
|
446
|
+
const label = element("label", null, property.title || name);
|
|
447
|
+
if (isRequired) label.append(element("span", "required", "*"));
|
|
448
|
+
let input;
|
|
449
|
+
|
|
450
|
+
if (property.enum) {
|
|
451
|
+
input = element("select");
|
|
452
|
+
if (!isRequired) input.append(element("option", null, ""));
|
|
453
|
+
property.enum.forEach((value) => {
|
|
454
|
+
const option = element("option", null, String(value));
|
|
455
|
+
option.value = String(value);
|
|
456
|
+
input.append(option);
|
|
457
|
+
});
|
|
458
|
+
} else if (kind === "boolean") {
|
|
459
|
+
input = element("input");
|
|
460
|
+
input.type = "checkbox";
|
|
461
|
+
if (property.default === true) input.checked = true;
|
|
462
|
+
} else if (kind === "integer" || kind === "number") {
|
|
463
|
+
input = element("input");
|
|
464
|
+
input.type = "number";
|
|
465
|
+
if (kind === "integer") input.step = "1";
|
|
466
|
+
if (property.minimum !== undefined) input.min = property.minimum;
|
|
467
|
+
if (property.maximum !== undefined) input.max = property.maximum;
|
|
468
|
+
if (property.default !== undefined) input.value = property.default;
|
|
469
|
+
} else if (kind === "object" || kind === "array") {
|
|
470
|
+
input = element("textarea");
|
|
471
|
+
input.placeholder = kind === "array" ? "[]" : "{}";
|
|
472
|
+
if (property.default !== undefined) input.value = JSON.stringify(property.default, null, 2);
|
|
473
|
+
} else {
|
|
474
|
+
input = element("input");
|
|
475
|
+
input.type = "text";
|
|
476
|
+
if (property.default !== undefined) input.value = property.default;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
input.dataset.name = name;
|
|
480
|
+
input.dataset.kind = kind;
|
|
481
|
+
if (isRequired) input.dataset.required = "1";
|
|
482
|
+
row.append(label, input);
|
|
483
|
+
if (property.description) row.append(element("div", "hint", property.description));
|
|
484
|
+
into.append(row);
|
|
485
|
+
return input;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function inferType(property) {
|
|
489
|
+
const branches = property.anyOf || property.oneOf || [];
|
|
490
|
+
const named = branches.map((branch) => branch.type).filter((type) => type && type !== "null");
|
|
491
|
+
return named.length ? named : "string";
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/* Empty inputs represent absent arguments, including for required-field validation. */
|
|
495
|
+
function missing(fields) {
|
|
496
|
+
return fields.filter(
|
|
497
|
+
(input) => input.dataset.required && input.dataset.kind !== "boolean" && !input.value.trim()
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function readFields(fields) {
|
|
502
|
+
const values = {};
|
|
503
|
+
fields.forEach((input) => {
|
|
504
|
+
const name = input.dataset.name;
|
|
505
|
+
const kind = input.dataset.kind;
|
|
506
|
+
if (kind === "boolean") {
|
|
507
|
+
values[name] = input.checked;
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const raw = input.value.trim();
|
|
511
|
+
if (raw === "") return; // absent, which is not the same as empty
|
|
512
|
+
if (kind === "integer") values[name] = parseInt(raw, 10);
|
|
513
|
+
else if (kind === "number") values[name] = parseFloat(raw);
|
|
514
|
+
else if (kind === "object" || kind === "array") {
|
|
515
|
+
try {
|
|
516
|
+
values[name] = JSON.parse(raw);
|
|
517
|
+
} catch (error) {
|
|
518
|
+
throw new Error(`${name}: ${error.message}`);
|
|
519
|
+
}
|
|
520
|
+
} else values[name] = raw;
|
|
521
|
+
});
|
|
522
|
+
return values;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
function show(items, title, into, describe, pick) {
|
|
527
|
+
if (!items.length) return;
|
|
528
|
+
const group = element("div", "group");
|
|
529
|
+
group.append(element("h3", null, title));
|
|
530
|
+
items.forEach((item) => {
|
|
531
|
+
const button = element("button", "item");
|
|
532
|
+
button.type = "button";
|
|
533
|
+
const name = element("span", "name", describe.label(item));
|
|
534
|
+
(describe.tags ? describe.tags(item) : []).forEach((tag) => {
|
|
535
|
+
name.append(element("span", `tag ${tag}`, tag));
|
|
536
|
+
});
|
|
537
|
+
button.append(name);
|
|
538
|
+
const note = describe.note(item);
|
|
539
|
+
if (note) button.append(element("span", "note", note));
|
|
540
|
+
button.onclick = () => {
|
|
541
|
+
page.catalogue.querySelectorAll(".item").forEach((other) => other.classList.remove("chosen"));
|
|
542
|
+
button.classList.add("chosen");
|
|
543
|
+
pick(item);
|
|
544
|
+
};
|
|
545
|
+
group.append(button);
|
|
546
|
+
});
|
|
547
|
+
into.append(group);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function firstLine(text) {
|
|
551
|
+
return plain((text || "").split("\n")[0]);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/* Escape server text before adding markdown tags; descriptions cannot supply HTML. */
|
|
555
|
+
|
|
556
|
+
const SAFE_LINK = /^(https?:|mailto:|#|\/)/i;
|
|
557
|
+
|
|
558
|
+
function escapeHtml(text) {
|
|
559
|
+
return String(text)
|
|
560
|
+
.replace(/&/g, "&")
|
|
561
|
+
.replace(/</g, "<")
|
|
562
|
+
.replace(/>/g, ">")
|
|
563
|
+
.replace(/"/g, """);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/* Protect code spans from inline markup using placeholders absent from escaped input. */
|
|
567
|
+
function inline(text) {
|
|
568
|
+
const spans = [];
|
|
569
|
+
let marked = text.replace(/`([^`]+)`/g, (whole, code) => {
|
|
570
|
+
spans.push(code);
|
|
571
|
+
return `<c${spans.length - 1}>`;
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
marked = marked
|
|
575
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (whole, label, href) =>
|
|
576
|
+
SAFE_LINK.test(href)
|
|
577
|
+
? `<a href="${href}" target="_blank" rel="noreferrer noopener">${label}</a>`
|
|
578
|
+
: label
|
|
579
|
+
)
|
|
580
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
581
|
+
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
|
|
582
|
+
.replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, "$1<em>$2</em>")
|
|
583
|
+
.replace(/(^|[\s(])_([^_\s][^_]*)_/g, "$1<em>$2</em>");
|
|
584
|
+
|
|
585
|
+
return marked.replace(/<c(\d+)>/g, (whole, index) => `<code>${spans[index]}</code>`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function listItem(line) {
|
|
589
|
+
const bullet = line.match(/^\s*[-*+]\s+(.*)$/);
|
|
590
|
+
if (bullet) return { ordered: false, text: bullet[1] };
|
|
591
|
+
const numbered = line.match(/^\s*\d+[.)]\s+(.*)$/);
|
|
592
|
+
if (numbered) return { ordered: true, text: numbered[1] };
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function markdownHtml(text) {
|
|
597
|
+
const lines = String(text || "").split("\n");
|
|
598
|
+
const out = [];
|
|
599
|
+
let paragraph = [];
|
|
600
|
+
let list = null;
|
|
601
|
+
|
|
602
|
+
const endParagraph = () => {
|
|
603
|
+
if (!paragraph.length) return;
|
|
604
|
+
// Reflow source-wrapped paragraphs to the panel width.
|
|
605
|
+
out.push(`<p>${inline(escapeHtml(paragraph.join(" ").trim()))}</p>`);
|
|
606
|
+
paragraph = [];
|
|
607
|
+
};
|
|
608
|
+
const endList = () => {
|
|
609
|
+
if (!list) return;
|
|
610
|
+
const tag = list.ordered ? "ol" : "ul";
|
|
611
|
+
out.push(`<${tag}>${list.items.map((item) => `<li>${item}</li>`).join("")}</${tag}>`);
|
|
612
|
+
list = null;
|
|
613
|
+
};
|
|
614
|
+
const endBoth = () => {
|
|
615
|
+
endParagraph();
|
|
616
|
+
endList();
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
620
|
+
const line = lines[index];
|
|
621
|
+
|
|
622
|
+
if (/^\s*```/.test(line)) {
|
|
623
|
+
endBoth();
|
|
624
|
+
const code = [];
|
|
625
|
+
for (index += 1; index < lines.length && !/^\s*```/.test(lines[index]); index += 1) {
|
|
626
|
+
code.push(lines[index]);
|
|
627
|
+
}
|
|
628
|
+
out.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (!line.trim()) {
|
|
633
|
+
endBoth();
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
|
638
|
+
if (heading) {
|
|
639
|
+
endBoth();
|
|
640
|
+
const level = Math.min(heading[1].length + 2, 6); // the panel owns h1 and h2
|
|
641
|
+
out.push(`<h${level}>${inline(escapeHtml(heading[2]))}</h${level}>`);
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const item = listItem(line);
|
|
646
|
+
if (item) {
|
|
647
|
+
endParagraph();
|
|
648
|
+
if (!list || list.ordered !== item.ordered) {
|
|
649
|
+
endList();
|
|
650
|
+
list = { ordered: item.ordered, items: [] };
|
|
651
|
+
}
|
|
652
|
+
list.items.push(inline(escapeHtml(item.text)));
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Indentation continues a preceding list or paragraph; otherwise it starts code.
|
|
657
|
+
if (/^ {4,}\S/.test(line) && !list && !paragraph.length) {
|
|
658
|
+
const code = [];
|
|
659
|
+
while (index < lines.length && (/^ {4,}/.test(lines[index]) || !lines[index].trim())) {
|
|
660
|
+
code.push(lines[index].replace(/^ {4}/, ""));
|
|
661
|
+
index += 1;
|
|
662
|
+
}
|
|
663
|
+
index -= 1;
|
|
664
|
+
out.push(`<pre><code>${escapeHtml(code.join("\n").replace(/\s+$/, ""))}</code></pre>`);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (list) {
|
|
669
|
+
list.items[list.items.length - 1] += ` ${inline(escapeHtml(line.trim()))}`;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
paragraph.push(line.trim());
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
endBoth();
|
|
676
|
+
return out.join("");
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function prose(text, into) {
|
|
680
|
+
into.innerHTML = markdownHtml(text);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/* Strip markdown for single-line button labels. */
|
|
684
|
+
function plain(text) {
|
|
685
|
+
return String(text || "")
|
|
686
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
687
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
688
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
689
|
+
.replace(/^#{1,6}\s+/, "")
|
|
690
|
+
.replace(/^\s*[-*+]\s+/, "");
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
function show(items, title, into, describe, pick) {
|
|
695
|
+
if (!items.length) return;
|
|
696
|
+
const group = element("div", "group");
|
|
697
|
+
group.append(element("h3", null, title));
|
|
698
|
+
items.forEach((item) => {
|
|
699
|
+
const button = element("button", "item");
|
|
700
|
+
button.type = "button";
|
|
701
|
+
const name = element("span", "name", describe.label(item));
|
|
702
|
+
(describe.tags ? describe.tags(item) : []).forEach((tag) => {
|
|
703
|
+
name.append(element("span", `tag ${tag}`, tag));
|
|
704
|
+
});
|
|
705
|
+
button.append(name);
|
|
706
|
+
const note = describe.note(item);
|
|
707
|
+
if (note) button.append(element("span", "note", note));
|
|
708
|
+
button.onclick = () => {
|
|
709
|
+
page.catalogue.querySelectorAll(".item").forEach((other) => other.classList.remove("chosen"));
|
|
710
|
+
button.classList.add("chosen");
|
|
711
|
+
pick(item);
|
|
712
|
+
};
|
|
713
|
+
group.append(button);
|
|
714
|
+
});
|
|
715
|
+
into.append(group);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function hints(tool) {
|
|
719
|
+
const annotations = tool.annotations || {};
|
|
720
|
+
const tags = [];
|
|
721
|
+
if (annotations.destructiveHint) tags.push("destructive");
|
|
722
|
+
if (annotations.readOnlyHint) tags.push("readonly");
|
|
723
|
+
return tags;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async function loadCatalogue() {
|
|
727
|
+
page.catalogue.textContent = "";
|
|
728
|
+
const into = document.createDocumentFragment();
|
|
729
|
+
|
|
730
|
+
const tools = await client.listTools();
|
|
731
|
+
show(tools, `Tools (${tools.length})`, into, {
|
|
732
|
+
label: (tool) => tool.name,
|
|
733
|
+
note: (tool) => firstLine(tool.description),
|
|
734
|
+
tags: hints,
|
|
735
|
+
}, (tool) => choose({ kind: "tool", item: tool }));
|
|
736
|
+
|
|
737
|
+
const { resources, templates } = await client.listResources();
|
|
738
|
+
show(resources, `Resources (${resources.length})`, into, {
|
|
739
|
+
label: (resource) => resource.uri,
|
|
740
|
+
note: (resource) => firstLine(resource.description) || resource.name,
|
|
741
|
+
}, (resource) => choose({ kind: "resource", item: resource }));
|
|
742
|
+
|
|
743
|
+
show(templates, `Resource templates (${templates.length})`, into, {
|
|
744
|
+
label: (template) => template.uriTemplate,
|
|
745
|
+
note: (template) => firstLine(template.description) || template.name,
|
|
746
|
+
}, (template) => choose({ kind: "template", item: template }));
|
|
747
|
+
|
|
748
|
+
const prompts = await client.listPrompts();
|
|
749
|
+
show(prompts, `Prompts (${prompts.length})`, into, {
|
|
750
|
+
label: (prompt) => prompt.name,
|
|
751
|
+
note: (prompt) => firstLine(prompt.description),
|
|
752
|
+
}, (prompt) => choose({ kind: "prompt", item: prompt }));
|
|
753
|
+
|
|
754
|
+
if (!into.childNodes.length) {
|
|
755
|
+
page.catalogue.append(element("p", "empty", "This server offers nothing."));
|
|
756
|
+
} else {
|
|
757
|
+
page.catalogue.append(into);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
function choose(what) {
|
|
763
|
+
chosen = what;
|
|
764
|
+
if (catalogue) catalogue.fold();
|
|
765
|
+
page.outcome.textContent = "";
|
|
766
|
+
page.args.textContent = "";
|
|
767
|
+
page.invoke.disabled = false;
|
|
768
|
+
|
|
769
|
+
if (what.kind === "tool") {
|
|
770
|
+
page.subject.textContent = what.item.name;
|
|
771
|
+
prose(what.item.description, page.about);
|
|
772
|
+
// Hide retry arguments managed by this client.
|
|
773
|
+
chosen.fields = buildFields(what.item.inputSchema || {}, page.args, DRIVEN);
|
|
774
|
+
page.invoke.textContent = "Call";
|
|
775
|
+
} else if (what.kind === "prompt") {
|
|
776
|
+
page.subject.textContent = what.item.name;
|
|
777
|
+
prose(what.item.description, page.about);
|
|
778
|
+
const schema = {
|
|
779
|
+
type: "object",
|
|
780
|
+
properties: Object.fromEntries(
|
|
781
|
+
(what.item.arguments || []).map((argument) => [
|
|
782
|
+
argument.name,
|
|
783
|
+
{ type: "string", description: argument.description },
|
|
784
|
+
])
|
|
785
|
+
),
|
|
786
|
+
required: (what.item.arguments || []).filter((one) => one.required).map((one) => one.name),
|
|
787
|
+
};
|
|
788
|
+
chosen.fields = buildFields(schema, page.args);
|
|
789
|
+
page.invoke.textContent = "Get";
|
|
790
|
+
} else if (what.kind === "resource") {
|
|
791
|
+
page.subject.textContent = what.item.uri;
|
|
792
|
+
prose(what.item.description, page.about);
|
|
793
|
+
chosen.fields = buildFields({}, page.args);
|
|
794
|
+
page.invoke.textContent = "Read";
|
|
795
|
+
} else {
|
|
796
|
+
page.subject.textContent = what.item.uriTemplate;
|
|
797
|
+
prose(what.item.description, page.about);
|
|
798
|
+
const variables = [...what.item.uriTemplate.matchAll(/\{([^}]+)\}/g)].map((one) => one[1]);
|
|
799
|
+
const schema = {
|
|
800
|
+
type: "object",
|
|
801
|
+
properties: Object.fromEntries(variables.map((name) => [name, { type: "string" }])),
|
|
802
|
+
required: variables,
|
|
803
|
+
};
|
|
804
|
+
chosen.fields = buildFields(schema, page.args);
|
|
805
|
+
page.invoke.textContent = "Read";
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
const COPY_ICON =
|
|
811
|
+
'<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">' +
|
|
812
|
+
'<rect x="5.5" y="5.5" width="8" height="9" rx="1.5" fill="none" ' +
|
|
813
|
+
'stroke="currentColor" stroke-width="1.3"/>' +
|
|
814
|
+
'<path d="M10.5 3.5h-7a1.5 1.5 0 0 0-1.5 1.5v7" fill="none" ' +
|
|
815
|
+
'stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
|
|
816
|
+
|
|
817
|
+
const DONE_ICON =
|
|
818
|
+
'<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">' +
|
|
819
|
+
'<path d="M3 8.5l3.5 3.5L13 5" fill="none" stroke="currentColor" ' +
|
|
820
|
+
'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
821
|
+
|
|
822
|
+
/* Fall back to execCommand when HTTP lacks the secure context required by Clipboard API. */
|
|
823
|
+
async function copyText(text) {
|
|
824
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
825
|
+
await navigator.clipboard.writeText(text);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const carrier = document.createElement("textarea");
|
|
829
|
+
carrier.value = text;
|
|
830
|
+
carrier.setAttribute("readonly", "");
|
|
831
|
+
carrier.style.position = "fixed";
|
|
832
|
+
carrier.style.opacity = "0";
|
|
833
|
+
document.body.append(carrier);
|
|
834
|
+
carrier.select();
|
|
835
|
+
document.execCommand("copy");
|
|
836
|
+
carrier.remove();
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function copyButton(text, what) {
|
|
840
|
+
const button = element("button", "copy");
|
|
841
|
+
button.type = "button";
|
|
842
|
+
button.title = `Copy ${what}`;
|
|
843
|
+
button.setAttribute("aria-label", `Copy ${what}`);
|
|
844
|
+
button.innerHTML = COPY_ICON;
|
|
845
|
+
button.onclick = async (event) => {
|
|
846
|
+
event.stopPropagation();
|
|
847
|
+
try {
|
|
848
|
+
await copyText(text);
|
|
849
|
+
button.innerHTML = DONE_ICON;
|
|
850
|
+
button.classList.add("done");
|
|
851
|
+
setTimeout(() => {
|
|
852
|
+
button.innerHTML = COPY_ICON;
|
|
853
|
+
button.classList.remove("done");
|
|
854
|
+
}, 1200);
|
|
855
|
+
} catch (error) {
|
|
856
|
+
button.title = `Could not copy: ${error.message}`;
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
return button;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function copyable(node, text, what) {
|
|
863
|
+
const holder = element("span", "copyable");
|
|
864
|
+
holder.append(node, copyButton(text, what || "this value"));
|
|
865
|
+
return holder;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function copyTextOf(value) {
|
|
869
|
+
if (value === null || value === undefined) return "null";
|
|
870
|
+
if (typeof value === "object") return JSON.stringify(value, null, 2);
|
|
871
|
+
return String(value);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/* Preserve JSON types; do not infer dates or other meanings from values. */
|
|
875
|
+
|
|
876
|
+
const LONG_TEXT = 70;
|
|
877
|
+
|
|
878
|
+
function scalar(value) {
|
|
879
|
+
if (value === null || value === undefined) {
|
|
880
|
+
return element("span", "value absent", "null");
|
|
881
|
+
}
|
|
882
|
+
if (typeof value === "boolean") {
|
|
883
|
+
return element("span", `value boolean ${value}`, value ? "true" : "false");
|
|
884
|
+
}
|
|
885
|
+
if (typeof value === "number") {
|
|
886
|
+
return element("span", "value number", String(value));
|
|
887
|
+
}
|
|
888
|
+
const text = String(value);
|
|
889
|
+
if (text.includes("\n") || text.length > LONG_TEXT) {
|
|
890
|
+
return element("pre", "value text", text);
|
|
891
|
+
}
|
|
892
|
+
return element("span", "value string", text || "—");
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function isScalar(value) {
|
|
896
|
+
return value === null || typeof value !== "object";
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function renderValue(value, depth) {
|
|
900
|
+
const level = depth || 0;
|
|
901
|
+
if (isScalar(value)) {
|
|
902
|
+
return value === null || value === undefined
|
|
903
|
+
? scalar(value)
|
|
904
|
+
: copyable(scalar(value), copyTextOf(value), "this value");
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (Array.isArray(value)) {
|
|
908
|
+
if (!value.length) return element("span", "value absent", "empty");
|
|
909
|
+
const list = element("ol", "items");
|
|
910
|
+
value.forEach((item) => {
|
|
911
|
+
const row = element("li");
|
|
912
|
+
row.append(renderValue(item, level + 1));
|
|
913
|
+
list.append(row);
|
|
914
|
+
});
|
|
915
|
+
return list;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const names = Object.keys(value);
|
|
919
|
+
if (!names.length) return element("span", "value absent", "empty");
|
|
920
|
+
|
|
921
|
+
const table = element("div", "fields");
|
|
922
|
+
names.forEach((name) => {
|
|
923
|
+
const row = element("div", "field-row");
|
|
924
|
+
const held = value[name];
|
|
925
|
+
const label = element("span", "field-name");
|
|
926
|
+
label.append(document.createTextNode(humanName(name)));
|
|
927
|
+
const shown = element("div", "field-value");
|
|
928
|
+
shown.append(renderValue(held, level + 1));
|
|
929
|
+
if (!isScalar(held)) {
|
|
930
|
+
shown.classList.add("nested");
|
|
931
|
+
label.classList.add("labels-branch");
|
|
932
|
+
label.append(copyButton(copyTextOf(held), humanName(name)));
|
|
933
|
+
}
|
|
934
|
+
row.append(label, shown);
|
|
935
|
+
table.append(row);
|
|
936
|
+
});
|
|
937
|
+
return table;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/* Humanize field names, preserving dotted or slashed protocol keys. */
|
|
941
|
+
function humanName(name) {
|
|
942
|
+
const text = String(name);
|
|
943
|
+
if (/[./]/.test(text)) return text;
|
|
944
|
+
return text
|
|
945
|
+
.replace(/[_-]+/g, " ")
|
|
946
|
+
.replace(/([a-z\d])([A-Z])/g, "$1 $2")
|
|
947
|
+
.trim()
|
|
948
|
+
.replace(/^./, (first) => first.toUpperCase());
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function parsed(text) {
|
|
952
|
+
try {
|
|
953
|
+
return JSON.parse(text);
|
|
954
|
+
} catch (error) {
|
|
955
|
+
return undefined;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/* Compare rendered values without treating object key order as a difference. */
|
|
960
|
+
function same(left, right) {
|
|
961
|
+
if (left === undefined || right === undefined) return false;
|
|
962
|
+
return JSON.stringify(sorted(left)) === JSON.stringify(sorted(right));
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function sorted(value) {
|
|
966
|
+
if (Array.isArray(value)) return value.map(sorted);
|
|
967
|
+
if (value === null || typeof value !== "object") return value;
|
|
968
|
+
return Object.fromEntries(
|
|
969
|
+
Object.keys(value)
|
|
970
|
+
.sort()
|
|
971
|
+
.map((name) => [name, sorted(value[name])])
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function summarize(params) {
|
|
976
|
+
return Object.entries(params || {})
|
|
977
|
+
.filter(([name]) => name !== "_meta")
|
|
978
|
+
.map(([name, value]) => `${name}=${isScalar(value) ? value : JSON.stringify(value)}`)
|
|
979
|
+
.join(" ");
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function valueViews(value) {
|
|
983
|
+
const holder = element("div", "views");
|
|
984
|
+
const bar = element("div", "views-bar");
|
|
985
|
+
bar.append(copyButton(copyTextOf(value), "the whole message"));
|
|
986
|
+
const toggle = element("button", "quiet tiny", "raw");
|
|
987
|
+
toggle.type = "button";
|
|
988
|
+
bar.append(toggle);
|
|
989
|
+
|
|
990
|
+
const tree = element("div", "value-tree");
|
|
991
|
+
tree.append(renderValue(value, 0));
|
|
992
|
+
const raw = element("pre", "value-raw", JSON.stringify(value, null, 2));
|
|
993
|
+
raw.hidden = true;
|
|
994
|
+
|
|
995
|
+
toggle.onclick = (event) => {
|
|
996
|
+
event.preventDefault();
|
|
997
|
+
const showRaw = raw.hidden;
|
|
998
|
+
raw.hidden = !showRaw;
|
|
999
|
+
tree.hidden = showRaw;
|
|
1000
|
+
toggle.textContent = showRaw ? "tree" : "raw";
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
holder.append(bar, tree, raw);
|
|
1004
|
+
return holder;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function reportValue(title, value, failed) {
|
|
1008
|
+
const card = element("div", `result${failed ? " failed" : ""}`);
|
|
1009
|
+
card.append(element("h4", null, title));
|
|
1010
|
+
card.append(valueViews(value));
|
|
1011
|
+
page.outcome.append(card);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/* What to show for one tool result: text, a value, or both.
|
|
1015
|
+
*
|
|
1016
|
+
* A value is shown wherever there is one. `structuredContent` is one, and so
|
|
1017
|
+
* is text that parses as an object or an array -- a server states JSON
|
|
1018
|
+
* compactly because it is going over a wire, and the two revisions older than
|
|
1019
|
+
* 2025-06-18 have no `structuredContent` to put it in at all. Anything that
|
|
1020
|
+
* does not parse is not JSON and stays the text it is.
|
|
1021
|
+
*
|
|
1022
|
+
* `"5"`, `"true"` and `"null"` do parse, but to a number, a boolean and
|
|
1023
|
+
* nothing. None of those is a tree, and a tool that answers in one word means
|
|
1024
|
+
* the word.
|
|
1025
|
+
*/
|
|
1026
|
+
function resultViews(result, text) {
|
|
1027
|
+
const structured = result.structuredContent;
|
|
1028
|
+
const held = structured !== undefined && structured !== null;
|
|
1029
|
+
const fromText = parsed(text);
|
|
1030
|
+
const structural = fromText !== null && fromText !== undefined && typeof fromText === "object";
|
|
1031
|
+
const value = held ? structured : structural ? fromText : undefined;
|
|
1032
|
+
// Text that says the same thing as the value beside it says nothing the
|
|
1033
|
+
// second time.
|
|
1034
|
+
const repeats = value !== undefined && same(fromText, value);
|
|
1035
|
+
return {
|
|
1036
|
+
text: text && !repeats ? text : null,
|
|
1037
|
+
value,
|
|
1038
|
+
label: held && !repeats ? "Structured content" : "Result",
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function report(title, body, failed) {
|
|
1043
|
+
const card = element("div", `result${failed ? " failed" : ""}`);
|
|
1044
|
+
card.append(element("h4", null, title));
|
|
1045
|
+
const text = String(body);
|
|
1046
|
+
const held = parsed(text);
|
|
1047
|
+
card.append(element("pre", null, held === undefined ? text : JSON.stringify(held, null, 2)));
|
|
1048
|
+
page.outcome.append(card);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function invoke() {
|
|
1052
|
+
if (!chosen || !client) return;
|
|
1053
|
+
page.invoke.disabled = true;
|
|
1054
|
+
page.outcome.textContent = "";
|
|
1055
|
+
const empty = missing(chosen.fields);
|
|
1056
|
+
chosen.fields.forEach((input) => input.classList.remove("wanting"));
|
|
1057
|
+
if (empty.length) {
|
|
1058
|
+
empty.forEach((input) => input.classList.add("wanting"));
|
|
1059
|
+
const names = empty.map((input) => input.dataset.name).join(", ");
|
|
1060
|
+
report("Not sent", `${names} ${empty.length > 1 ? "are" : "is"} required.`, true);
|
|
1061
|
+
empty[0].focus();
|
|
1062
|
+
page.invoke.disabled = false;
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
try {
|
|
1066
|
+
const values = readFields(chosen.fields);
|
|
1067
|
+
let result;
|
|
1068
|
+
if (chosen.kind === "tool") {
|
|
1069
|
+
result = await client.callTool(chosen.item.name, values);
|
|
1070
|
+
const failed = result.isError === true;
|
|
1071
|
+
const text = (result.content || [])
|
|
1072
|
+
.map((block) => (block.type === "text" ? block.text : JSON.stringify(block)))
|
|
1073
|
+
.join("\n");
|
|
1074
|
+
const views = resultViews(result, text);
|
|
1075
|
+
if (views.text !== null) {
|
|
1076
|
+
report(failed ? "Result (isError)" : "Result", views.text, failed);
|
|
1077
|
+
}
|
|
1078
|
+
if (views.value !== undefined) reportValue(views.label, views.value, failed);
|
|
1079
|
+
if (views.text === null && views.value === undefined) {
|
|
1080
|
+
reportValue("Result", result, failed);
|
|
1081
|
+
}
|
|
1082
|
+
} else if (chosen.kind === "prompt") {
|
|
1083
|
+
result = await client.getPrompt(chosen.item.name, values);
|
|
1084
|
+
(result.messages || []).forEach((message) => {
|
|
1085
|
+
const content = message.content || {};
|
|
1086
|
+
report(message.role, content.text !== undefined ? content.text : content);
|
|
1087
|
+
});
|
|
1088
|
+
} else {
|
|
1089
|
+
const uri =
|
|
1090
|
+
chosen.kind === "resource"
|
|
1091
|
+
? chosen.item.uri
|
|
1092
|
+
: chosen.item.uriTemplate.replace(/\{([^}]+)\}/g, (whole, name) =>
|
|
1093
|
+
values[name] === undefined ? whole : encodeURIComponent(values[name])
|
|
1094
|
+
);
|
|
1095
|
+
result = await client.readResource(uri);
|
|
1096
|
+
(result.contents || []).forEach((part) => {
|
|
1097
|
+
if (part.text === undefined) {
|
|
1098
|
+
reportValue(part.mimeType || "contents", part);
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
const held = parsed(part.text);
|
|
1102
|
+
if (held === undefined || typeof held !== "object") {
|
|
1103
|
+
report(part.mimeType || "contents", part.text);
|
|
1104
|
+
} else {
|
|
1105
|
+
reportValue(part.mimeType || "contents", held);
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
const detail = error.code !== undefined ? ` (${error.code})` : "";
|
|
1111
|
+
report(`Failed${detail}`, error.message, true);
|
|
1112
|
+
if (error.data !== undefined && error.data !== null) {
|
|
1113
|
+
reportValue("Error data", error.data, true);
|
|
1114
|
+
}
|
|
1115
|
+
} finally {
|
|
1116
|
+
page.invoke.disabled = false;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
function disconnect() {
|
|
1122
|
+
// Sessions expire server-side; there is no close request.
|
|
1123
|
+
client = null;
|
|
1124
|
+
chosen = null;
|
|
1125
|
+
page.catalogue.textContent = "";
|
|
1126
|
+
page.catalogue.append(element("p", "empty", "Connect to see what this server offers."));
|
|
1127
|
+
page.subject.textContent = "Nothing selected";
|
|
1128
|
+
page.about.textContent = "";
|
|
1129
|
+
page.args.textContent = "";
|
|
1130
|
+
page.outcome.textContent = "";
|
|
1131
|
+
page.invoke.disabled = true;
|
|
1132
|
+
page.refresh.disabled = true;
|
|
1133
|
+
page.revision.disabled = false;
|
|
1134
|
+
page.answerable.disabled = false;
|
|
1135
|
+
page.connect.textContent = "Connect";
|
|
1136
|
+
say("not connected", "");
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
async function connect() {
|
|
1140
|
+
page.connect.disabled = true;
|
|
1141
|
+
page.revision.disabled = true;
|
|
1142
|
+
page.answerable.disabled = true;
|
|
1143
|
+
say("connecting", "");
|
|
1144
|
+
page.outcome.textContent = "";
|
|
1145
|
+
try {
|
|
1146
|
+
client = new Client(endpointUrl.href, page.revision.value, {
|
|
1147
|
+
answerable: page.answerable.checked,
|
|
1148
|
+
onFrame: record,
|
|
1149
|
+
onNotify: noticed,
|
|
1150
|
+
onQuestion: askPerson,
|
|
1151
|
+
});
|
|
1152
|
+
const described = await client.initialize();
|
|
1153
|
+
await loadCatalogue();
|
|
1154
|
+
page.refresh.disabled = false;
|
|
1155
|
+
const name = (described.serverInfo || (described._meta || {})["io.modelcontextprotocol/serverInfo"] || {}).name;
|
|
1156
|
+
say(`${name || "connected"} · ${client.version}`, "on");
|
|
1157
|
+
page.connect.textContent = "Disconnect";
|
|
1158
|
+
if (described.instructions) {
|
|
1159
|
+
page.subject.textContent = "Instructions";
|
|
1160
|
+
prose(described.instructions, page.about);
|
|
1161
|
+
page.invoke.disabled = true;
|
|
1162
|
+
}
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
client = null;
|
|
1165
|
+
page.refresh.disabled = true;
|
|
1166
|
+
page.revision.disabled = false;
|
|
1167
|
+
page.answerable.disabled = false;
|
|
1168
|
+
page.connect.textContent = "Connect";
|
|
1169
|
+
say(error.message, "off");
|
|
1170
|
+
report("Could not connect", connectionHelp(error), true);
|
|
1171
|
+
} finally {
|
|
1172
|
+
page.connect.disabled = false;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function connectionHelp(error) {
|
|
1177
|
+
const lines = [error.message];
|
|
1178
|
+
if (error instanceof TypeError || /fetch/i.test(error.message)) {
|
|
1179
|
+
lines.push("", `The request to ${endpointUrl.href} did not reach a server.`);
|
|
1180
|
+
}
|
|
1181
|
+
return lines.join("\n");
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
const NARROW = "(max-width: 900px)";
|
|
1186
|
+
|
|
1187
|
+
function folding(panel, toggle, shutWhenNarrow) {
|
|
1188
|
+
const narrow = window.matchMedia(NARROW);
|
|
1189
|
+
let chosenByHand = false;
|
|
1190
|
+
|
|
1191
|
+
const show = (open) => {
|
|
1192
|
+
panel.classList.toggle("shut", !open);
|
|
1193
|
+
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
1194
|
+
if (open) panel.classList.remove("waiting");
|
|
1195
|
+
};
|
|
1196
|
+
|
|
1197
|
+
const settle = (isNarrow) => {
|
|
1198
|
+
chosenByHand = false;
|
|
1199
|
+
show(!isNarrow || !shutWhenNarrow);
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
settle(narrow.matches);
|
|
1203
|
+
|
|
1204
|
+
// Fold on header clicks without intercepting its action buttons.
|
|
1205
|
+
panel.querySelector(".panel-head").onclick = (event) => {
|
|
1206
|
+
if (!narrow.matches) return;
|
|
1207
|
+
const control = event.target.closest("button, input, label, select");
|
|
1208
|
+
if (control && control !== toggle) return;
|
|
1209
|
+
chosenByHand = true;
|
|
1210
|
+
show(panel.classList.contains("shut"));
|
|
1211
|
+
};
|
|
1212
|
+
// Reset layout defaults when the viewport changes.
|
|
1213
|
+
narrow.addEventListener("change", (event) => settle(event.matches));
|
|
1214
|
+
|
|
1215
|
+
return {
|
|
1216
|
+
fold: () => {
|
|
1217
|
+
if (narrow.matches) show(false);
|
|
1218
|
+
},
|
|
1219
|
+
isShut: () => panel.classList.contains("shut"),
|
|
1220
|
+
chosenByHand: () => chosenByHand,
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
let traffic = null;
|
|
1225
|
+
let catalogue = null;
|
|
1226
|
+
|
|
1227
|
+
function foldPanels() {
|
|
1228
|
+
traffic = folding(page.trafficPanel, page.trafficToggle, true);
|
|
1229
|
+
catalogue = folding(page.cataloguePanel, page.catalogueToggle, false);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function markTraffic() {
|
|
1233
|
+
if (!traffic || !traffic.isShut() || traffic.chosenByHand()) return;
|
|
1234
|
+
page.trafficPanel.classList.add("waiting");
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
function start() {
|
|
1239
|
+
Object.keys(REVISIONS).forEach((version) => {
|
|
1240
|
+
const option = element("option", null, version);
|
|
1241
|
+
option.value = version;
|
|
1242
|
+
page.revision.append(option);
|
|
1243
|
+
});
|
|
1244
|
+
const remembered = localStorage.getItem("mcp-console-revision");
|
|
1245
|
+
page.revision.value = REVISIONS[remembered] ? remembered : Object.keys(REVISIONS)[0];
|
|
1246
|
+
|
|
1247
|
+
endpointUrl = new URL(document.documentElement.dataset.endpoint || "/mcp", location.origin);
|
|
1248
|
+
page.endpoint.textContent = endpointUrl.pathname;
|
|
1249
|
+
|
|
1250
|
+
page.connect.onclick = () => (client ? disconnect() : connect());
|
|
1251
|
+
page.refresh.onclick = () => loadCatalogue().catch((error) => say(error.message, "off"));
|
|
1252
|
+
// Call is the form's submit button, though it sits outside the form -- the
|
|
1253
|
+
// `form` attribute says which form it belongs to. That is what makes Enter
|
|
1254
|
+
// in a field call the tool: a form with several fields and no submit button
|
|
1255
|
+
// ignores Enter, which is the rule rather than a quirk. A textarea keeps
|
|
1256
|
+
// Enter for what Enter does in a textarea.
|
|
1257
|
+
page.args.onsubmit = (event) => {
|
|
1258
|
+
event.preventDefault();
|
|
1259
|
+
invoke();
|
|
1260
|
+
};
|
|
1261
|
+
foldPanels();
|
|
1262
|
+
page.clear.onclick = () => {
|
|
1263
|
+
page.traffic.textContent = "";
|
|
1264
|
+
page.traffic.append(element("p", "empty", "Every message, as it goes over the wire."));
|
|
1265
|
+
};
|
|
1266
|
+
page.revision.onchange = () => {
|
|
1267
|
+
localStorage.setItem("mcp-console-revision", page.revision.value);
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
connect();
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
start();
|