@bike4mind/cli 3.4.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -37
- package/bin/bike4mind-cli.mjs +55 -1
- package/bin/hearth-hook.mjs +53 -7
- package/dist/{AgentHistoryStore-ClO2zOn6.mjs → AgentHistoryStore-DgqmcVLN.mjs} +5429 -5543
- package/dist/ApiClient-_JrM_xWw.mjs +133 -0
- package/dist/{ConfigStore-CbVFLXMH.mjs → ConfigStore-BwqsQ0U9.mjs} +1254 -182
- package/dist/RemoteSkillSource-qTvRkaNA.mjs +455 -0
- package/dist/{WorkItemsClient-SeNbzvEw.mjs → WorkItemsClient-CERTDrhT.mjs} +1 -1
- package/dist/{buildAgent-BeaVbpzH.mjs → buildToolSearch-D9GEK91u.mjs} +406 -47
- package/dist/commands/acpCommand.mjs +13 -13
- package/dist/commands/apiCommand.mjs +1 -1
- package/dist/commands/doctorCommand.mjs +1 -1
- package/dist/commands/envCommand.mjs +1 -1
- package/dist/commands/headlessCommand.mjs +5 -4
- package/dist/commands/mcpCommand.mjs +4 -3
- package/dist/commands/pluginCommand.mjs +1 -1
- package/dist/commands/skillsCommand.mjs +259 -0
- package/dist/commands/updateCommand.mjs +1 -1
- package/dist/{dist-DlBq3wHu.mjs → dist-Ul2qwn5x.mjs} +17 -132
- package/dist/index.mjs +171 -48
- package/dist/{package-MoDZ9cGj.mjs → package-CQaam4Dq.mjs} +1 -1
- package/dist/{serve-zi-B2iUT.mjs → serve-CFxUhjml.mjs} +657 -27
- package/dist/store-BYeE0shc.mjs +2 -0
- package/dist/{store-BxmPEI3v.mjs → store-BbvSi3KK.mjs} +26 -2
- package/package.json +11 -10
- package/dist/store-BkMur6uk.mjs +0 -2
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { Bt as supportedVoiceGenerationVendor, D as AGENT_QUEST_MANIFEST, E as AGENT_QUEST_ID, F as BRIEFCASE_CATALOG_QUERIES, H as CreateArtifactRequestSchema, Ht as ttsBase64ResponseSchema, J as ImageModels, L as BriefcasePromptIdSchema, N as ArtifactTypeSchema, Nt as questReplyText, O as AGENT_QUEST_MCP_URI, Pt as replacePromptVariables, St as generatedAudioResponseSchema, U as DEFAULT_TTS_PROVIDER, Ut as ttsRequestSchema, Wt as ttsResponseTooLargeSchema, Y as ImagePromptResolutionSchema, _t as buildPromptContext, b as logger, j as ApiKeyScope, jt as joinReplySlots, p as requireApiUrl, pt as VisibilitySchema, q as GENERATED_IMAGE_EXTENSION_RE, rt as PROMPT_TEXT_MAX, t as ConfigStore, v as NotAuthenticatedError, y as parseApiUrl } from "./ConfigStore-BwqsQ0U9.mjs";
|
|
3
|
+
import { n as isProviderKeyFailure, t as ApiClient } from "./ApiClient-_JrM_xWw.mjs";
|
|
4
|
+
import { i as parseRetryAfterSeconds, r as createClient, t as B4mApiError } from "./dist-Ul2qwn5x.mjs";
|
|
4
5
|
import { z as z$1 } from "zod";
|
|
5
6
|
import { isAxiosError } from "axios";
|
|
6
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
7
8
|
import { Writable } from "node:stream";
|
|
8
9
|
import { createServer } from "node:http";
|
|
9
10
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -13,6 +14,82 @@ import { ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, McpError }
|
|
|
13
14
|
//#region src/mcp/b4mApiClient.ts
|
|
14
15
|
const NOTEBOOK_ID_PATTERN = /^[a-f0-9]{24}$/i;
|
|
15
16
|
const STOP_REPLY_TIMEOUT_MS = 1e4;
|
|
17
|
+
/** Only the route is known here: the solver roster and limits are fetched at runtime. */
|
|
18
|
+
const SOLVER_ROUTE = "/api/premium-optihashi/quantum/solve";
|
|
19
|
+
/**
|
|
20
|
+
* The catalog and the key-scope lookup run before the stdio handshake completes, so they must not
|
|
21
|
+
* inherit the client's 10-minute default: a hung backend would otherwise stall `initialize` for every
|
|
22
|
+
* user, entitled or not.
|
|
23
|
+
*/
|
|
24
|
+
const SOLVER_CATALOG_TIMEOUT_MS = 5e3;
|
|
25
|
+
/** Bob's routes. Bob is deployed apart from the host, so a server may not serve them. */
|
|
26
|
+
const BOB_ROUTE = "/api/premium-bob";
|
|
27
|
+
/**
|
|
28
|
+
* One Bob run as `GET bob-run?id=&view=agent` answers it. Every field the tools read is required or
|
|
29
|
+
* nullable here, so a server that ignores `view=agent` (and answers the app's own shape) fails the
|
|
30
|
+
* parse rather than reading as a run with no report.
|
|
31
|
+
*/
|
|
32
|
+
const bobAgentRunSchema = z$1.looseObject({
|
|
33
|
+
runId: z$1.string(),
|
|
34
|
+
url: z$1.string(),
|
|
35
|
+
status: z$1.enum([
|
|
36
|
+
"reading",
|
|
37
|
+
"ok",
|
|
38
|
+
"failed"
|
|
39
|
+
]),
|
|
40
|
+
progress: z$1.object({
|
|
41
|
+
done: z$1.number(),
|
|
42
|
+
total: z$1.number()
|
|
43
|
+
}).nullish(),
|
|
44
|
+
createdAt: z$1.string(),
|
|
45
|
+
settledAt: z$1.string().nullish(),
|
|
46
|
+
error: z$1.string().nullish(),
|
|
47
|
+
scanOnly: z$1.string().nullish(),
|
|
48
|
+
reportMarkdown: z$1.string().nullish(),
|
|
49
|
+
appPath: z$1.string(),
|
|
50
|
+
publishedPath: z$1.string().nullish(),
|
|
51
|
+
visibility: z$1.string().nullish()
|
|
52
|
+
});
|
|
53
|
+
const bobAgentComparisonSchema = z$1.looseObject({
|
|
54
|
+
comparisonId: z$1.string(),
|
|
55
|
+
status: z$1.enum([
|
|
56
|
+
"reading",
|
|
57
|
+
"ok",
|
|
58
|
+
"failed"
|
|
59
|
+
]),
|
|
60
|
+
you: bobAgentRunSchema.nullable(),
|
|
61
|
+
them: bobAgentRunSchema.nullable(),
|
|
62
|
+
summaryMarkdown: z$1.string().nullish(),
|
|
63
|
+
appPath: z$1.string()
|
|
64
|
+
});
|
|
65
|
+
/**
|
|
66
|
+
* One row of `GET bob-run?list=recent`: a single-site run or a two-site comparison (`you` and
|
|
67
|
+
* `them` are the two URLs). A server that predates comparison rows sends runs with no `kind`.
|
|
68
|
+
*/
|
|
69
|
+
const bobRecentRowSchema = z$1.union([z$1.looseObject({
|
|
70
|
+
kind: z$1.literal("run").optional(),
|
|
71
|
+
runId: z$1.string(),
|
|
72
|
+
url: z$1.string(),
|
|
73
|
+
createdAt: z$1.string().nullish()
|
|
74
|
+
}), z$1.looseObject({
|
|
75
|
+
kind: z$1.literal("comparison"),
|
|
76
|
+
comparisonId: z$1.string(),
|
|
77
|
+
you: z$1.string(),
|
|
78
|
+
them: z$1.string(),
|
|
79
|
+
createdAt: z$1.string().nullish()
|
|
80
|
+
})]);
|
|
81
|
+
const bobRecentListSchema = z$1.object({ runs: z$1.array(z$1.unknown()) });
|
|
82
|
+
/** A start's 200 refusal: the daily cap or a credits refusal, in Bob's words. */
|
|
83
|
+
const bobRefusalSchema = z$1.object({
|
|
84
|
+
ok: z$1.literal(false),
|
|
85
|
+
error: z$1.string()
|
|
86
|
+
});
|
|
87
|
+
const bobOrganizationField = (organizationId) => organizationId === void 0 ? {} : { organizationId };
|
|
88
|
+
const keyScopeListSchema = z$1.array(z$1.object({
|
|
89
|
+
keyPrefix: z$1.string().optional(),
|
|
90
|
+
scopes: z$1.array(z$1.string()).optional(),
|
|
91
|
+
status: z$1.string().optional()
|
|
92
|
+
}));
|
|
16
93
|
/**
|
|
17
94
|
* An empty or dot-segment id collapses `/api/sessions/{id}` to `/api/sessions`, whose DELETE wipes every
|
|
18
95
|
* notebook the caller owns, so write paths refuse anything that is not an ObjectId before any request.
|
|
@@ -36,6 +113,7 @@ function writableNotebookId(notebookId) {
|
|
|
36
113
|
var B4mApiClient = class {
|
|
37
114
|
constructor(baseURL, configStore, apiKey) {
|
|
38
115
|
this.baseURL = baseURL;
|
|
116
|
+
this.apiKey = apiKey;
|
|
39
117
|
this.client = new ApiClient(baseURL, configStore, apiKey);
|
|
40
118
|
this.sdk = createClient({
|
|
41
119
|
baseUrl: baseURL,
|
|
@@ -293,7 +371,99 @@ var B4mApiClient = class {
|
|
|
293
371
|
async getBriefcasePrompt(promptId) {
|
|
294
372
|
return (await this.client.get(`/api/briefcase/prompts/${encodeURIComponent(promptId)}`)).prompt;
|
|
295
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* Scopes held by the API key this client authenticates with, read from the caller's own key list by
|
|
376
|
+
* matching the key's stored prefix. Undefined for a login (JWT) caller, who has no scopes to check;
|
|
377
|
+
* an empty list when the key is not found or cannot be told apart from another, so a caller that cannot be
|
|
378
|
+
* identified is never treated as scoped. Stored prefixes differ in length (older keys keep a shorter one),
|
|
379
|
+
* and the list can include other keys, so the longest matching prefix wins and a tie at that length is refused.
|
|
380
|
+
*/
|
|
381
|
+
async getOwnKeyScopes() {
|
|
382
|
+
const apiKey = this.apiKey;
|
|
383
|
+
if (!apiKey) return void 0;
|
|
384
|
+
const matches = keyScopeListSchema.parse(await this.client.get("/api/user-api-keys", { timeout: SOLVER_CATALOG_TIMEOUT_MS })).filter((k) => k.status === "active" && !!k.keyPrefix && apiKey.startsWith(k.keyPrefix));
|
|
385
|
+
const longest = matches.reduce((length, key) => Math.max(length, key.keyPrefix?.length ?? 0), 0);
|
|
386
|
+
const best = matches.filter((k) => k.keyPrefix?.length === longest);
|
|
387
|
+
return best.length === 1 ? best[0].scopes ?? [] : [];
|
|
388
|
+
}
|
|
389
|
+
/** GET on the entitlement-gated solver route: the runnable rosters and limits for this caller. */
|
|
390
|
+
async getSolverCatalog() {
|
|
391
|
+
return this.client.get(SOLVER_ROUTE, { timeout: SOLVER_CATALOG_TIMEOUT_MS });
|
|
392
|
+
}
|
|
393
|
+
/** POST on the same route: run a bounded solver race. The body is validated server-side. */
|
|
394
|
+
async runSolverRace(body) {
|
|
395
|
+
return this.client.post(SOLVER_ROUTE, body);
|
|
396
|
+
}
|
|
397
|
+
/** POST bob-run: start one site read under a caller-minted run id. Throws Bob's words on a refusal. */
|
|
398
|
+
async startBobRun(args) {
|
|
399
|
+
const body = {
|
|
400
|
+
url: args.url,
|
|
401
|
+
runId: args.runId,
|
|
402
|
+
...bobOrganizationField(args.organizationId)
|
|
403
|
+
};
|
|
404
|
+
throwBobRefusal(await this.client.post(`${BOB_ROUTE}/bob-run`, body));
|
|
405
|
+
}
|
|
406
|
+
async getBobRun(runId) {
|
|
407
|
+
const result = await this.client.get(`${BOB_ROUTE}/bob-run`, { params: {
|
|
408
|
+
id: runId,
|
|
409
|
+
view: "agent"
|
|
410
|
+
} });
|
|
411
|
+
return this.parseBobView(z$1.object({ run: bobAgentRunSchema }), result, "run").run;
|
|
412
|
+
}
|
|
413
|
+
/** POST bob-compare: start a read of each site, linked as one comparison. Throws Bob's words on a refusal. */
|
|
414
|
+
async startBobComparison(args) {
|
|
415
|
+
const { organizationId, ...ids } = args;
|
|
416
|
+
const body = {
|
|
417
|
+
...ids,
|
|
418
|
+
...bobOrganizationField(organizationId)
|
|
419
|
+
};
|
|
420
|
+
throwBobRefusal(await this.client.post(`${BOB_ROUTE}/bob-compare`, body));
|
|
421
|
+
}
|
|
422
|
+
async getBobComparison(comparisonId) {
|
|
423
|
+
const result = await this.client.get(`${BOB_ROUTE}/bob-compare`, { params: {
|
|
424
|
+
id: comparisonId,
|
|
425
|
+
view: "agent"
|
|
426
|
+
} });
|
|
427
|
+
return this.parseBobView(z$1.object({ comparison: bobAgentComparisonSchema }), result, "comparison").comparison;
|
|
428
|
+
}
|
|
429
|
+
/** POST bob-recheck: re-run one check of a run against its site. Answers with the new reading. */
|
|
430
|
+
async recheckBob(args) {
|
|
431
|
+
const result = await this.client.post(`${BOB_ROUTE}/bob-recheck`, args);
|
|
432
|
+
throwBobRefusal(result);
|
|
433
|
+
return result.recheck;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* GET bob-run?list=recent: the caller's finished runs and comparisons, newest first. The server caps
|
|
437
|
+
* `limit`. A row of a kind this CLI does not know is dropped, so a newer server cannot break the list.
|
|
438
|
+
*/
|
|
439
|
+
async listBobReports(limit) {
|
|
440
|
+
const result = await this.client.get(`${BOB_ROUTE}/bob-run`, { params: {
|
|
441
|
+
list: "recent",
|
|
442
|
+
...limit !== void 0 ? { limit } : {}
|
|
443
|
+
} });
|
|
444
|
+
return bobRecentListSchema.parse(result).runs.flatMap((row) => {
|
|
445
|
+
const parsed = bobRecentRowSchema.safeParse(row);
|
|
446
|
+
return parsed.success ? [parsed.data] : [];
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
/** GET bob-run?quota: runs left under the daily cap, for the account a start would bill. */
|
|
450
|
+
async getBobQuota(organizationId) {
|
|
451
|
+
return (await this.client.get(`${BOB_ROUTE}/bob-run`, { params: {
|
|
452
|
+
quota: "",
|
|
453
|
+
...organizationId === void 0 ? {} : { organizationId: organizationId ?? "" }
|
|
454
|
+
} })).quota ?? {};
|
|
455
|
+
}
|
|
456
|
+
parseBobView(schema, body, what) {
|
|
457
|
+
const parsed = schema.safeParse(body);
|
|
458
|
+
if (!parsed.success) throw new Error(`the Bike4Mind server at ${this.baseURL} answered the Bob ${what} without its agent view; it likely predates these tools`);
|
|
459
|
+
return parsed.data;
|
|
460
|
+
}
|
|
296
461
|
};
|
|
462
|
+
/** A 2xx `{ ok: false, error }` from Bob is a refusal (the daily cap, credits), not a transport error. */
|
|
463
|
+
function throwBobRefusal(body) {
|
|
464
|
+
const refusal = bobRefusalSchema.safeParse(body);
|
|
465
|
+
if (refusal.success) throw new Error(refusal.data.error);
|
|
466
|
+
}
|
|
297
467
|
/**
|
|
298
468
|
* Turn an API failure into an actionable, transport-agnostic message. `scope` is
|
|
299
469
|
* the recommended API-key scope for the failing tool. A 403 can come from a
|
|
@@ -335,6 +505,20 @@ function mapApiError(error, baseURL, scope) {
|
|
|
335
505
|
return error instanceof Error ? error.message : String(error);
|
|
336
506
|
}
|
|
337
507
|
/**
|
|
508
|
+
* {@link mapApiError} for Bob's routes, which do enforce their scopes. A scope 403 (Bob's own
|
|
509
|
+
* `missing_scope`, or the host gate's `required_scopes`) names the scope to grant; a 429 or 503 that
|
|
510
|
+
* carries `retryAfterMs` says how long to wait; a 404 with no error body means the server has no Bob.
|
|
511
|
+
*/
|
|
512
|
+
function mapBobApiError(error, baseURL, scope) {
|
|
513
|
+
const failure = httpFailure(error);
|
|
514
|
+
const message = extractServerMessage(failure?.data);
|
|
515
|
+
const body = failure?.data && typeof failure.data === "object" ? failure.data : {};
|
|
516
|
+
if (failure?.status === 403 && (body.code === "missing_scope" || Array.isArray(body.required_scopes))) return `${message ?? "API key forbidden"} (grant the key ${scope})`;
|
|
517
|
+
if ((failure?.status === 429 || failure?.status === 503) && typeof body.retryAfterMs === "number") return `${message ?? "Bob is busy"} (retry after ${Math.ceil(body.retryAfterMs / 1e3)}s)`;
|
|
518
|
+
if (failure?.status === 404 && !message) return `Bob is not available on the Bike4Mind server at ${baseURL}`;
|
|
519
|
+
return mapApiError(error, baseURL, scope);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
338
522
|
* Status and body of an HTTP failure from either transport: a `B4mApiError` from the SDK calls or an
|
|
339
523
|
* AxiosError from the axios ones (with no status when the request never got a response). Undefined for
|
|
340
524
|
* anything else.
|
|
@@ -493,6 +677,48 @@ const TOOL_META = [
|
|
|
493
677
|
title: "Publish artifact",
|
|
494
678
|
description: "Publish an artifact you own to a shareable /p/... link and return its absolute URL. Republishing an artifact updates its existing page and keeps that page's visibility unless you pass one; a first publish defaults to public.",
|
|
495
679
|
scope: "files:write"
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
name: "bob_read_site",
|
|
683
|
+
title: "Read a site with Bob",
|
|
684
|
+
description: "Have Bob read a website: a panel of synthetic personas reads it and reports where visitors get lost. Spends the key owner's credits and publishes the report as them, as a run started in the app does. Waits for the report, reporting progress, and returns it as Markdown with links to the in-app and published reports; fails with Bob's reason when the read fails. If Bob is still reading when waitSeconds runs out, returns the runId to pass to get_bob_report later.",
|
|
685
|
+
scope: "bob:run"
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
name: "bob_compare_sites",
|
|
689
|
+
title: "Compare two sites with Bob",
|
|
690
|
+
description: "Have Bob read your site and another one side by side. Spends the key owner's credits for both reads and publishes each report as them. Waits for both, reporting progress, and returns the comparison summary as Markdown with each side's status and links, plus each side's full report when includeReports is true. If Bob is still reading when waitSeconds runs out, returns the comparisonId to pass to get_bob_comparison later.",
|
|
691
|
+
scope: "bob:run"
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
name: "bob_recheck",
|
|
695
|
+
title: "Re-check a Bob finding",
|
|
696
|
+
description: "Re-scan one check from a Bob report against the live site, e.g. after a fix, and return the new reading. Costs no credits and adds the reading to the existing report beside the original; no new report is published. checkId is the id the report shows in backticks beside the check. Re-checks are rate-limited, and a refusal says how long to wait.",
|
|
697
|
+
scope: "bob:run"
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
name: "get_bob_report",
|
|
701
|
+
title: "Get a Bob report",
|
|
702
|
+
description: "Fetch one Bob run by runId: its status (reading, ok or failed), its progress while reading, and once it is ok the report as Markdown with links to the in-app and published reports.",
|
|
703
|
+
scope: "bob:read"
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
name: "get_bob_comparison",
|
|
707
|
+
title: "Get a Bob comparison",
|
|
708
|
+
description: "Fetch one Bob comparison by comparisonId: its status, the summary as Markdown once both sides are read, and each side's status and links. Pass a side's runId to get_bob_report for its full report.",
|
|
709
|
+
scope: "bob:read"
|
|
710
|
+
},
|
|
711
|
+
{
|
|
712
|
+
name: "list_bob_reports",
|
|
713
|
+
title: "List Bob reports",
|
|
714
|
+
description: "List the key owner's recent finished Bob reports, newest first: single-site runs (kind 'run') and two-site comparisons (kind 'comparison'), each with its in-app link. Read a run with get_bob_report and its runId, and a comparison with get_bob_comparison and its comparisonId.",
|
|
715
|
+
scope: "bob:read"
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
name: "bob_runs_left",
|
|
719
|
+
title: "Bob runs left",
|
|
720
|
+
description: "How many Bob runs the key owner can start now under the daily limit, when the next one frees up, and whether the account's credits cover a full read. A comparison uses two runs.",
|
|
721
|
+
scope: "bob:read"
|
|
496
722
|
}
|
|
497
723
|
];
|
|
498
724
|
TOOL_META.map((t) => t.name);
|
|
@@ -586,6 +812,32 @@ const textToSpeechShape = {
|
|
|
586
812
|
provider: ttsRequestSchema.shape.provider.describe(`Speech provider; defaults to ${DEFAULT_TTS_PROVIDER}`),
|
|
587
813
|
preview: ttsRequestSchema.shape.preview.describe("Skip saving a copy to the file browser")
|
|
588
814
|
};
|
|
815
|
+
const BOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
816
|
+
const BOB_WAIT_DEFAULT_SECONDS = 600;
|
|
817
|
+
const BOB_WAIT_MAX_SECONDS = 900;
|
|
818
|
+
const bobId = (description) => z$1.string().regex(BOB_ID_PATTERN, "must be a UUID").describe(description);
|
|
819
|
+
const bobOrganizationId = z$1.string().min(1).nullable().optional().describe("Organization whose credits pay for the run: an organization id, or null for the key owner's personal account; omit for their home organization");
|
|
820
|
+
const bobWaitSeconds = z$1.number().int().min(0).max(BOB_WAIT_MAX_SECONDS).default(BOB_WAIT_DEFAULT_SECONDS).describe(`How long to wait for the result, in seconds (0-${BOB_WAIT_MAX_SECONDS}, default ${BOB_WAIT_DEFAULT_SECONDS}); a read takes about 1 to 5 minutes and Bob keeps reading after the wait ends`);
|
|
821
|
+
const bobReadSiteShape = {
|
|
822
|
+
url: z$1.string().min(1).describe("The site to read, e.g. acme.io or acme.io/pricing"),
|
|
823
|
+
organizationId: bobOrganizationId,
|
|
824
|
+
waitSeconds: bobWaitSeconds
|
|
825
|
+
};
|
|
826
|
+
const bobCompareSitesShape = {
|
|
827
|
+
yourUrl: z$1.string().min(1).describe("Your site, e.g. acme.io"),
|
|
828
|
+
theirUrl: z$1.string().min(1).describe("The site to compare yours with"),
|
|
829
|
+
organizationId: bobOrganizationId,
|
|
830
|
+
waitSeconds: bobWaitSeconds,
|
|
831
|
+
includeReports: z$1.boolean().default(false).describe("Also return each side's full report as Markdown")
|
|
832
|
+
};
|
|
833
|
+
const bobRecheckShape = {
|
|
834
|
+
runId: bobId("The run the check belongs to"),
|
|
835
|
+
checkId: z$1.string().min(1).describe("The check id, as the report shows it in backticks beside the check")
|
|
836
|
+
};
|
|
837
|
+
const getBobReportShape = { runId: bobId("The run id") };
|
|
838
|
+
const getBobComparisonShape = { comparisonId: bobId("The comparison id") };
|
|
839
|
+
const listBobReportsShape = { limit: z$1.number().int().min(1).max(100).optional().describe("Maximum reports to return; the server caps the list") };
|
|
840
|
+
const bobRunsLeftShape = { organizationId: bobOrganizationId.describe("Account whose credits to check: an organization id, or null for the key owner's personal account; omit for their home organization") };
|
|
589
841
|
function notebookSummary(n) {
|
|
590
842
|
return {
|
|
591
843
|
id: n.id,
|
|
@@ -746,30 +998,38 @@ const isPermanentApiError = (err) => {
|
|
|
746
998
|
const status = apiErrorStatus(err);
|
|
747
999
|
return status === 401 || status === 403 || status === 404;
|
|
748
1000
|
};
|
|
749
|
-
/** Poll
|
|
750
|
-
async function
|
|
1001
|
+
/** Poll `read` until `isFinished`, the timeout, a permanent poll failure, or an abort. */
|
|
1002
|
+
async function pollUntil({ read, ref, task, isFinished, describeError }, { interval, timeoutMs, sleep = defaultSleep, signal, onProgress }) {
|
|
751
1003
|
const started = Date.now();
|
|
752
1004
|
let failures = 0;
|
|
753
1005
|
let retryAfterMs = 0;
|
|
1006
|
+
let latest;
|
|
754
1007
|
for (;;) {
|
|
755
1008
|
signal?.throwIfAborted();
|
|
756
|
-
let
|
|
1009
|
+
let value;
|
|
757
1010
|
try {
|
|
758
|
-
|
|
1011
|
+
value = await read();
|
|
1012
|
+
latest = value;
|
|
759
1013
|
failures = 0;
|
|
760
|
-
if (isFinished(
|
|
1014
|
+
if (isFinished(value)) return {
|
|
1015
|
+
finished: true,
|
|
1016
|
+
value
|
|
1017
|
+
};
|
|
761
1018
|
} catch (err) {
|
|
762
1019
|
if (isRateLimited(err)) retryAfterMs = (apiErrorRetryAfterSeconds(err) ?? 0) * 1e3;
|
|
763
1020
|
else {
|
|
764
1021
|
failures += 1;
|
|
765
|
-
if (isPermanentApiError(err) || failures >= MAX_CONSECUTIVE_POLL_FAILURES) throw new Error(`${
|
|
1022
|
+
if (isPermanentApiError(err) || failures >= MAX_CONSECUTIVE_POLL_FAILURES) throw new Error(`${describeError(err)} (${ref}; the ${task} may still complete)`);
|
|
766
1023
|
}
|
|
767
1024
|
}
|
|
768
1025
|
const elapsed = Date.now() - started;
|
|
769
|
-
if (elapsed >= timeoutMs)
|
|
1026
|
+
if (elapsed >= timeoutMs) return {
|
|
1027
|
+
finished: false,
|
|
1028
|
+
latest
|
|
1029
|
+
};
|
|
770
1030
|
signal?.throwIfAborted();
|
|
771
1031
|
try {
|
|
772
|
-
await onProgress?.(elapsed,
|
|
1032
|
+
await onProgress?.(elapsed, value);
|
|
773
1033
|
} catch (err) {
|
|
774
1034
|
logger.warn(`mcp: progress notification failed (${ref}): ${err instanceof Error ? err.message : String(err)}`);
|
|
775
1035
|
}
|
|
@@ -778,6 +1038,18 @@ async function pollQuest(client, { questId, ref, task, scope, isFinished }, { in
|
|
|
778
1038
|
retryAfterMs = 0;
|
|
779
1039
|
}
|
|
780
1040
|
}
|
|
1041
|
+
/** Poll a queued quest until `isFinished`, the timeout, a permanent poll failure, or an abort. */
|
|
1042
|
+
async function pollQuest(client, { questId, ref, task, scope, isFinished }, { baseURL = "", ...timing }) {
|
|
1043
|
+
const outcome = await pollUntil({
|
|
1044
|
+
read: () => client.getQuest(questId),
|
|
1045
|
+
ref,
|
|
1046
|
+
task,
|
|
1047
|
+
isFinished,
|
|
1048
|
+
describeError: (err) => mapApiError(err, baseURL, scope)
|
|
1049
|
+
}, timing);
|
|
1050
|
+
if (!outcome.finished) throw new Error(`${task} did not finish within ${Math.round(timing.timeoutMs / 1e3)}s (${ref})`);
|
|
1051
|
+
return outcome.value;
|
|
1052
|
+
}
|
|
781
1053
|
/**
|
|
782
1054
|
* Queue an image render and poll its quest to completion. A failed render still
|
|
783
1055
|
* resolves `status: 'done'`, so `type: 'error'` (reason in `reply`) is the failure signal.
|
|
@@ -1016,19 +1288,19 @@ async function textToSpeech(client, args) {
|
|
|
1016
1288
|
});
|
|
1017
1289
|
}
|
|
1018
1290
|
/**
|
|
1019
|
-
* Turn a
|
|
1020
|
-
*
|
|
1021
|
-
* progress wait out
|
|
1291
|
+
* Turn a poll's progress callback into MCP `notifications/progress`, or nothing when the client
|
|
1292
|
+
* sent no progressToken. Progress counts elapsed seconds, so it rises on every poll, and that lets a
|
|
1293
|
+
* client that resets its request timeout on progress wait out slow work.
|
|
1022
1294
|
*/
|
|
1023
1295
|
function progressReporter({ _meta, sendNotification }, describe) {
|
|
1024
1296
|
const progressToken = _meta?.progressToken;
|
|
1025
1297
|
if (progressToken === void 0) return void 0;
|
|
1026
|
-
return (elapsedMs,
|
|
1298
|
+
return (elapsedMs, latest) => sendNotification({
|
|
1027
1299
|
method: "notifications/progress",
|
|
1028
1300
|
params: {
|
|
1029
1301
|
progressToken,
|
|
1030
1302
|
progress: Math.floor(elapsedMs / 1e3),
|
|
1031
|
-
message: describe(
|
|
1303
|
+
message: describe(latest)
|
|
1032
1304
|
}
|
|
1033
1305
|
});
|
|
1034
1306
|
}
|
|
@@ -1038,6 +1310,188 @@ function chatProgress(quest) {
|
|
|
1038
1310
|
return characters > 0 ? `generating reply (${characters} characters so far)` : "generating reply";
|
|
1039
1311
|
}
|
|
1040
1312
|
/**
|
|
1313
|
+
* Bob's own done/total count goes in the progress message, not in `progress`: a count can stay flat
|
|
1314
|
+
* for a while, and MCP needs `progress` to rise on every notification (see {@link progressReporter}).
|
|
1315
|
+
*/
|
|
1316
|
+
function bobProgressMessage(what, count) {
|
|
1317
|
+
return count && count.total > 0 ? `${what} (${count.done} of ${count.total})` : what;
|
|
1318
|
+
}
|
|
1319
|
+
function bobComparisonCount(comparison) {
|
|
1320
|
+
const counts = [comparison.you?.progress, comparison.them?.progress].filter((c) => !!c);
|
|
1321
|
+
if (counts.length === 0) return null;
|
|
1322
|
+
return counts.reduce((sum, c) => ({
|
|
1323
|
+
done: sum.done + c.done,
|
|
1324
|
+
total: sum.total + c.total
|
|
1325
|
+
}));
|
|
1326
|
+
}
|
|
1327
|
+
/** send_message's cadence (fast for the first 30s, then slower), bounded by the caller's wait. */
|
|
1328
|
+
function bobPollTiming({ intervalMs = CHAT_POLL_INTERVAL_MS, ...poll }, waitSeconds) {
|
|
1329
|
+
return {
|
|
1330
|
+
...poll,
|
|
1331
|
+
timeoutMs: waitSeconds * 1e3,
|
|
1332
|
+
interval: (elapsedMs) => chatPollInterval(elapsedMs, intervalMs)
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
/** Bob's views carry app paths; a link is the API base URL plus one. */
|
|
1336
|
+
const bobLink = (baseURL, path) => path ? `${baseURL.replace(/\/+$/, "")}${path}` : null;
|
|
1337
|
+
function bobRunSummary(run, baseURL) {
|
|
1338
|
+
return {
|
|
1339
|
+
runId: run.runId,
|
|
1340
|
+
url: run.url,
|
|
1341
|
+
status: run.status,
|
|
1342
|
+
progress: run.progress ?? null,
|
|
1343
|
+
createdAt: run.createdAt,
|
|
1344
|
+
settledAt: run.settledAt ?? null,
|
|
1345
|
+
error: run.error ?? null,
|
|
1346
|
+
scanOnly: run.scanOnly ?? null,
|
|
1347
|
+
appUrl: bobLink(baseURL, run.appPath),
|
|
1348
|
+
publishedUrl: bobLink(baseURL, run.publishedPath),
|
|
1349
|
+
visibility: run.visibility ?? null
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
function bobComparisonSummary(comparison, baseURL) {
|
|
1353
|
+
return {
|
|
1354
|
+
comparisonId: comparison.comparisonId,
|
|
1355
|
+
status: comparison.status,
|
|
1356
|
+
you: comparison.you && bobRunSummary(comparison.you, baseURL),
|
|
1357
|
+
them: comparison.them && bobRunSummary(comparison.them, baseURL),
|
|
1358
|
+
appUrl: bobLink(baseURL, comparison.appPath)
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* The summary as JSON (and as structuredContent), then each Markdown document as a text block of its
|
|
1363
|
+
* own. The documents stay out of structuredContent so a long report is not sent twice, as the audio
|
|
1364
|
+
* tools keep their bytes out.
|
|
1365
|
+
*/
|
|
1366
|
+
function bobResult(summary, documents) {
|
|
1367
|
+
const result = toResult(summary);
|
|
1368
|
+
for (const text of documents) if (text) result.content.push({
|
|
1369
|
+
type: "text",
|
|
1370
|
+
text
|
|
1371
|
+
});
|
|
1372
|
+
return result;
|
|
1373
|
+
}
|
|
1374
|
+
function bobComparisonResult(comparison, baseURL, includeReports) {
|
|
1375
|
+
const reports = includeReports ? [comparison.you, comparison.them].map((side) => side?.reportMarkdown && `Report for ${side.url}:\n\n${side.reportMarkdown}`) : [];
|
|
1376
|
+
return bobResult(bobComparisonSummary(comparison, baseURL), [comparison.summaryMarkdown, ...reports]);
|
|
1377
|
+
}
|
|
1378
|
+
const bobStillReading = (waitSeconds, readBack) => `Bob is still reading after ${waitSeconds}s and carries on without this call; use ${readBack} later.`;
|
|
1379
|
+
/**
|
|
1380
|
+
* Start a Bob read of one site and poll it until it settles or `waitSeconds` passes. A refusal or a
|
|
1381
|
+
* failed read throws, so the tool reports isError in Bob's words. A read still going at the deadline
|
|
1382
|
+
* is not an error: the result carries the runId to read it back with.
|
|
1383
|
+
*/
|
|
1384
|
+
async function bobReadSite(client, args, poll = {}) {
|
|
1385
|
+
const runId = randomUUID();
|
|
1386
|
+
await client.startBobRun({
|
|
1387
|
+
url: args.url,
|
|
1388
|
+
runId,
|
|
1389
|
+
organizationId: args.organizationId
|
|
1390
|
+
});
|
|
1391
|
+
const ref = `run ${runId}`;
|
|
1392
|
+
const outcome = await pollUntil({
|
|
1393
|
+
read: () => client.getBobRun(runId),
|
|
1394
|
+
ref,
|
|
1395
|
+
task: "site read",
|
|
1396
|
+
isFinished: (run) => run.status !== "reading",
|
|
1397
|
+
describeError: (err) => mapBobApiError(err, client.baseURL, "bob:run")
|
|
1398
|
+
}, bobPollTiming(poll, args.waitSeconds));
|
|
1399
|
+
if (!outcome.finished) return toResult({
|
|
1400
|
+
...outcome.latest ? bobRunSummary(outcome.latest, client.baseURL) : {
|
|
1401
|
+
runId,
|
|
1402
|
+
url: args.url,
|
|
1403
|
+
status: "reading"
|
|
1404
|
+
},
|
|
1405
|
+
note: bobStillReading(args.waitSeconds, "get_bob_report with this runId")
|
|
1406
|
+
});
|
|
1407
|
+
const run = outcome.value;
|
|
1408
|
+
if (run.status === "failed") throw new Error(`Bob could not read ${run.url}: ${run.error || "the read failed"} (${ref})`);
|
|
1409
|
+
return bobResult(bobRunSummary(run, client.baseURL), [run.reportMarkdown]);
|
|
1410
|
+
}
|
|
1411
|
+
/** {@link bobReadSite} for two sites started as one comparison; fails only when neither side could be read. */
|
|
1412
|
+
async function bobCompareSites(client, args, poll = {}) {
|
|
1413
|
+
const comparisonId = randomUUID();
|
|
1414
|
+
const you = {
|
|
1415
|
+
url: args.yourUrl,
|
|
1416
|
+
runId: randomUUID()
|
|
1417
|
+
};
|
|
1418
|
+
const them = {
|
|
1419
|
+
url: args.theirUrl,
|
|
1420
|
+
runId: randomUUID()
|
|
1421
|
+
};
|
|
1422
|
+
await client.startBobComparison({
|
|
1423
|
+
comparisonId,
|
|
1424
|
+
you,
|
|
1425
|
+
them,
|
|
1426
|
+
organizationId: args.organizationId
|
|
1427
|
+
});
|
|
1428
|
+
const ref = `comparison ${comparisonId}`;
|
|
1429
|
+
const outcome = await pollUntil({
|
|
1430
|
+
read: () => client.getBobComparison(comparisonId),
|
|
1431
|
+
ref,
|
|
1432
|
+
task: "comparison",
|
|
1433
|
+
isFinished: (comparison) => comparison.status !== "reading",
|
|
1434
|
+
describeError: (err) => mapBobApiError(err, client.baseURL, "bob:run")
|
|
1435
|
+
}, bobPollTiming(poll, args.waitSeconds));
|
|
1436
|
+
if (!outcome.finished) return toResult({
|
|
1437
|
+
...outcome.latest ? bobComparisonSummary(outcome.latest, client.baseURL) : {
|
|
1438
|
+
comparisonId,
|
|
1439
|
+
status: "reading",
|
|
1440
|
+
you,
|
|
1441
|
+
them
|
|
1442
|
+
},
|
|
1443
|
+
note: bobStillReading(args.waitSeconds, "get_bob_comparison with this comparisonId")
|
|
1444
|
+
});
|
|
1445
|
+
const comparison = outcome.value;
|
|
1446
|
+
if (comparison.status === "failed") {
|
|
1447
|
+
const reasons = [comparison.you, comparison.them].map((side) => side ? `${side.url}: ${side.error || "the read failed"}` : "one side has no run").join("; ");
|
|
1448
|
+
throw new Error(`Bob could not read either site: ${reasons} (${ref})`);
|
|
1449
|
+
}
|
|
1450
|
+
return bobComparisonResult(comparison, client.baseURL, args.includeReports === true);
|
|
1451
|
+
}
|
|
1452
|
+
async function bobRecheck(client, args) {
|
|
1453
|
+
return toResult({
|
|
1454
|
+
runId: args.runId,
|
|
1455
|
+
recheck: await client.recheckBob(args)
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
async function getBobReport(client, args) {
|
|
1459
|
+
const run = await client.getBobRun(args.runId);
|
|
1460
|
+
return bobResult(bobRunSummary(run, client.baseURL), [run.reportMarkdown]);
|
|
1461
|
+
}
|
|
1462
|
+
async function getBobComparison(client, args) {
|
|
1463
|
+
return bobComparisonResult(await client.getBobComparison(args.comparisonId), client.baseURL, false);
|
|
1464
|
+
}
|
|
1465
|
+
/** A recent-list row in compact form, with the in-app link its kind opens at. */
|
|
1466
|
+
function bobReportRow(row, baseURL) {
|
|
1467
|
+
if (row.kind === "comparison") {
|
|
1468
|
+
const { comparisonId, you, them } = row;
|
|
1469
|
+
const appUrl = bobLink(baseURL, `/bob?compare=${encodeURIComponent(comparisonId)}`);
|
|
1470
|
+
return {
|
|
1471
|
+
kind: "comparison",
|
|
1472
|
+
comparisonId,
|
|
1473
|
+
you,
|
|
1474
|
+
them,
|
|
1475
|
+
createdAt: row.createdAt ?? null,
|
|
1476
|
+
appUrl
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
const appUrl = bobLink(baseURL, `/bob?run=${encodeURIComponent(row.runId)}`);
|
|
1480
|
+
return {
|
|
1481
|
+
kind: "run",
|
|
1482
|
+
runId: row.runId,
|
|
1483
|
+
url: row.url,
|
|
1484
|
+
createdAt: row.createdAt ?? null,
|
|
1485
|
+
appUrl
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
async function listBobReports(client, args) {
|
|
1489
|
+
return toResult({ reports: (await client.listBobReports(args.limit)).map((row) => bobReportRow(row, client.baseURL)) });
|
|
1490
|
+
}
|
|
1491
|
+
async function bobRunsLeft(client, args) {
|
|
1492
|
+
return toResult(await client.getBobQuota(args.organizationId));
|
|
1493
|
+
}
|
|
1494
|
+
/**
|
|
1041
1495
|
* Register the Bike4Mind MCP tools on `server`. Each handler is wrapped so an
|
|
1042
1496
|
* API failure becomes a structured `isError` result carrying a friendly message
|
|
1043
1497
|
* (see {@link mapApiError}) rather than throwing across the transport.
|
|
@@ -1167,6 +1621,135 @@ function registerTools(server, client) {
|
|
|
1167
1621
|
description: meta("publish_artifact").description,
|
|
1168
1622
|
inputSchema: publishArtifactShape
|
|
1169
1623
|
}, (args) => run("files:write", () => publishArtifact(client, args)));
|
|
1624
|
+
const runBob = async (scope, fn) => {
|
|
1625
|
+
try {
|
|
1626
|
+
return await fn();
|
|
1627
|
+
} catch (err) {
|
|
1628
|
+
return errorResult(mapBobApiError(err, baseURL, scope));
|
|
1629
|
+
}
|
|
1630
|
+
};
|
|
1631
|
+
server.registerTool("bob_read_site", {
|
|
1632
|
+
title: meta("bob_read_site").title,
|
|
1633
|
+
description: meta("bob_read_site").description,
|
|
1634
|
+
inputSchema: bobReadSiteShape
|
|
1635
|
+
}, (args, extra) => runBob("bob:run", () => bobReadSite(client, args, {
|
|
1636
|
+
signal: extra.signal,
|
|
1637
|
+
onProgress: progressReporter(extra, (run) => bobProgressMessage(`reading ${args.url}`, run?.progress))
|
|
1638
|
+
})));
|
|
1639
|
+
server.registerTool("bob_compare_sites", {
|
|
1640
|
+
title: meta("bob_compare_sites").title,
|
|
1641
|
+
description: meta("bob_compare_sites").description,
|
|
1642
|
+
inputSchema: bobCompareSitesShape
|
|
1643
|
+
}, (args, extra) => runBob("bob:run", () => bobCompareSites(client, args, {
|
|
1644
|
+
signal: extra.signal,
|
|
1645
|
+
onProgress: progressReporter(extra, (comparison) => bobProgressMessage("reading both sites", comparison && bobComparisonCount(comparison)))
|
|
1646
|
+
})));
|
|
1647
|
+
server.registerTool("bob_recheck", {
|
|
1648
|
+
title: meta("bob_recheck").title,
|
|
1649
|
+
description: meta("bob_recheck").description,
|
|
1650
|
+
inputSchema: bobRecheckShape
|
|
1651
|
+
}, (args) => runBob("bob:run", () => bobRecheck(client, args)));
|
|
1652
|
+
server.registerTool("get_bob_report", {
|
|
1653
|
+
title: meta("get_bob_report").title,
|
|
1654
|
+
description: meta("get_bob_report").description,
|
|
1655
|
+
inputSchema: getBobReportShape
|
|
1656
|
+
}, (args) => runBob("bob:read", () => getBobReport(client, args)));
|
|
1657
|
+
server.registerTool("get_bob_comparison", {
|
|
1658
|
+
title: meta("get_bob_comparison").title,
|
|
1659
|
+
description: meta("get_bob_comparison").description,
|
|
1660
|
+
inputSchema: getBobComparisonShape
|
|
1661
|
+
}, (args) => runBob("bob:read", () => getBobComparison(client, args)));
|
|
1662
|
+
server.registerTool("list_bob_reports", {
|
|
1663
|
+
title: meta("list_bob_reports").title,
|
|
1664
|
+
description: meta("list_bob_reports").description,
|
|
1665
|
+
inputSchema: listBobReportsShape
|
|
1666
|
+
}, (args) => runBob("bob:read", () => listBobReports(client, args)));
|
|
1667
|
+
server.registerTool("bob_runs_left", {
|
|
1668
|
+
title: meta("bob_runs_left").title,
|
|
1669
|
+
description: meta("bob_runs_left").description,
|
|
1670
|
+
inputSchema: bobRunsLeftShape
|
|
1671
|
+
}, (args) => runBob("bob:read", () => bobRunsLeft(client, args)));
|
|
1672
|
+
}
|
|
1673
|
+
//#endregion
|
|
1674
|
+
//#region src/mcp/solverTools.ts
|
|
1675
|
+
/** Both scopes the tools need; named in a 403 so callers know what to grant. */
|
|
1676
|
+
const REQUIRED_SCOPES = [ApiKeyScope.OPTIHASHI_READ, ApiKeyScope.OPTIHASHI_COMPUTE];
|
|
1677
|
+
const SOLVER_SCOPES = REQUIRED_SCOPES.join(" and ");
|
|
1678
|
+
const SOLVER_DISCOVERY_TIMEOUT_MS = 5e3;
|
|
1679
|
+
/**
|
|
1680
|
+
* The route's GET answer, trusted only as far as the tool schemas need. Everything beyond these
|
|
1681
|
+
* fields passes through to the agent untouched, so a roster or limit the server adds later needs
|
|
1682
|
+
* no CLI release.
|
|
1683
|
+
*/
|
|
1684
|
+
const catalogSchema = z$1.looseObject({
|
|
1685
|
+
family: z$1.looseObject({
|
|
1686
|
+
familyIds: z$1.array(z$1.string()),
|
|
1687
|
+
solverIds: z$1.array(z$1.string())
|
|
1688
|
+
}),
|
|
1689
|
+
scheduling: z$1.looseObject({ solverIds: z$1.array(z$1.string()) }),
|
|
1690
|
+
defaultSolverIds: z$1.array(z$1.string()),
|
|
1691
|
+
limits: z$1.looseObject({
|
|
1692
|
+
maxSolversPerRequest: z$1.number().int().positive(),
|
|
1693
|
+
defaultTimeoutMs: z$1.number().int().positive(),
|
|
1694
|
+
maxTimeoutMs: z$1.number().int().positive()
|
|
1695
|
+
})
|
|
1696
|
+
});
|
|
1697
|
+
const oneOf = (ids) => ids.length > 0 ? [ids[0], ...ids.slice(1)] : void 0;
|
|
1698
|
+
function solveInputShape(catalog) {
|
|
1699
|
+
const solverIds = oneOf([.../* @__PURE__ */ new Set([...catalog.family.solverIds, ...catalog.scheduling.solverIds])]);
|
|
1700
|
+
const familyIds = oneOf(catalog.family.familyIds);
|
|
1701
|
+
const { limits } = catalog;
|
|
1702
|
+
return {
|
|
1703
|
+
familyId: (familyIds ? z$1.enum(familyIds) : z$1.string()).optional().describe("Problem family. Omit for a scheduling brief; the problem must then be a scheduling problem."),
|
|
1704
|
+
problem: z$1.record(z$1.string(), z$1.unknown()).describe("The problem instance. The server validates it and names what is wrong on a 400."),
|
|
1705
|
+
solverIds: z$1.array(solverIds ? z$1.enum(solverIds) : z$1.string()).min(1).max(limits.maxSolversPerRequest).optional().describe(`Solvers to race. Defaults to ${catalog.defaultSolverIds.join(", ")}.`),
|
|
1706
|
+
timeoutMs: z$1.number().int().min(100).max(limits.maxTimeoutMs).optional().describe(`Per-solver time budget in ms (default ${limits.defaultTimeoutMs}).`),
|
|
1707
|
+
includeTrace: z$1.boolean().optional().describe("Include a truncated convergence trace per solver.")
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* Register the solver tools an entitled caller may use, built from what the server reports at
|
|
1712
|
+
* startup. Stdio only: the HTTP transport has no per-request auth, so it never calls this.
|
|
1713
|
+
*
|
|
1714
|
+
* Offering nothing is the normal outcome for a caller without the feature, the scopes or the
|
|
1715
|
+
* entitlement (the route answers 403/404), so any failure here leaves the server's other tools
|
|
1716
|
+
* untouched instead of failing the session. Only the free classical race is exposed; no tool here
|
|
1717
|
+
* commissions paid compute.
|
|
1718
|
+
*/
|
|
1719
|
+
async function registerSolverTools(server, client) {
|
|
1720
|
+
let catalog;
|
|
1721
|
+
let discoveryTimer;
|
|
1722
|
+
try {
|
|
1723
|
+
const [rawCatalog, scopes] = await Promise.race([Promise.all([client.getSolverCatalog(), client.getOwnKeyScopes()]), new Promise((_resolve, reject) => {
|
|
1724
|
+
discoveryTimer = setTimeout(() => reject(/* @__PURE__ */ new Error("solver discovery timed out")), SOLVER_DISCOVERY_TIMEOUT_MS);
|
|
1725
|
+
})]);
|
|
1726
|
+
if (scopes && !REQUIRED_SCOPES.every((scope) => scopes.includes(scope))) {
|
|
1727
|
+
logger.debug(`mcp: solver tools not offered: key lacks ${SOLVER_SCOPES}`);
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
catalog = catalogSchema.parse(rawCatalog);
|
|
1731
|
+
} catch (err) {
|
|
1732
|
+
logger.debug(`mcp: solver tools not offered: ${mapApiError(err, client.baseURL, SOLVER_SCOPES)}`);
|
|
1733
|
+
return;
|
|
1734
|
+
} finally {
|
|
1735
|
+
clearTimeout(discoveryTimer);
|
|
1736
|
+
}
|
|
1737
|
+
const run = async (fn) => {
|
|
1738
|
+
try {
|
|
1739
|
+
return toResult(await fn());
|
|
1740
|
+
} catch (err) {
|
|
1741
|
+
return errorResult(mapApiError(err, client.baseURL, SOLVER_SCOPES));
|
|
1742
|
+
}
|
|
1743
|
+
};
|
|
1744
|
+
server.registerTool("optihashi_solvers", {
|
|
1745
|
+
title: "List OptiHashi solvers",
|
|
1746
|
+
description: "List the problem families, solvers, default race and limits available to the caller. Call before optihashi_solve."
|
|
1747
|
+
}, () => run(async () => catalogSchema.parse(await client.getSolverCatalog())));
|
|
1748
|
+
server.registerTool("optihashi_solve", {
|
|
1749
|
+
title: "Solve with OptiHashi",
|
|
1750
|
+
description: "Race classical solvers on an optimization problem and return the ranked results and the winner. Bounded and synchronous; no credits are spent.",
|
|
1751
|
+
inputSchema: solveInputShape(catalog)
|
|
1752
|
+
}, (args) => run(() => client.runSolverRace(args)));
|
|
1170
1753
|
}
|
|
1171
1754
|
//#endregion
|
|
1172
1755
|
//#region src/mcp/resources.ts
|
|
@@ -1408,8 +1991,7 @@ function registerPrompts(server, client, now = () => /* @__PURE__ */ new Date())
|
|
|
1408
1991
|
* network - only tool/resource *calls* and prompt listing/gets hit the API - so a
|
|
1409
1992
|
* server built with an unreachable endpoint still advertises its full capability set.
|
|
1410
1993
|
*/
|
|
1411
|
-
function buildMcpServer(options) {
|
|
1412
|
-
const client = new B4mApiClient(options.baseURL, options.configStore, options.apiKey);
|
|
1994
|
+
function buildMcpServer(options, client = new B4mApiClient(options.baseURL, options.configStore, options.apiKey)) {
|
|
1413
1995
|
const server = new McpServer({
|
|
1414
1996
|
name: "bike4mind",
|
|
1415
1997
|
version: options.version
|
|
@@ -1423,6 +2005,17 @@ function buildMcpServer(options) {
|
|
|
1423
2005
|
registerPrompts(server, client);
|
|
1424
2006
|
return server;
|
|
1425
2007
|
}
|
|
2008
|
+
/**
|
|
2009
|
+
* {@link buildMcpServer} plus the tools only an authenticated, entitled caller gets, which the server
|
|
2010
|
+
* reports at startup. Stdio only: the HTTP transport trusts anything that can reach its socket, so
|
|
2011
|
+
* it uses the plain builder and never offers these.
|
|
2012
|
+
*/
|
|
2013
|
+
async function buildStdioMcpServer(options) {
|
|
2014
|
+
const client = new B4mApiClient(options.baseURL, options.configStore, options.apiKey);
|
|
2015
|
+
const server = buildMcpServer(options, client);
|
|
2016
|
+
await registerSolverTools(server, client);
|
|
2017
|
+
return server;
|
|
2018
|
+
}
|
|
1426
2019
|
//#endregion
|
|
1427
2020
|
//#region src/mcp/serve.ts
|
|
1428
2021
|
/**
|
|
@@ -1437,10 +2030,9 @@ function buildMcpServer(options) {
|
|
|
1437
2030
|
* NOTHING else, so all diagnostics are forced to stderr before ANY other work
|
|
1438
2031
|
* runs (mirrors the acp command's captureStdout - see src/commands/acpCommand.ts).
|
|
1439
2032
|
*
|
|
1440
|
-
* HTTP mode
|
|
1441
|
-
*
|
|
1442
|
-
*
|
|
1443
|
-
* in depth against a browser being tricked into posting to the local port.
|
|
2033
|
+
* HTTP mode binds loopback only (127.0.0.1) and requires `Authorization: Bearer
|
|
2034
|
+
* <token>` on every request. Token precedence is `--token` > `B4M_MCP_TOKEN` >
|
|
2035
|
+
* one generated at startup. DNS-rebinding protection (Host/Origin checks) stays on.
|
|
1444
2036
|
*/
|
|
1445
2037
|
const HTTP_PATH = "/mcp";
|
|
1446
2038
|
/**
|
|
@@ -1470,7 +2062,7 @@ function resolveBaseURL(options, configStore) {
|
|
|
1470
2062
|
return configStore.getApiConfig().then((apiConfig) => requireApiUrl(apiConfig));
|
|
1471
2063
|
}
|
|
1472
2064
|
async function serveStdio(buildOptions, writeFrame) {
|
|
1473
|
-
const server =
|
|
2065
|
+
const server = await buildStdioMcpServer(buildOptions);
|
|
1474
2066
|
const stdout = new Writable({ write(chunk, _encoding, callback) {
|
|
1475
2067
|
if (writeFrame(chunk)) {
|
|
1476
2068
|
callback();
|
|
@@ -1485,6 +2077,30 @@ async function serveStdio(buildOptions, writeFrame) {
|
|
|
1485
2077
|
process.stdin.once("close", resolve);
|
|
1486
2078
|
});
|
|
1487
2079
|
}
|
|
2080
|
+
function resolveHttpToken(flag, env) {
|
|
2081
|
+
if (flag) return {
|
|
2082
|
+
token: flag,
|
|
2083
|
+
source: "flag"
|
|
2084
|
+
};
|
|
2085
|
+
if (env) return {
|
|
2086
|
+
token: env,
|
|
2087
|
+
source: "env"
|
|
2088
|
+
};
|
|
2089
|
+
return {
|
|
2090
|
+
token: randomBytes(32).toString("base64url"),
|
|
2091
|
+
source: "generated"
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
function isAuthorized(header, token) {
|
|
2095
|
+
const match = /^Bearer (.+)$/i.exec(header ?? "");
|
|
2096
|
+
if (!match) return false;
|
|
2097
|
+
const given = Buffer.from(match[1]);
|
|
2098
|
+
const expected = Buffer.from(token);
|
|
2099
|
+
return given.length === expected.length && timingSafeEqual(given, expected);
|
|
2100
|
+
}
|
|
2101
|
+
function maskToken(token) {
|
|
2102
|
+
return token.length > 8 ? `${token.slice(0, 4)}...` : "****";
|
|
2103
|
+
}
|
|
1488
2104
|
async function handleHttpRequest(req, res, buildOptions, port) {
|
|
1489
2105
|
const server = buildMcpServer(buildOptions);
|
|
1490
2106
|
const transport = new StreamableHTTPServerTransport({
|
|
@@ -1506,13 +2122,21 @@ async function handleHttpRequest(req, res, buildOptions, port) {
|
|
|
1506
2122
|
await server.connect(transport);
|
|
1507
2123
|
await transport.handleRequest(req, res);
|
|
1508
2124
|
}
|
|
1509
|
-
|
|
1510
|
-
|
|
2125
|
+
function createHttpHandler(buildOptions, port, token) {
|
|
2126
|
+
return (req, res) => {
|
|
1511
2127
|
if (new URL(req.url ?? "/", `http://localhost:${port}`).pathname !== HTTP_PATH) {
|
|
1512
2128
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1513
2129
|
res.end(JSON.stringify({ error: `Not found. The MCP endpoint is ${HTTP_PATH}.` }));
|
|
1514
2130
|
return;
|
|
1515
2131
|
}
|
|
2132
|
+
if (!isAuthorized(req.headers.authorization, token)) {
|
|
2133
|
+
res.writeHead(401, {
|
|
2134
|
+
"Content-Type": "application/json",
|
|
2135
|
+
"WWW-Authenticate": "Bearer"
|
|
2136
|
+
});
|
|
2137
|
+
res.end(JSON.stringify({ error: "Unauthorized. Send an Authorization: Bearer <token> header." }));
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
1516
2140
|
handleHttpRequest(req, res, buildOptions, port).catch((err) => {
|
|
1517
2141
|
logger.error("MCP HTTP request failed", err);
|
|
1518
2142
|
if (!res.headersSent) {
|
|
@@ -1527,12 +2151,18 @@ async function serveHttp(buildOptions, port) {
|
|
|
1527
2151
|
}));
|
|
1528
2152
|
}
|
|
1529
2153
|
});
|
|
1530
|
-
}
|
|
2154
|
+
};
|
|
2155
|
+
}
|
|
2156
|
+
async function serveHttp(buildOptions, port, resolved) {
|
|
2157
|
+
const httpServer = createServer(createHttpHandler(buildOptions, port, resolved.token));
|
|
1531
2158
|
await new Promise((resolve, reject) => {
|
|
1532
2159
|
httpServer.once("error", reject);
|
|
1533
2160
|
httpServer.listen(port, "127.0.0.1", () => resolve());
|
|
1534
2161
|
});
|
|
1535
2162
|
process.stderr.write(`Bike4Mind MCP server listening on http://127.0.0.1:${port}${HTTP_PATH}\n`);
|
|
2163
|
+
const shown = resolved.source === "generated" ? resolved.token : maskToken(resolved.token);
|
|
2164
|
+
process.stderr.write(`Authorization: Bearer ${shown}\n`);
|
|
2165
|
+
process.stderr.write("Set a fixed token with --token or B4M_MCP_TOKEN.\n");
|
|
1536
2166
|
await new Promise((resolve) => {
|
|
1537
2167
|
const shutdown = () => httpServer.close(() => resolve());
|
|
1538
2168
|
process.once("SIGINT", shutdown);
|
|
@@ -1549,7 +2179,7 @@ async function handleMcpServeCommand(options) {
|
|
|
1549
2179
|
version: options.version
|
|
1550
2180
|
};
|
|
1551
2181
|
if (writeFrame) await serveStdio(buildOptions, writeFrame);
|
|
1552
|
-
else await serveHttp(buildOptions, options.port ?? 7e3);
|
|
2182
|
+
else await serveHttp(buildOptions, options.port ?? 7e3, resolveHttpToken(options.token, process.env.B4M_MCP_TOKEN));
|
|
1553
2183
|
}
|
|
1554
2184
|
//#endregion
|
|
1555
2185
|
export { handleMcpServeCommand };
|