@yichus/mcp 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -28
- package/dist/cli.js +344 -115
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +18 -14
- package/dist/index.js +313 -66
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/LICENSE +0 -21
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { parseArgs } from "util";
|
|
5
|
+
import { z as z4 } from "zod";
|
|
6
|
+
|
|
3
7
|
// src/server.ts
|
|
4
|
-
import {
|
|
5
|
-
import { z } from "zod";
|
|
8
|
+
import { z as z3 } from "zod";
|
|
6
9
|
|
|
7
10
|
// src/calculators.ts
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
// src/calculators.json
|
|
14
|
+
var calculators_default = [
|
|
10
15
|
{
|
|
11
16
|
slug: "event-budget",
|
|
12
17
|
title: "Event budget",
|
|
@@ -19,6 +24,12 @@ var CALCULATORS = [
|
|
|
19
24
|
description: "How much water does one rainfall event collect, and how much overflows the tank?",
|
|
20
25
|
path: "demos/simulation/rainwater.html"
|
|
21
26
|
},
|
|
27
|
+
{
|
|
28
|
+
slug: "rainwater-history",
|
|
29
|
+
title: "Rainwater through a dry spell",
|
|
30
|
+
description: "Run NOAA weather observations, or your own daily rainfall CSV, through an inspectable daily tank model. Change capacity and demand, inspect captured data, and share a saved report.",
|
|
31
|
+
path: "demos/simulation/rainwater-history.html"
|
|
32
|
+
},
|
|
22
33
|
{
|
|
23
34
|
slug: "battery-runtime",
|
|
24
35
|
title: "Battery runtime",
|
|
@@ -128,6 +139,16 @@ var CALCULATORS = [
|
|
|
128
139
|
path: "demos/simulation/carbon-abatement.html"
|
|
129
140
|
}
|
|
130
141
|
];
|
|
142
|
+
|
|
143
|
+
// src/calculators.ts
|
|
144
|
+
var CalculatorEntry = z.strictObject({
|
|
145
|
+
slug: z.string(),
|
|
146
|
+
title: z.string(),
|
|
147
|
+
description: z.string(),
|
|
148
|
+
path: z.string()
|
|
149
|
+
});
|
|
150
|
+
var DEFAULT_BASE_URL = "https://yich.us";
|
|
151
|
+
var CALCULATORS = z.array(CalculatorEntry).parse(calculators_default);
|
|
131
152
|
var PLAYGROUND = {
|
|
132
153
|
slug: "playground",
|
|
133
154
|
title: "Yichus Bosatsu Playground",
|
|
@@ -168,44 +189,281 @@ function openUrlInBrowser(rawUrl) {
|
|
|
168
189
|
command = "xdg-open";
|
|
169
190
|
args = [validated];
|
|
170
191
|
}
|
|
171
|
-
|
|
172
|
-
|
|
192
|
+
return new Promise((resolve) => {
|
|
193
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
194
|
+
child.once("spawn", () => {
|
|
195
|
+
child.unref();
|
|
196
|
+
resolve({ command, args, opened: true });
|
|
197
|
+
});
|
|
198
|
+
child.on("error", (error) => resolve({ command, args, opened: false, error: error.message }));
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// src/relay.ts
|
|
203
|
+
import {
|
|
204
|
+
LocalRelayMcpServer,
|
|
205
|
+
RelayBridgeServer,
|
|
206
|
+
RelayRegistry,
|
|
207
|
+
buildPublicToolName
|
|
208
|
+
} from "@mcp-b/webmcp-local-relay";
|
|
209
|
+
var REMEMBERED_TABS = 256;
|
|
210
|
+
var FirstTabNamingRegistry = class extends RelayRegistry {
|
|
211
|
+
firstSeen = /* @__PURE__ */ new Map();
|
|
212
|
+
tabByConnection = /* @__PURE__ */ new Map();
|
|
213
|
+
seen = 0;
|
|
214
|
+
upsertSource(connectionId, hello) {
|
|
215
|
+
super.upsertSource(connectionId, hello);
|
|
216
|
+
this.tabByConnection.set(connectionId, hello.tabId);
|
|
217
|
+
this.remember(hello.tabId);
|
|
218
|
+
}
|
|
219
|
+
removeConnection(connectionId) {
|
|
220
|
+
super.removeConnection(connectionId);
|
|
221
|
+
this.tabByConnection.delete(connectionId);
|
|
222
|
+
}
|
|
223
|
+
listTools() {
|
|
224
|
+
return this.named().map(({ tool }) => tool).sort((a, b) => a.name.localeCompare(b.name));
|
|
225
|
+
}
|
|
226
|
+
resolveInvocation(options) {
|
|
227
|
+
const entry = this.named().find(({ tool }) => tool.name === options.toolName);
|
|
228
|
+
if (!entry) return null;
|
|
229
|
+
const resolved = super.resolveInvocation({ ...options, toolName: entry.relayName });
|
|
230
|
+
return resolved && { ...resolved, publicToolName: entry.tool.name };
|
|
231
|
+
}
|
|
232
|
+
// Each relay tool with the name an agent sees: the plain name for the
|
|
233
|
+
// copy published by the first-seen tab among those publishing it.
|
|
234
|
+
named() {
|
|
235
|
+
const tools = super.listTools();
|
|
236
|
+
const owners = /* @__PURE__ */ new Map();
|
|
237
|
+
for (const tool of tools) {
|
|
238
|
+
for (const source of tool.sources) {
|
|
239
|
+
const order = this.order(source.tabId);
|
|
240
|
+
owners.set(tool.originalName, Math.min(owners.get(tool.originalName) ?? order, order));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return tools.map((tool) => {
|
|
244
|
+
const owned = tool.sources.some((source) => this.order(source.tabId) === owners.get(tool.originalName));
|
|
245
|
+
const name = owned ? buildPublicToolName({ originalToolName: tool.originalName }) : tool.name;
|
|
246
|
+
return { relayName: tool.name, tool: { ...tool, name } };
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
order(tabId) {
|
|
250
|
+
return this.firstSeen.get(tabId) ?? Number.POSITIVE_INFINITY;
|
|
251
|
+
}
|
|
252
|
+
remember(tabId) {
|
|
253
|
+
if (this.firstSeen.has(tabId)) return;
|
|
254
|
+
this.firstSeen.set(tabId, this.seen++);
|
|
255
|
+
const open = new Set(this.tabByConnection.values());
|
|
256
|
+
for (const tab of this.firstSeen.keys()) {
|
|
257
|
+
if (this.firstSeen.size - open.size <= REMEMBERED_TABS) break;
|
|
258
|
+
if (!open.has(tab)) this.firstSeen.delete(tab);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
var TOOL_LIST_CHANGED = "notifications/tools/list_changed";
|
|
263
|
+
var isToolListChanged = (message) => "method" in message && message.method === TOOL_LIST_CHANGED && !("id" in message);
|
|
264
|
+
var ToolListChangedCoalescer = class {
|
|
265
|
+
constructor(inner, windowMs) {
|
|
266
|
+
this.inner = inner;
|
|
267
|
+
this.windowMs = windowMs;
|
|
268
|
+
inner.onmessage = (message, extra) => this.onmessage?.(message, extra);
|
|
269
|
+
inner.onerror = (error) => this.onerror?.(error);
|
|
270
|
+
inner.onclose = () => {
|
|
271
|
+
this.drop();
|
|
272
|
+
this.onclose?.();
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
inner;
|
|
276
|
+
windowMs;
|
|
277
|
+
onclose;
|
|
278
|
+
onerror;
|
|
279
|
+
onmessage;
|
|
280
|
+
pending = null;
|
|
281
|
+
get sessionId() {
|
|
282
|
+
return this.inner.sessionId;
|
|
283
|
+
}
|
|
284
|
+
setProtocolVersion(version) {
|
|
285
|
+
this.inner.setProtocolVersion?.(version);
|
|
286
|
+
}
|
|
287
|
+
start() {
|
|
288
|
+
return this.inner.start();
|
|
289
|
+
}
|
|
290
|
+
async send(message, options) {
|
|
291
|
+
if (!isToolListChanged(message)) return this.inner.send(message, options);
|
|
292
|
+
if (this.pending) return;
|
|
293
|
+
this.pending = setTimeout(() => {
|
|
294
|
+
this.pending = null;
|
|
295
|
+
this.inner.send(message, options).catch((error) => this.onerror?.(error));
|
|
296
|
+
}, this.windowMs);
|
|
297
|
+
this.pending.unref?.();
|
|
298
|
+
}
|
|
299
|
+
close() {
|
|
300
|
+
this.drop();
|
|
301
|
+
return this.inner.close();
|
|
302
|
+
}
|
|
303
|
+
drop() {
|
|
304
|
+
if (this.pending) clearTimeout(this.pending);
|
|
305
|
+
this.pending = null;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
var CoalescingRelayServer = class extends LocalRelayMcpServer {
|
|
309
|
+
constructor(options, windowMs) {
|
|
310
|
+
super(options);
|
|
311
|
+
this.windowMs = windowMs;
|
|
312
|
+
}
|
|
313
|
+
windowMs;
|
|
314
|
+
connect(transport) {
|
|
315
|
+
return super.connect(new ToolListChangedCoalescer(transport, this.windowMs));
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
var PageAnswerBridge = class extends RelayBridgeServer {
|
|
319
|
+
pageContent = /* @__PURE__ */ new WeakMap();
|
|
320
|
+
async invokeTool(...call) {
|
|
321
|
+
const result = await super.invokeTool(...call);
|
|
322
|
+
this.pageContent.set(result, result.content);
|
|
323
|
+
return result;
|
|
324
|
+
}
|
|
325
|
+
/** The content a page answered with, for an answer this bridge carried. */
|
|
326
|
+
pageAnswer(result) {
|
|
327
|
+
return this.pageContent.get(result);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// src/saveCalculator.ts
|
|
332
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
333
|
+
import { isAbsolute, join } from "path";
|
|
334
|
+
import { z as z2 } from "zod";
|
|
335
|
+
var saveCalculatorInput = z2.object({
|
|
336
|
+
exportToolName: z2.string().regex(/^yichus_example_export(?:_[a-zA-Z0-9_-]+)?$/).describe("Public export tool name from webmcp_list_tools, matched to the intended tab."),
|
|
337
|
+
exampleId: z2.string().min(1),
|
|
338
|
+
expectedRevision: z2.number().int().nonnegative(),
|
|
339
|
+
outputDirectory: z2.string().refine(isAbsolute, "Use an absolute output directory.").describe("A new directory under an existing parent. Existing directories are refused; files are never overwritten.")
|
|
340
|
+
});
|
|
341
|
+
var chunkSchema = z2.object({
|
|
342
|
+
exampleId: z2.string(),
|
|
343
|
+
revision: z2.number().int(),
|
|
344
|
+
exportId: z2.string().min(1),
|
|
345
|
+
assetBaseUrl: z2.url(),
|
|
346
|
+
selfContained: z2.boolean().default(false),
|
|
347
|
+
sources: z2.array(z2.object({ fileName: z2.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*\.bosatsu$/), source: z2.string().max(1e6) })).min(1).max(16),
|
|
348
|
+
html: z2.object({
|
|
349
|
+
fileName: z2.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*\.html$/),
|
|
350
|
+
content: z2.string().min(1).max(32768),
|
|
351
|
+
offset: z2.number().int().nonnegative(),
|
|
352
|
+
totalLength: z2.number().int().positive().max(32 * 1024 * 1024),
|
|
353
|
+
nextOffset: z2.number().int().nonnegative().nullable()
|
|
354
|
+
})
|
|
355
|
+
});
|
|
356
|
+
async function saveCalculator(rawArgs, invoke) {
|
|
357
|
+
const args = saveCalculatorInput.parse(rawArgs);
|
|
358
|
+
const content = [];
|
|
359
|
+
let first;
|
|
360
|
+
let offset = 0;
|
|
361
|
+
for (; ; ) {
|
|
362
|
+
const response = z2.object({ isError: z2.boolean().optional(), structuredContent: z2.unknown().optional() }).parse(await invoke(args.exportToolName, {
|
|
363
|
+
exampleId: args.exampleId,
|
|
364
|
+
expectedRevision: args.expectedRevision,
|
|
365
|
+
offset,
|
|
366
|
+
...first ? { expectedExportId: first.exportId } : {}
|
|
367
|
+
}));
|
|
368
|
+
if (response.isError) throw new Error("Browser export failed. Read the current example feedback before retrying.");
|
|
369
|
+
const chunk = chunkSchema.parse(response.structuredContent);
|
|
370
|
+
if (chunk.exampleId !== args.exampleId || chunk.revision !== args.expectedRevision || chunk.html.offset !== offset) throw new Error("Export does not match the requested example, revision, or offset.");
|
|
371
|
+
if (first && (chunk.exportId !== first.exportId || chunk.assetBaseUrl !== first.assetBaseUrl || chunk.selfContained !== first.selfContained || chunk.html.fileName !== first.html.fileName || chunk.html.totalLength !== first.html.totalLength || JSON.stringify(chunk.sources) !== JSON.stringify(first.sources))) throw new Error("Export changed during transfer. Nothing was saved; start again.");
|
|
372
|
+
first ??= chunk;
|
|
373
|
+
const end = offset + chunk.html.content.length;
|
|
374
|
+
if (end > chunk.html.totalLength || (chunk.html.nextOffset === null ? end !== chunk.html.totalLength : chunk.html.nextOffset !== end || end >= chunk.html.totalLength)) throw new Error("Incomplete or inconsistent export chunk. Nothing was saved.");
|
|
375
|
+
content.push(chunk.html.content);
|
|
376
|
+
if (chunk.html.nextOffset === null) break;
|
|
377
|
+
offset = chunk.html.nextOffset;
|
|
378
|
+
}
|
|
379
|
+
const files = [...first.sources.map((s) => ({ fileName: s.fileName, content: s.source })), { fileName: first.html.fileName, content: content.join("") }];
|
|
380
|
+
if (new Set(files.map((f) => f.fileName.toLowerCase())).size !== files.length) throw new Error("Export file names must be unique.");
|
|
381
|
+
await mkdir(args.outputDirectory);
|
|
382
|
+
for (const file of files) await writeFile(join(args.outputDirectory, file.fileName), file.content, { encoding: "utf8", flag: "wx" });
|
|
383
|
+
return {
|
|
384
|
+
ok: true,
|
|
385
|
+
directory: args.outputDirectory,
|
|
386
|
+
revision: first.revision,
|
|
387
|
+
exportId: first.exportId,
|
|
388
|
+
...first.selfContained ? {} : { assetBaseUrl: first.assetBaseUrl },
|
|
389
|
+
files: files.map((f) => ({ fileName: f.fileName, path: join(args.outputDirectory, f.fileName), bytes: Buffer.byteLength(f.content) })),
|
|
390
|
+
htmlPath: join(args.outputDirectory, first.html.fileName),
|
|
391
|
+
note: first.selfContained ? "The HTML starts from configured defaults and carries its own scripts and styles, so it can be hosted anywhere and calls no Yichus server. Linking an input to a formula loads the formula compiler from jsDelivr on first use. No program was executed." : "The HTML starts from configured defaults and loads supporting assets from the site. It requires network access. No program was executed."
|
|
392
|
+
};
|
|
173
393
|
}
|
|
174
394
|
|
|
175
395
|
// src/server.ts
|
|
176
396
|
var PACKAGE_NAME = "yichus-mcp";
|
|
177
|
-
var PACKAGE_VERSION = "0.1.
|
|
397
|
+
var PACKAGE_VERSION = "0.1.2";
|
|
178
398
|
function createYichusMcpServer(options = {}) {
|
|
179
399
|
const baseUrl = normalizeBaseUrl(options.baseUrl ?? "https://yich.us");
|
|
180
400
|
const bridgeOverrides = options.bridgeOverrides ?? {};
|
|
181
|
-
const
|
|
401
|
+
const bridge = new PageAnswerBridge({
|
|
402
|
+
host: bridgeOverrides.host,
|
|
403
|
+
port: bridgeOverrides.port,
|
|
404
|
+
label: bridgeOverrides.label ?? "Yichus",
|
|
405
|
+
// The pair every page hints the widget (web/webmcp-autocomplete.js,
|
|
406
|
+
// the generated calculators); the widget rejects any other hello.
|
|
407
|
+
workspace: bridgeOverrides.workspace ?? "yichus",
|
|
408
|
+
relayId: bridgeOverrides.relayId ?? "yichus",
|
|
409
|
+
allowedOrigins: [baseUrl],
|
|
410
|
+
// Cold browser compilation can outlast the relay's 25-second default.
|
|
411
|
+
// Keep a deadline so an unresponsive tab still fails explicitly.
|
|
412
|
+
invokeTimeoutMs: 12e4
|
|
413
|
+
}, new FirstTabNamingRegistry());
|
|
414
|
+
const server = new CoalescingRelayServer({
|
|
182
415
|
serverName: PACKAGE_NAME,
|
|
183
416
|
serverVersion: PACKAGE_VERSION,
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
port: bridgeOverrides.port,
|
|
187
|
-
label: bridgeOverrides.label ?? "Yichus",
|
|
188
|
-
// The pair every page hints the widget (web/webmcp-autocomplete.js,
|
|
189
|
-
// the generated calculators); the widget rejects any other hello.
|
|
190
|
-
workspace: bridgeOverrides.workspace ?? "yichus",
|
|
191
|
-
relayId: bridgeOverrides.relayId ?? "yichus",
|
|
192
|
-
allowedOrigins: [baseUrl]
|
|
193
|
-
}
|
|
194
|
-
});
|
|
417
|
+
bridge
|
|
418
|
+
}, TOOL_LIST_CHANGED_WINDOW_MS);
|
|
195
419
|
registerYichusTools(server, { baseUrl, disableOpener: options.disableOpener ?? false });
|
|
420
|
+
answerRefusalsAsThePageWroteThem(server, bridge);
|
|
196
421
|
return server;
|
|
197
422
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
423
|
+
var relayInstructions = (baseUrl) => `Yichus in the browser: the tools here come from ${new URL(baseUrl).host} pages open in the person's browser, such as a calculator, the playground or the workbench. yichus_list_calculators and yichus_open_calculator open one, webmcp_list_tools lists what the open tabs offer, and each page's tools say what they do. Use them to explore a page with the person and explain what it shows.
|
|
424
|
+
|
|
425
|
+
To build the person's own app with them, use the Yichus studio instead. It is its own MCP server, run in their project folder (npx -y yichus@0.3.1 studio). It asks the person the product questions, checks the program after every change, and keeps a page open where you can point at what you mean while you talk.`;
|
|
426
|
+
var TOOL_LIST_CHANGED_WINDOW_MS = 100;
|
|
427
|
+
var isFunction = (field) => typeof field === "function";
|
|
428
|
+
var StaticToolFields = z3.object({
|
|
429
|
+
mcpServer: z3.object({
|
|
430
|
+
// The MCP SDK's Server keeps the handshake instructions here; the relay
|
|
431
|
+
// takes none of its own. server.test reads them back from initialize, so
|
|
432
|
+
// a release that moves them fails there.
|
|
433
|
+
server: z3.custom((value) => typeof value === "object" && value !== null && "_instructions" in value),
|
|
434
|
+
registerTool: z3.custom(isFunction),
|
|
435
|
+
// The MCP SDK's record of each registered tool; the relay's
|
|
436
|
+
// webmcp_call_tool is wrapped there (answerRefusalsAsThePageWroteThem).
|
|
437
|
+
_registeredTools: z3.object({ webmcp_call_tool: z3.object({ handler: z3.custom(isFunction) }) })
|
|
438
|
+
}),
|
|
439
|
+
staticToolData: z3.instanceof(Map)
|
|
440
|
+
});
|
|
441
|
+
var StaticToolHost = z3.custom(
|
|
442
|
+
(value) => StaticToolFields.safeParse(value).success,
|
|
443
|
+
{ error: "@mcp-b/webmcp-local-relay no longer has mcpServer.registerTool, a registered webmcp_call_tool handler, an SDK server with instructions and staticToolData" }
|
|
444
|
+
);
|
|
201
445
|
function registerYichusTools(server, ctx) {
|
|
202
|
-
const
|
|
203
|
-
|
|
446
|
+
const host = StaticToolHost.parse(server);
|
|
447
|
+
host.mcpServer.server._instructions = relayInstructions(ctx.baseUrl);
|
|
448
|
+
registerStatic(host, {
|
|
449
|
+
name: "yichus_save_calculator",
|
|
450
|
+
description: "Save a browser-compiled example and its Bosatsu sources to a NEW local directory. Pass the public yichus_example_export tool name for the intended tab and its current source revision. Transfers bounded chunks internally and returns file paths, not generated HTML. Does not execute the program. Requires the browser tab to stay open.",
|
|
451
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
452
|
+
input: saveCalculatorInput,
|
|
453
|
+
handler: async (args) => {
|
|
454
|
+
try {
|
|
455
|
+
const payload = await saveCalculator(args, (name, params) => server.bridge.invokeTool(name, params));
|
|
456
|
+
return { content: [{ type: "text", text: JSON.stringify(payload) }], structuredContent: payload };
|
|
457
|
+
} catch (error) {
|
|
458
|
+
return { isError: true, content: [{ type: "text", text: String(error) }] };
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
registerStatic(host, {
|
|
204
463
|
name: "yichus_list_calculators",
|
|
205
464
|
description: "List the curated Yichus calculator demos and the playground editor available on the configured Yichus origin. Returns a slug, title, description, and absolute URL for each.",
|
|
206
465
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
207
|
-
|
|
208
|
-
inputSchemaZod: {},
|
|
466
|
+
input: z3.object({}),
|
|
209
467
|
handler: async () => {
|
|
210
468
|
const entries = listCalculators().map((calc) => ({
|
|
211
469
|
slug: calc.slug,
|
|
@@ -220,24 +478,16 @@ function registerYichusTools(server, ctx) {
|
|
|
220
478
|
};
|
|
221
479
|
}
|
|
222
480
|
});
|
|
223
|
-
registerStatic(
|
|
481
|
+
registerStatic(host, {
|
|
224
482
|
name: "yichus_open_calculator",
|
|
225
483
|
description: "Open a curated Yichus calculator (or the playground) in the user's default browser with WebMCP enabled (?webmcp=1). After opening, the relay tools below will see the page's tools (yichus_list_inputs, yichus_set_inputs, yichus_list_outputs, yichus_explain_output for calculators; yichus_playground_* for the playground). Use yichus_list_calculators to discover slugs.",
|
|
226
484
|
annotations: { openWorldHint: true },
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
},
|
|
235
|
-
required: ["slug"],
|
|
236
|
-
additionalProperties: false
|
|
237
|
-
},
|
|
238
|
-
inputSchemaZod: { slug: z.string() },
|
|
239
|
-
handler: async (rawArgs) => {
|
|
240
|
-
const args = z.object({ slug: z.string() }).parse(rawArgs);
|
|
485
|
+
input: z3.object({
|
|
486
|
+
slug: z3.string().describe(
|
|
487
|
+
'Calculator slug from yichus_list_calculators (for example "loan", "investment", "tax", "playground").'
|
|
488
|
+
)
|
|
489
|
+
}),
|
|
490
|
+
handler: async (args) => {
|
|
241
491
|
const calc = findCalculator(args.slug);
|
|
242
492
|
if (!calc) {
|
|
243
493
|
const known = listCalculators().map((c) => c.slug).join(", ");
|
|
@@ -252,10 +502,7 @@ function registerYichusTools(server, ctx) {
|
|
|
252
502
|
};
|
|
253
503
|
}
|
|
254
504
|
const url = withWebMcpFlag(calculatorUrl(calc, ctx.baseUrl));
|
|
255
|
-
|
|
256
|
-
if (!ctx.disableOpener) {
|
|
257
|
-
opener = openUrlInBrowser(url);
|
|
258
|
-
}
|
|
505
|
+
const attempt = ctx.disableOpener ? null : await openUrlInBrowser(url);
|
|
259
506
|
const payload = {
|
|
260
507
|
calculator: {
|
|
261
508
|
slug: calc.slug,
|
|
@@ -263,35 +510,39 @@ function registerYichusTools(server, ctx) {
|
|
|
263
510
|
description: calc.description
|
|
264
511
|
},
|
|
265
512
|
url,
|
|
266
|
-
opened:
|
|
267
|
-
opener
|
|
513
|
+
opened: attempt?.opened ?? false,
|
|
514
|
+
opener: attempt && { command: attempt.command, args: attempt.args },
|
|
515
|
+
...attempt?.error === void 0 ? {} : { openerError: attempt.error }
|
|
268
516
|
};
|
|
269
517
|
return {
|
|
270
|
-
content: [
|
|
271
|
-
{
|
|
272
|
-
type: "text",
|
|
273
|
-
text: ctx.disableOpener ? `Would open ${calc.title} at ${url} (browser opener disabled).` : `Opening ${calc.title} at ${url}.`
|
|
274
|
-
}
|
|
275
|
-
],
|
|
518
|
+
content: [{ type: "text", text: openAnswer(calc, url, attempt) }],
|
|
276
519
|
structuredContent: payload
|
|
277
520
|
};
|
|
278
521
|
}
|
|
279
522
|
});
|
|
280
523
|
}
|
|
281
|
-
function
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
524
|
+
function openAnswer(calc, url, attempt) {
|
|
525
|
+
if (attempt === null) return `Would open ${calc.title} at ${url} (browser opener disabled).`;
|
|
526
|
+
if (attempt.opened) return `Opening ${calc.title} at ${url}.`;
|
|
527
|
+
return `Could not open a browser on this machine (${attempt.error}). Open this link in a browser yourself to load ${calc.title}: ${url}`;
|
|
528
|
+
}
|
|
529
|
+
function answerRefusalsAsThePageWroteThem(server, bridge) {
|
|
530
|
+
const callTool = StaticToolHost.parse(server).mcpServer._registeredTools.webmcp_call_tool;
|
|
531
|
+
const relayHandler = callTool.handler;
|
|
532
|
+
callTool.handler = async (...call) => {
|
|
533
|
+
const result = await relayHandler(...call);
|
|
534
|
+
const page = bridge.pageAnswer(result);
|
|
535
|
+
return result.isError === true && page !== void 0 ? { ...result, content: page } : result;
|
|
285
536
|
};
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
name
|
|
292
|
-
description
|
|
293
|
-
inputSchema:
|
|
294
|
-
|
|
537
|
+
}
|
|
538
|
+
function registerStatic(host, spec) {
|
|
539
|
+
const { name, description, annotations, input, handler } = spec;
|
|
540
|
+
host.mcpServer.registerTool(name, { description, inputSchema: input.shape, annotations }, handler);
|
|
541
|
+
host.staticToolData.set(name, {
|
|
542
|
+
name,
|
|
543
|
+
description,
|
|
544
|
+
inputSchema: z3.toJSONSchema(input, { unrepresentable: "any" }),
|
|
545
|
+
annotations
|
|
295
546
|
});
|
|
296
547
|
}
|
|
297
548
|
function normalizeBaseUrl(raw) {
|
|
@@ -299,59 +550,37 @@ function normalizeBaseUrl(raw) {
|
|
|
299
550
|
}
|
|
300
551
|
|
|
301
552
|
// src/cli.ts
|
|
553
|
+
var Port = z4.coerce.number().int().min(1).max(65535);
|
|
302
554
|
function parseFlags(argv) {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
case "--base-url":
|
|
316
|
-
flags.baseUrl = next();
|
|
317
|
-
break;
|
|
318
|
-
case "--host":
|
|
319
|
-
flags.host = next();
|
|
320
|
-
break;
|
|
321
|
-
case "--port": {
|
|
322
|
-
const value = next();
|
|
323
|
-
const parsed = Number.parseInt(value, 10);
|
|
324
|
-
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
|
325
|
-
throw new Error(`Invalid --port value: ${value}`);
|
|
326
|
-
}
|
|
327
|
-
flags.port = parsed;
|
|
328
|
-
break;
|
|
329
|
-
}
|
|
330
|
-
case "--label":
|
|
331
|
-
flags.label = next();
|
|
332
|
-
break;
|
|
333
|
-
case "--workspace":
|
|
334
|
-
flags.workspace = next();
|
|
335
|
-
break;
|
|
336
|
-
case "--relay-id":
|
|
337
|
-
flags.relayId = next();
|
|
338
|
-
break;
|
|
339
|
-
case "--help":
|
|
340
|
-
case "-h":
|
|
341
|
-
printHelp();
|
|
342
|
-
process.exit(0);
|
|
343
|
-
// eslint-disable-next-line no-fallthrough
|
|
344
|
-
default:
|
|
345
|
-
throw new Error(`Unknown flag: ${arg}`);
|
|
346
|
-
}
|
|
555
|
+
const { values } = parseArgs({ args: argv, strict: true, options: {
|
|
556
|
+
"base-url": { type: "string" },
|
|
557
|
+
host: { type: "string" },
|
|
558
|
+
port: { type: "string" },
|
|
559
|
+
label: { type: "string" },
|
|
560
|
+
workspace: { type: "string" },
|
|
561
|
+
"relay-id": { type: "string" },
|
|
562
|
+
help: { type: "boolean", short: "h" }
|
|
563
|
+
} });
|
|
564
|
+
if (values.help) {
|
|
565
|
+
printHelp();
|
|
566
|
+
process.exit(0);
|
|
347
567
|
}
|
|
348
|
-
|
|
568
|
+
const port = values.port === void 0 ? void 0 : Port.safeParse(values.port);
|
|
569
|
+
if (port && !port.success) throw new Error(`Invalid --port value: ${values.port}`);
|
|
570
|
+
return {
|
|
571
|
+
baseUrl: values["base-url"],
|
|
572
|
+
host: values.host,
|
|
573
|
+
port: port?.data,
|
|
574
|
+
label: values.label,
|
|
575
|
+
workspace: values.workspace,
|
|
576
|
+
relayId: values["relay-id"]
|
|
577
|
+
};
|
|
349
578
|
}
|
|
350
579
|
function printHelp() {
|
|
351
580
|
process.stdout.write(`yichus-mcp [options]
|
|
352
581
|
|
|
353
|
-
Yichus MCP server: bundles the WebMCP local relay with
|
|
354
|
-
|
|
582
|
+
Yichus MCP server: bundles the WebMCP local relay with three static tools
|
|
583
|
+
(yichus_list_calculators, yichus_open_calculator, yichus_save_calculator) so an MCP client
|
|
355
584
|
can drive any deployed Yichus calculator end to end.
|
|
356
585
|
|
|
357
586
|
Options:
|
|
@@ -369,13 +598,13 @@ Options:
|
|
|
369
598
|
built to hint the same values.
|
|
370
599
|
--help, -h Show this help.
|
|
371
600
|
|
|
372
|
-
Add to your MCP client config
|
|
601
|
+
Add to your MCP client config:
|
|
373
602
|
|
|
374
603
|
{
|
|
375
604
|
"mcpServers": {
|
|
376
605
|
"yichus": {
|
|
377
606
|
"command": "npx",
|
|
378
|
-
"args": ["-y", "@yichus/mcp"]
|
|
607
|
+
"args": ["-y", "@yichus/mcp@0.1.2"]
|
|
379
608
|
}
|
|
380
609
|
}
|
|
381
610
|
}
|