akanjs 3.0.0-alpha.68 → 3.0.0-alpha.69

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.
@@ -16,9 +16,13 @@ export interface McpExposureEndpoint {
16
16
  args: { name: string; refName: string; type: string; arrDepth?: number; nullable?: boolean }[];
17
17
  guards?: string[];
18
18
  fileUpload?: boolean;
19
+ mcp?: boolean;
19
20
  }
20
21
 
21
22
  export interface McpExposureOption {
23
+ /** The model the endpoint belongs to and the name it is published under: one rule reads the key, not the shape. */
24
+ refName: string;
25
+ key: string;
22
26
  /**
23
27
  * The read-only deployment valve, which is server configuration. The browser explorer cannot know it and so
24
28
  * badges what the code decided; the boot log is where a read-only deployment says what it dropped.
@@ -56,10 +60,19 @@ export const mcpHintsOf = (key: string, endpoint: { type: string }) => {
56
60
  };
57
61
 
58
62
  /** The sentence explaining why this endpoint is not in the catalogue, or `null` when it is. */
59
- export const mcpRefusalOf = (endpoint: McpExposureEndpoint, { readOnly }: McpExposureOption = {}): string | null => {
63
+ export const mcpRefusalOf = (
64
+ endpoint: McpExposureEndpoint,
65
+ { refName, key, readOnly }: McpExposureOption,
66
+ ): string | null => {
67
+
68
+ if (endpoint.mcp === false)
69
+ return "it declares `mcp: false`, so it is deliberately off the agent shelf. HTTP still serves it.";
60
70
 
61
71
  if (!endpoint.guards?.length)
62
72
  return "it declares no guards, and exposure follows them — write `guards: [Public]` if anonymous access is the intent.";
73
+
74
+ if (key === `light${capitalize(refName)}`)
75
+ return `it reads the same document as \`${refName}\` in a smaller shape — call \`${refName}\` instead.`;
63
76
  if (endpoint.type === "prompt") return mcpPromptRefusalOf(endpoint);
64
77
  if (endpoint.type === "pubsub" || endpoint.type === "message")
65
78
  return `\`${endpoint.type}\` rides the websocket, and its internal arguments read a socket an MCP request does not have.`;
@@ -27,6 +27,24 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
27
27
  })),
28
28
  pubsubPing: fn(["Pubsub Ping", "Pubsub Ping"]),
29
29
  }))
30
+ .error({
31
+ serverUnreachable: [
32
+ "Cannot reach the server. Check your connection and try again.",
33
+ "서버에 연결할 수 없습니다. 네트워크 상태를 확인한 뒤 다시 시도해주세요.",
34
+ ],
35
+ serverUnavailable: [
36
+ "The server is restarting. Please try again in a moment.",
37
+ "서버가 재시작 중입니다. 잠시 후 다시 시도해주세요.",
38
+ ],
39
+ gatewayTimeout: [
40
+ "The server took too long to answer. Please try again in a moment.",
41
+ "서버 응답이 지연되고 있습니다. 잠시 후 다시 시도해주세요.",
42
+ ],
43
+ unexpectedResponse: [
44
+ "The server returned an unexpected response ({status}).",
45
+ "서버가 예상하지 못한 응답을 보냈습니다. ({status})",
46
+ ],
47
+ })
30
48
  .translate({
31
49
  somethingWrong: ["Something's wrong!", "문제가 생겼어요!"],
32
50
  connecting: ["Connecting...", "연결 중..."],
@@ -443,6 +443,9 @@ export class FetchClient {
443
443
  const createGuards = signal.createGuards ?? signal.cruGuards;
444
444
  const updateGuards = signal.updateGuards ?? signal.cruGuards;
445
445
  const removeGuards = signal.removeGuards ?? signal.cruGuards;
446
+
447
+ const mcp = (verb: keyof NonNullable<SerializedSignal["mcp"]>) =>
448
+ signal.mcp?.[verb] === false ? { mcp: false as const } : {};
446
449
  const endpoint: { [key: string]: SerializedEndpoint } = {};
447
450
  if (signal.getGuards) {
448
451
  endpoint[names.model] = {
@@ -450,12 +453,14 @@ export class FetchClient {
450
453
  args: [{ type: "param", name: names.modelId, refName: "ID" }],
451
454
  returns: { refName, modelType: "full" },
452
455
  guards: signal.getGuards,
456
+ ...mcp("get"),
453
457
  };
454
458
  endpoint[names.lightModel] = {
455
459
  type: "query",
456
460
  args: [{ type: "param", name: names.modelId, refName: "ID" }],
457
461
  returns: { refName, modelType: "light" },
458
462
  guards: signal.getGuards,
463
+ ...mcp("get"),
459
464
  };
460
465
  }
461
466
  if (createGuards) {
@@ -464,6 +469,7 @@ export class FetchClient {
464
469
  args: [{ type: "body", name: "data", refName, modelType: "input" }],
465
470
  returns: { refName, modelType: "full" },
466
471
  guards: createGuards,
472
+ ...mcp("create"),
467
473
  };
468
474
  }
469
475
  if (updateGuards) {
@@ -475,6 +481,7 @@ export class FetchClient {
475
481
  ],
476
482
  returns: { refName, modelType: "full" },
477
483
  guards: updateGuards,
484
+ ...mcp("update"),
478
485
  };
479
486
  }
480
487
  if (removeGuards) {
@@ -483,6 +490,7 @@ export class FetchClient {
483
490
  args: [{ type: "param", name: names.modelId, refName: "ID" }],
484
491
  returns: { refName, modelType: "full" },
485
492
  guards: removeGuards,
493
+ ...mcp("remove"),
486
494
  };
487
495
  }
488
496
  return endpoint;
@@ -602,18 +610,22 @@ export class FetchClient {
602
610
  list: `${refName}List${capSuffix}`,
603
611
  insight: `${refName}Insight${capSuffix}`,
604
612
  };
613
+
614
+ const mcp = slice.mcp === false ? { mcp: false as const } : {};
605
615
  const endpoint: { [key: string]: SerializedEndpoint } = {
606
616
  [names.list]: {
607
617
  type: "query",
608
618
  args: [...slice.args, ...FetchClient.paginationArgs],
609
619
  returns: { refName, modelType: "light", arrDepth: 1 },
610
620
  guards: slice.guards,
621
+ ...mcp,
611
622
  },
612
623
  [names.insight]: {
613
624
  type: "query",
614
625
  args: [...slice.args],
615
626
  returns: { refName, modelType: "insight" },
616
627
  guards: slice.guards,
628
+ ...mcp,
617
629
  },
618
630
  };
619
631
  return endpoint;
@@ -27,6 +27,19 @@ interface FetchOptions {
27
27
  baseUrl?: string;
28
28
  }
29
29
 
30
+ const jsonContentType = /^application\/(?:[\w.+-]+\+)?json\b/i;
31
+
32
+ const transportErrorKeyMap = {
33
+ 408: "base.error.gatewayTimeout",
34
+ 502: "base.error.serverUnavailable",
35
+ 503: "base.error.serverUnavailable",
36
+ 504: "base.error.gatewayTimeout",
37
+ } as const;
38
+
39
+ const serverUnreachableKey = "base.error.serverUnreachable";
40
+ const unexpectedResponseKey = "base.error.unexpectedResponse";
41
+ const transportDetailLimit = 200;
42
+
30
43
  export class HttpClient {
31
44
  readonly baseUrl: string;
32
45
  constructor(
@@ -42,11 +55,17 @@ export class HttpClient {
42
55
  #resolveBaseUrl(baseUrl?: string) {
43
56
  return (baseUrl ?? this.baseUrl).replace(/\/$/, "");
44
57
  }
58
+ #resolveUrl(url: string, options: FetchOptions) {
59
+ return `${this.#resolveBaseUrl(options.baseUrl)}${url}`;
60
+ }
61
+
62
+ static #makeHeaders(headers: Record<string, string>, options: FetchOptions) {
63
+ return { Accept: "application/json", ...headers, ...options.headers };
64
+ }
45
65
  async get<Returns = unknown>(url: string, options: FetchOptions = {}): Promise<Returns> {
46
- const res = await fetch(`${this.#resolveBaseUrl(options.baseUrl)}${url}`, {
47
- headers: { "Content-Type": "application/json", ...options.headers },
66
+ return await this.#request<Returns>(this.#resolveUrl(url, options), {
67
+ headers: HttpClient.#makeHeaders({ "Content-Type": "application/json" }, options),
48
68
  });
49
- return await this.#readJsonResponse<Returns>(res);
50
69
  }
51
70
  #makeReqContent(data: FormData | Record<string, unknown>): { body: BodyInit; headers: Record<string, string> } {
52
71
 
@@ -60,12 +79,11 @@ export class HttpClient {
60
79
  options: FetchOptions = {},
61
80
  ): Promise<Returns> {
62
81
  const { body, headers } = this.#makeReqContent(data);
63
- const res = await fetch(`${this.#resolveBaseUrl(options.baseUrl)}${url}`, {
82
+ return await this.#request<Returns>(this.#resolveUrl(url, options), {
64
83
  method,
65
84
  body,
66
- headers: { ...headers, ...options.headers },
85
+ headers: HttpClient.#makeHeaders(headers, options),
67
86
  });
68
- return await this.#readJsonResponse<Returns>(res);
69
87
  }
70
88
  async put<Returns = unknown>(
71
89
  url: string,
@@ -82,19 +100,69 @@ export class HttpClient {
82
100
  return await this.send<Returns>("POST", url, data, options);
83
101
  }
84
102
  async delete<Returns = unknown>(url: string, options: FetchOptions = {}): Promise<Returns> {
85
- const res = await fetch(`${this.#resolveBaseUrl(options.baseUrl)}${url}`, {
103
+ return await this.#request<Returns>(this.#resolveUrl(url, options), {
86
104
  method: "DELETE",
87
- headers: { "Content-Type": "application/json", ...options.headers },
105
+ headers: HttpClient.#makeHeaders({ "Content-Type": "application/json" }, options),
88
106
  });
107
+ }
108
+
109
+ async #request<Returns>(url: string, init: RequestInit): Promise<Returns> {
110
+ const res = await this.#fetch(url, init);
89
111
  return await this.#readJsonResponse<Returns>(res);
90
112
  }
91
113
 
114
+ /**
115
+ * `fetch` rejects only when no response arrived at all — a refused connection, a DNS failure, a socket
116
+ * dropped mid-flight. That is the server being unreachable, not an error this API reported, so it is
117
+ * restored as one rather than surfacing the runtime's own `TypeError: Failed to fetch`.
118
+ */
119
+ async #fetch(url: string, init: RequestInit) {
120
+ try {
121
+ return await fetch(url, init);
122
+ } catch (error) {
123
+ if (error instanceof Error && error.name === "AbortError") throw error;
124
+ throw this.#restoreError({ error: serverUnreachableKey, details: String(error) }, 503);
125
+ }
126
+ }
127
+
92
128
  async #readJsonResponse<Returns>(res: Response): Promise<Returns> {
93
- const body = await res.json();
129
+ const body = await this.#readBody(res);
94
130
  if (res.ok) return body as Returns;
95
131
  throw this.#restoreError(body, res.status);
96
132
  }
97
133
 
134
+ /**
135
+ * A proxy answers a restarting upstream with a page of its own — nginx's `504 Gateway Time-out` HTML,
136
+ * the federation gateway's plain-text 503. That body is not this API's, so parsing it would surface the
137
+ * parser's complaint (`Unexpected token '<'`) instead of the fact that the server is down.
138
+ */
139
+ async #readBody(res: Response) {
140
+ if (jsonContentType.test(res.headers.get("content-type") ?? "")) {
141
+ try {
142
+ return (await res.json()) as unknown;
143
+ } catch (error) {
144
+ throw this.#transportError(res.status, String(error));
145
+ }
146
+ }
147
+ const raw = await res.text();
148
+ const parsed = HttpClient.#parseJson(raw);
149
+ if (parsed === undefined) throw this.#transportError(res.status, raw);
150
+ return parsed;
151
+ }
152
+
153
+ static #parseJson(raw: string): unknown {
154
+ try {
155
+ return JSON.parse(raw) as unknown;
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ }
160
+
161
+ #transportError(status: number, detail: string): RestoredError {
162
+ const error = transportErrorKeyMap[status as keyof typeof transportErrorKeyMap] ?? unexpectedResponseKey;
163
+ return this.#restoreError({ error, data: { status }, details: detail.slice(0, transportDetailLimit) }, status);
164
+ }
165
+
98
166
  #restoreError(body: unknown, fallbackStatusCode: number): RestoredError {
99
167
  const payload =
100
168
  body && typeof body === "object" && "error" in body
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.68",
3
+ "version": "3.0.0-alpha.69",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -367,4 +367,4 @@
367
367
  "bun": ">=1.4.0"
368
368
  },
369
369
  "main": "./index.ts"
370
- }
370
+ }
package/server/akanApp.ts CHANGED
@@ -780,7 +780,7 @@ export class AkanApp {
780
780
  `Child ${child.idx}/${child.role} upstream is unreachable (${child.upstream.socketPath}); restarting`,
781
781
  );
782
782
  this.#scheduleChildRestart(child, child.proc, "upstream-open-failed");
783
- return new Response("Federation child upstream is unreachable; restarting", { status: 503 });
783
+ return AkanApp.#unavailableResponse(req, "Federation child upstream is unreachable; restarting");
784
784
  }
785
785
  throw error;
786
786
  } finally {
@@ -802,13 +802,13 @@ export class AkanApp {
802
802
  */
803
803
  async #pickReadyFederationChild(req: Request): Promise<ChildState | null> {
804
804
  const ready = this.#pickFederationChild();
805
- if (ready) return ready;
805
+ if (ready?.upstream) return ready;
806
806
  const deadline = performance.now() + this.#upstreamWaitMs;
807
807
  while (performance.now() < deadline) {
808
808
  if (this.#stopping || req.signal.aborted || this.#getCrashLoopDetail()) return null;
809
809
  await Bun.sleep(50);
810
810
  const child = this.#pickFederationChild();
811
- if (child) return child;
811
+ if (child?.upstream) return child;
812
812
  }
813
813
  return null;
814
814
  }
@@ -844,13 +844,26 @@ export class AkanApp {
844
844
  text: "No healthy federation child is ready",
845
845
  note: "A replica is booting or restarting — this page reloads itself as soon as it answers.",
846
846
  };
847
- if (!req.headers.get("accept")?.includes("text/html")) {
848
- return new Response(page.text, { status: 503, headers: { "cache-control": "no-store" } });
847
+ if (req.headers.get("accept")?.includes("text/html")) {
848
+ return new Response(AkanApp.#statusPageHtml(page), {
849
+ status: 503,
850
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
851
+ });
849
852
  }
850
- return new Response(AkanApp.#statusPageHtml(page), {
851
- status: 503,
852
- headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
853
- });
853
+ return AkanApp.#unavailableResponse(req, page.text);
854
+ }
855
+
856
+ /**
857
+ * An API caller asks for JSON, so it is answered with the payload `HttpClient` restores into an `Err` —
858
+ * a bare-text 503 reaches the browser as a JSON parse error naming the page body instead of the outage.
859
+ */
860
+ static #unavailableResponse(req: Request, detail: string): Response {
861
+ const headers = { "cache-control": "no-store" };
862
+ if (!req.headers.get("accept")?.includes("application/json")) return new Response(detail, { status: 503, headers });
863
+ return Response.json(
864
+ { error: "base.error.serverUnavailable", statusCode: 503, data: { status: 503 }, details: detail },
865
+ { status: 503, headers },
866
+ );
854
867
  }
855
868
 
856
869
  static #statusPageHtml({ heading, detail, note }: { heading: string; detail: string; note: string }) {
@@ -14,6 +14,7 @@ import {
14
14
  type McpExposedEndpoint,
15
15
  type McpJsonRpcRequest,
16
16
  McpProgress,
17
+ type McpSignalCost,
17
18
  type McpToolResult,
18
19
  } from "../../signal/mcp";
19
20
  import type { MiddlewareCls } from "../../signal/middleware";
@@ -94,6 +95,8 @@ const discoverCache: McpCacheHint = { ttlMs: 3_600_000, cacheScope: "public" };
94
95
  */
95
96
  export class McpRouter {
96
97
  static readonly logger = new Logger("McpRouter");
98
+ /** Past this a listing is a meaningful slice of a model's window, which is where it becomes worth saying so. */
99
+ static readonly listingWarnBytes = 100 * 1024;
97
100
 
98
101
  readonly #props: McpRouterProps;
99
102
  readonly #dispatcher: McpDispatcher;
@@ -138,9 +141,18 @@ export class McpRouter {
138
141
  */
139
142
  report() {
140
143
  try {
141
- const { tools, prompts, resourceTemplates, refusals, undescribed } = this.#getDocument();
144
+ const document = this.#getDocument();
145
+ const { tools, prompts, resourceTemplates, refusals, undescribed } = document;
146
+ const cost = document.listingCost;
142
147
  const counts = `tools=${tools.length} prompts=${prompts.length} resourceTemplates=${resourceTemplates.length}`;
143
- McpRouter.logger.debug(`MCP catalogue: ${counts}${this.#props.readOnly ? " (read-only deployment)" : ""}`);
148
+ const readOnly = this.#props.readOnly ? " (read-only deployment)" : "";
149
+ McpRouter.logger.debug(`MCP catalogue: ${counts}${readOnly} · listing ${McpRouter.#kb(cost.bytes)}`);
150
+ if (cost.bySignal.length) McpRouter.logger.debug(`MCP catalogue cost: ${McpRouter.#costLine(cost.bySignal)}`);
151
+
152
+ if (cost.bytes > McpRouter.listingWarnBytes)
153
+ McpRouter.logger.warn(
154
+ `MCP listing is ${McpRouter.#kb(cost.bytes)}, which every agent that connects pays before its first turn. Narrow it with \`mcp: false\` on an endpoint or the \`mcp\` map on \`slice()\`.`,
155
+ );
144
156
 
145
157
  if (!tools.length && !prompts.length)
146
158
  McpRouter.logger.warn(
@@ -157,6 +169,20 @@ export class McpRouter {
157
169
  }
158
170
  }
159
171
 
172
+ static #kb(bytes: number) {
173
+ return bytes < 1024 ? `${bytes}B` : `${Math.round(bytes / 1024)}KB`;
174
+ }
175
+
176
+ /** The heaviest signals first, capped: a fleet of forty models would otherwise wrap the line off the screen. */
177
+ static #costLine(bySignal: McpSignalCost[]) {
178
+ const shown = bySignal.slice(0, 8);
179
+ const rest = bySignal.length - shown.length;
180
+ const line = shown
181
+ .map(({ refName, entries, bytes }) => `${refName} ${entries}/${McpRouter.#kb(bytes)}`)
182
+ .join(" · ");
183
+ return rest > 0 ? `${line} · +${rest} more` : line;
184
+ }
185
+
160
186
  /** Rebuilding per request would re-derive every tool schema on a list agents poll; the set is fixed at boot. */
161
187
  #getDocument() {
162
188
  if (this.#document) return this.#document;
@@ -1,4 +1,4 @@
1
- import { capitalize, isMcpDescribableArg, mcpHintsOf, mcpRefusalOf } from "akanjs/common";
1
+ import { isMcpDescribableArg, mcpHintsOf, mcpRefusalOf } from "akanjs/common";
2
2
  import { FetchClient } from "akanjs/fetch";
3
3
  import { type AgentCandidate, AgentCatalogue, type AgentRefusal, type AgentUndescribed } from "../agent";
4
4
  import { type JsonSchema, JsonSchemaBuilder } from "../schema";
@@ -30,6 +30,18 @@ export interface McpExposedEndpoint {
30
30
  export type McpRefusal = AgentRefusal;
31
31
  export type McpUndescribed = AgentUndescribed;
32
32
 
33
+ /** What one signal contributes to a listing, so a catalogue that grew can say where it grew. */
34
+ export interface McpSignalCost {
35
+ refName: string;
36
+ entries: number;
37
+ bytes: number;
38
+ }
39
+
40
+ export interface McpListingCost {
41
+ bytes: number;
42
+ bySignal: McpSignalCost[];
43
+ }
44
+
33
45
  /**
34
46
  * Turns the serialized signal registry into the three MCP catalogues and answers the lookups `tools/call` and
35
47
  * `resources/read` need. Pure: no IO and no DI — the sibling of `createOpenApiDocument`.
@@ -62,6 +74,7 @@ export class McpDocument {
62
74
  readonly #byPromptName = new Map<string, { exposed: McpExposedEndpoint; prompt: McpPrompt }>();
63
75
  /** Keyed by endpoint key: what is addressable, and by exactly which uri. */
64
76
  readonly #templates = new Map<string, string>();
77
+ #cost: McpListingCost | null = null;
65
78
 
66
79
  constructor(serializedSignal: Record<string, SerializedSignal>, options: McpDocumentOptions = {}) {
67
80
  this.#options = options;
@@ -82,6 +95,30 @@ export class McpDocument {
82
95
  this.undescribed = this.#catalogue.undescribed;
83
96
  }
84
97
 
98
+ /**
99
+ * Roughly what a `tools/list` plus `prompts/list` costs the caller, and which signals it went to.
100
+ *
101
+ * Worth reporting because the number is nobody's intuition: MCP has no shared component section and forbids a
102
+ * `$ref` across entries, so every entry inlines the full schema of every model it mentions — a plain 21-field
103
+ * model with one named slice ships 12KB across its eight entries, three quarters of it the same four schemas
104
+ * repeated. A catalogue is re-sent whole to every agent that connects, before its first turn.
105
+ */
106
+ get listingCost(): McpListingCost {
107
+ if (this.#cost) return this.#cost;
108
+ const bySignal = new Map<string, McpSignalCost>();
109
+ const add = (refName: string, entry: unknown) => {
110
+ const cost = bySignal.get(refName) ?? { refName, entries: 0, bytes: 0 };
111
+ cost.entries += 1;
112
+ cost.bytes += JSON.stringify(entry).length;
113
+ bySignal.set(refName, cost);
114
+ };
115
+ for (const tool of this.tools) add(this.#byToolName.get(tool.name)?.refName ?? tool.name, tool);
116
+ for (const prompt of this.prompts) add(this.#byPromptName.get(prompt.name)?.exposed.refName ?? prompt.name, prompt);
117
+ const costs = [...bySignal.values()].sort((a, b) => b.bytes - a.bytes);
118
+ this.#cost = { bytes: costs.reduce((sum, cost) => sum + cost.bytes, 0), bySignal: costs };
119
+ return this.#cost;
120
+ }
121
+
85
122
  findTool(name: string): McpExposedEndpoint | undefined {
86
123
  return this.#byToolName.get(name);
87
124
  }
@@ -132,7 +169,11 @@ export class McpDocument {
132
169
  endpoint: candidate.endpoint,
133
170
  };
134
171
 
135
- const reason = mcpRefusalOf(item.endpoint, { readOnly: this.#options.readOnly });
172
+ const reason = mcpRefusalOf(item.endpoint, {
173
+ refName: item.refName,
174
+ key: item.key,
175
+ readOnly: this.#options.readOnly,
176
+ });
136
177
  if (reason) {
137
178
  this.#catalogue.refuse(item.key, reason);
138
179
  continue;
@@ -273,7 +314,6 @@ export class McpDocument {
273
314
 
274
315
  static #uriTemplate(refName: string, key: string, endpoint: SerializedEndpoint) {
275
316
  if (key === refName) return McpUriTemplate.model(refName);
276
- if (key === `light${capitalize(refName)}`) return McpUriTemplate.light(refName);
277
317
  const listPrefix = `${refName}List`;
278
318
  if (!key.startsWith(listPrefix)) return undefined;
279
319
  const suffix = key.slice(listPrefix.length);
@@ -15,14 +15,11 @@ export interface McpResourceTarget {
15
15
  export class McpUriTemplate {
16
16
  static readonly scheme = "akan";
17
17
  /** Reserved second segment: a model id may never take one of these values, and none is a valid ObjectId. */
18
- static readonly #reserved = new Set(["light", "list"]);
18
+ static readonly #reserved = new Set(["list"]);
19
19
 
20
20
  static model(refName: string) {
21
21
  return `${McpUriTemplate.scheme}://${refName}/{${refName}Id}`;
22
22
  }
23
- static light(refName: string) {
24
- return `${McpUriTemplate.scheme}://${refName}/light/{${refName}Id}`;
25
- }
26
23
  /**
27
24
  * The model's own unfiltered list is the bare `…/list`, never `…/list/<token>`. A named slice occupies the
28
25
  * third segment, and a slice key is an author-chosen identifier — so any token put there for the root list
@@ -51,8 +48,6 @@ export class McpUriTemplate {
51
48
  return { endpointKey: refName, args: { [`${refName}Id`]: second } };
52
49
  if (segments.length === 2 && second === "list")
53
50
  return { endpointKey: `${refName}List`, args: McpUriTemplate.#searchArgs(search) };
54
- if (segments.length === 3 && second === "light" && third)
55
- return { endpointKey: `light${capitalize(refName)}`, args: { [`${refName}Id`]: third } };
56
51
  if (segments.length === 3 && second === "list" && third)
57
52
  return { endpointKey: `${refName}List${capitalize(third)}`, args: McpUriTemplate.#searchArgs(search) };
58
53
  return null;
@@ -13,6 +13,7 @@ import type {
13
13
  SerializedFilter,
14
14
  SerializedReturns,
15
15
  SerializedSignal,
16
+ SerializedSignalMcp,
16
17
  SerializedSlice,
17
18
  SliceCls,
18
19
  SliceInfo,
@@ -69,6 +70,7 @@ export class FetchSerializer {
69
70
  ...(endpointInfo.signalOption.method ? { method: endpointInfo.signalOption.method } : {}),
70
71
  ...(endpointInfo.signalOption.fileUpload ? { fileUpload: true } : {}),
71
72
  ...(guards?.length ? { guards } : {}),
73
+ ...(endpointInfo.signalOption.mcp === false ? { mcp: false as const } : {}),
72
74
  };
73
75
  }
74
76
 
@@ -107,6 +109,7 @@ export class FetchSerializer {
107
109
  args: sliceInfo.args.map(FetchSerializer.#serializeArg),
108
110
  ...(sliceInfo.signalOption.path ? { path: sliceInfo.signalOption.path } : {}),
109
111
  ...(guards?.length ? { guards } : {}),
112
+ ...(sliceInfo.signalOption.mcp === false ? { mcp: false as const } : {}),
110
113
  };
111
114
  }
112
115
 
@@ -146,10 +149,21 @@ export class FetchSerializer {
146
149
  ...(sliceCls.removeGuards !== sliceCls.cruGuards && sliceCls.removeGuards.filter((g) => g.name !== "None").length
147
150
  ? { removeGuards: sliceCls.removeGuards.map((g) => g.name) }
148
151
  : {}),
152
+ ...FetchSerializer.#serializeSliceMcp(sliceCls),
149
153
  endpoint,
150
154
  };
151
155
  }
152
156
 
157
+ /** Only the verbs kept off the shelf travel: `true` is the default, so emitting it would grow every payload. */
158
+ static #serializeSliceMcp(sliceCls: SliceCls): { mcp?: SerializedSignalMcp } {
159
+ const mcp = Object.fromEntries(
160
+ Object.entries(sliceCls.mcp ?? {})
161
+ .filter(([, published]) => !published)
162
+ .map(([verb]) => [verb, false]),
163
+ ) as SerializedSignalMcp;
164
+ return Object.keys(mcp).length ? { mcp } : {};
165
+ }
166
+
153
167
  static serializeServiceSignal(endpointCls: EndpointCls): SerializedSignal {
154
168
  const endpointMeta = endpointCls[ENDPOINT_META] as { [key: string]: EndpointInfo };
155
169
  const endpoint: { [key: string]: SerializedEndpoint } = {};
package/signal/slice.ts CHANGED
@@ -38,8 +38,17 @@ export type SliceCls<
38
38
  createGuards: GuardCls[];
39
39
  updateGuards: GuardCls[];
40
40
  removeGuards: GuardCls[];
41
+ mcp: ResolvedSliceMcp;
41
42
  };
42
43
 
44
+ /** The generated verbs, resolved to a plain answer each. `root` is not here: it rides on the root slice itself. */
45
+ export interface ResolvedSliceMcp {
46
+ get: boolean;
47
+ create: boolean;
48
+ update: boolean;
49
+ remove: boolean;
50
+ }
51
+
43
52
  interface RootSliceOption {
44
53
  guards?: {
45
54
  root?: GuardCls | GuardCls[];
@@ -49,6 +58,18 @@ interface RootSliceOption {
49
58
  update?: GuardCls | GuardCls[];
50
59
  remove?: GuardCls | GuardCls[];
51
60
  };
61
+ /**
62
+ * Which of the entries this call generates belong on an agent's shelf. Keyed exactly like `guards` above and
63
+ * scoped to exactly what `guards` governs — the root slice and the generated CRUD — so `false` here narrows the
64
+ * catalogue and nothing else. `create`/`update`/`remove` fall back to `cru` the same way their guards do, and
65
+ * the bare `false` is every key at once.
66
+ *
67
+ * A named slice and a custom endpoint declare their own `mcp` in their own signal option, exactly as they
68
+ * declare their own guards. This is curation, not authorization: HTTP is unchanged.
69
+ */
70
+ mcp?:
71
+ | boolean
72
+ | { root?: boolean; get?: boolean; cru?: boolean; create?: boolean; update?: boolean; remove?: boolean };
52
73
  prefix?: string;
53
74
  }
54
75
 
@@ -131,6 +152,15 @@ export function slice<
131
152
  const createGuards = option.guards?.create ? toGuards(option.guards.create) : cruGuards;
132
153
  const updateGuards = option.guards?.update ? toGuards(option.guards.update) : cruGuards;
133
154
  const removeGuards = option.guards?.remove ? toGuards(option.guards.remove) : cruGuards;
155
+ const mcpOption =
156
+ typeof option.mcp === "boolean" ? { root: option.mcp, get: option.mcp, cru: option.mcp } : option.mcp;
157
+ const cruMcp = mcpOption?.cru !== false;
158
+ const mcp: ResolvedSliceMcp = {
159
+ get: mcpOption?.get !== false,
160
+ create: mcpOption?.create ?? cruMcp,
161
+ update: mcpOption?.update ?? cruMcp,
162
+ remove: mcpOption?.remove ?? cruMcp,
163
+ };
134
164
  const srvKeys = [
135
165
  ...new Set([...Object.keys(srv.srvMap), ...libSlices.flatMap((libSlice) => Object.keys(libSlice.srv.srvMap))]),
136
166
  ];
@@ -144,10 +174,11 @@ export function slice<
144
174
  static createGuards = createGuards;
145
175
  static updateGuards = updateGuards;
146
176
  static removeGuards = removeGuards;
177
+ static mcp = mcp;
147
178
  static [SLICE_META] = Object.assign(
148
179
  {
149
180
 
150
- [""]: init({ guards: rootGuards })
181
+ [""]: init({ guards: rootGuards, ...(mcpOption?.root === false ? { mcp: false } : {}) })
151
182
  .search<"queryKey", string>("queryKey", String)
152
183
  .search<"args", unknown[]>("args", Any)
153
184
  .exec((queryKey, args) => {
package/signal/types.ts CHANGED
@@ -85,6 +85,17 @@ export interface SignalOption<Response = any, Nullable extends boolean = false,
85
85
  method?: HttpMutationMethod;
86
86
  /** Marks this mutation as the framework file-upload endpoint (see resolveFileUploadCapability). */
87
87
  fileUpload?: boolean;
88
+ /**
89
+ * Whether this endpoint belongs on an agent's shelf. `true` — the default — publishes it to MCP subject to the
90
+ * guard and shape rules; `false` keeps it out of the catalogue entirely.
91
+ *
92
+ * This is curation, not authorization: HTTP serves the endpoint exactly as before, and the guards are still the
93
+ * only thing deciding who may call it. Write it where an endpoint is perfectly guarded and still has no business
94
+ * on a shelf — a step of a UI-driven state machine (`requestPhoneCodeForSignin`), or a read a model would only
95
+ * ever call by mistake. Every catalogue entry carries the model schemas it mentions, so one endpoint dropped
96
+ * here is kilobytes off every `tools/list`.
97
+ */
98
+ mcp?: boolean;
88
99
  /**
89
100
  * What a `pubsub(Binary)` does when a subscriber cannot keep up. `"coalesce"` (the default) keeps only the
90
101
  * newest frame per room, which is what a telemetry or video stream wants — an old frame is worthless once a
@@ -108,6 +119,13 @@ interface SerializedSignalOption {
108
119
  guards?: string[];
109
120
  method?: HttpMutationMethod;
110
121
  fileUpload?: boolean;
122
+ /**
123
+ * Only ever `false`, and only when something declared it: `true` is the default, so serializing it would ship a
124
+ * field per endpoint that says nothing. Resolved here rather than left to the reader because the browser API
125
+ * explorer has guard *names* and no classes, and a second implementation of the rule would eventually disagree
126
+ * with the catalogue.
127
+ */
128
+ mcp?: false;
111
129
  }
112
130
  export interface SerializedSlice extends SerializedSignalOption {}
113
131
 
@@ -152,6 +170,19 @@ export interface SerializedSignal {
152
170
  createGuards?: string[];
153
171
  updateGuards?: string[];
154
172
  removeGuards?: string[];
173
+ /**
174
+ * Which generated CRUD verbs this model keeps off the agent shelf. Carried on the signal because the endpoints
175
+ * it names do not exist until `FetchClient.getBaseEndpoint` synthesizes them. Only the `false` keys travel.
176
+ */
177
+ mcp?: SerializedSignalMcp;
178
+ }
179
+
180
+ /** Keyed by generated verb, mirroring the `guards` map `slice()` takes. The root slice's own flag rides on `slice[""]`. */
181
+ export interface SerializedSignalMcp {
182
+ get?: false;
183
+ create?: false;
184
+ update?: false;
185
+ remove?: false;
155
186
  }
156
187
 
157
188
  export type SignalType = "restapi" | "websocket";
@@ -22,8 +22,12 @@ export interface McpExposureEndpoint {
22
22
  }[];
23
23
  guards?: string[];
24
24
  fileUpload?: boolean;
25
+ mcp?: boolean;
25
26
  }
26
27
  export interface McpExposureOption {
28
+ /** The model the endpoint belongs to and the name it is published under: one rule reads the key, not the shape. */
29
+ refName: string;
30
+ key: string;
27
31
  /**
28
32
  * The read-only deployment valve, which is server configuration. The browser explorer cannot know it and so
29
33
  * badges what the code decided; the boot log is where a read-only deployment says what it dropped.
@@ -50,7 +54,7 @@ export declare const mcpHintsOf: (key: string, endpoint: {
50
54
  openWorldHint: boolean;
51
55
  };
52
56
  /** The sentence explaining why this endpoint is not in the catalogue, or `null` when it is. */
53
- export declare const mcpRefusalOf: (endpoint: McpExposureEndpoint, { readOnly }?: McpExposureOption) => string | null;
57
+ export declare const mcpRefusalOf: (endpoint: McpExposureEndpoint, { refName, key, readOnly }: McpExposureOption) => string | null;
54
58
  /**
55
59
  * A prompt is a read exposed on the same terms as a query, so every rejection here is one thing: an argument
56
60
  * `prompts/get` cannot carry. Its `arguments` is a flat string map — one string per name, and no schema beside it
@@ -1 +1 @@
1
- export declare const baseDictionary: import("./dictInfo.d.ts").ServiceDictInfo<[string, string], "ping" | "pingBody" | "pingParam" | "pingQuery" | "wsPing" | "pubsubPing", never, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentListen" | "agentVoiceFailed" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentAttachDuplicate" | "agentAttachTooMany" | "agentAttachTooMuch" | "agentAnswerNeeded" | "agentToolDone" | "agentToolFailed" | "agentToolRunning" | "agentToolResult" | "agentTokens" | "agentSummarizing" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCompact" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentNothingToCompact" | "agentCompacted" | "agentSummary" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view">;
1
+ export declare const baseDictionary: import("./dictInfo.d.ts").ServiceDictInfo<[string, string], "ping" | "pingBody" | "pingParam" | "pingQuery" | "wsPing" | "pubsubPing", "serverUnreachable" | "serverUnavailable" | "gatewayTimeout" | "unexpectedResponse", "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentListen" | "agentVoiceFailed" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentAttachDuplicate" | "agentAttachTooMany" | "agentAttachTooMuch" | "agentAnswerNeeded" | "agentToolDone" | "agentToolFailed" | "agentToolRunning" | "agentToolResult" | "agentTokens" | "agentSummarizing" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCompact" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentNothingToCompact" | "agentCompacted" | "agentSummary" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view">;
@@ -1,13 +1,13 @@
1
1
  import type { AgentEndpoint, AgentTurn, BaseEndpoint } from "akanjs/signal";
2
2
  export declare const dictionary: {
3
- base: import("./locale.d.ts").DictModule<import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentListen" | "agentVoiceFailed" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentAttachDuplicate" | "agentAttachTooMany" | "agentAttachTooMuch" | "agentAnswerNeeded" | "agentToolDone" | "agentToolFailed" | "agentToolRunning" | "agentToolResult" | "agentTokens" | "agentSummarizing" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCompact" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentNothingToCompact" | "agentCompacted" | "agentSummary" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view">, never>;
3
+ base: import("./locale.d.ts").DictModule<import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentListen" | "agentVoiceFailed" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentAttachDuplicate" | "agentAttachTooMany" | "agentAttachTooMuch" | "agentAnswerNeeded" | "agentToolDone" | "agentToolFailed" | "agentToolRunning" | "agentToolResult" | "agentTokens" | "agentSummarizing" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCompact" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentNothingToCompact" | "agentCompacted" | "agentSummary" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view">, "base.error.gatewayTimeout" | "base.error.serverUnavailable" | "base.error.serverUnreachable" | "base.error.unexpectedResponse">;
4
4
  agentTurn: import("./locale.d.ts").DictModule<import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc`, never>;
5
5
  agent: import("./locale.d.ts").DictModule<import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, "agent.error.llmUnavailable" | "agent.error.deepseekRequestFailed">;
6
6
  };
7
- export declare const Err: import("./trans.d.ts").ErrConstructor<"agent.error.llmUnavailable" | "agent.error.deepseekRequestFailed">, translate: (lang: "en" | "ko" | "zhChs" | "zhCht" | "ja", key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentListen" | "agentVoiceFailed" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentAttachDuplicate" | "agentAttachTooMany" | "agentAttachTooMuch" | "agentAnswerNeeded" | "agentToolDone" | "agentToolFailed" | "agentToolRunning" | "agentToolResult" | "agentTokens" | "agentSummarizing" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCompact" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentNothingToCompact" | "agentCompacted" | "agentSummary" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, data?: import("./trans.d.ts").TranslationData) => string, msg: {
7
+ export declare const Err: import("./trans.d.ts").ErrConstructor<"base.error.gatewayTimeout" | "base.error.serverUnavailable" | "base.error.serverUnreachable" | "base.error.unexpectedResponse" | "agent.error.llmUnavailable" | "agent.error.deepseekRequestFailed">, translate: (lang: "en" | "ko" | "zhChs" | "zhCht" | "ja", key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentListen" | "agentVoiceFailed" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentAttachDuplicate" | "agentAttachTooMany" | "agentAttachTooMuch" | "agentAnswerNeeded" | "agentToolDone" | "agentToolFailed" | "agentToolRunning" | "agentToolResult" | "agentTokens" | "agentSummarizing" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCompact" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentNothingToCompact" | "agentCompacted" | "agentSummary" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, data?: import("./trans.d.ts").TranslationData) => string, msg: {
8
8
  info: (key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentListen" | "agentVoiceFailed" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentAttachDuplicate" | "agentAttachTooMany" | "agentAttachTooMuch" | "agentAnswerNeeded" | "agentToolDone" | "agentToolFailed" | "agentToolRunning" | "agentToolResult" | "agentTokens" | "agentSummarizing" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCompact" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentNothingToCompact" | "agentCompacted" | "agentSummary" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, option?: import("./trans.d.ts").TransMessageOption) => void;
9
9
  success: (key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentListen" | "agentVoiceFailed" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentAttachDuplicate" | "agentAttachTooMany" | "agentAttachTooMuch" | "agentAnswerNeeded" | "agentToolDone" | "agentToolFailed" | "agentToolRunning" | "agentToolResult" | "agentTokens" | "agentSummarizing" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCompact" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentNothingToCompact" | "agentCompacted" | "agentSummary" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, option?: import("./trans.d.ts").TransMessageOption) => void;
10
10
  error: (key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentListen" | "agentVoiceFailed" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentAttachDuplicate" | "agentAttachTooMany" | "agentAttachTooMuch" | "agentAnswerNeeded" | "agentToolDone" | "agentToolFailed" | "agentToolRunning" | "agentToolResult" | "agentTokens" | "agentSummarizing" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCompact" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentNothingToCompact" | "agentCompacted" | "agentSummary" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, option?: import("./trans.d.ts").TransMessageOption) => void;
11
11
  warning: (key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentListen" | "agentVoiceFailed" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentAttachDuplicate" | "agentAttachTooMany" | "agentAttachTooMuch" | "agentAnswerNeeded" | "agentToolDone" | "agentToolFailed" | "agentToolRunning" | "agentToolResult" | "agentTokens" | "agentSummarizing" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCompact" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentNothingToCompact" | "agentCompacted" | "agentSummary" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, option?: import("./trans.d.ts").TransMessageOption) => void;
12
12
  loading: (key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentListen" | "agentVoiceFailed" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentAttachDuplicate" | "agentAttachTooMany" | "agentAttachTooMuch" | "agentAnswerNeeded" | "agentToolDone" | "agentToolFailed" | "agentToolRunning" | "agentToolResult" | "agentTokens" | "agentSummarizing" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCompact" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentNothingToCompact" | "agentCompacted" | "agentSummary" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, option?: import("./trans.d.ts").TransMessageOption) => void;
13
- }, getDictionary: (lang: "en" | "ko" | "zhChs" | "zhCht" | "ja") => object, getAllDictionary: () => import("./trans.d.ts").RootDictionary, __Dict_Key__: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentListen" | "agentVoiceFailed" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentAttachDuplicate" | "agentAttachTooMany" | "agentAttachTooMuch" | "agentAnswerNeeded" | "agentToolDone" | "agentToolFailed" | "agentToolRunning" | "agentToolResult" | "agentTokens" | "agentSummarizing" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCompact" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentNothingToCompact" | "agentCompacted" | "agentSummary" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, __Error_Key__: "agent.error.llmUnavailable" | "agent.error.deepseekRequestFailed";
13
+ }, getDictionary: (lang: "en" | "ko" | "zhChs" | "zhCht" | "ja") => object, getAllDictionary: () => import("./trans.d.ts").RootDictionary, __Dict_Key__: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentListen" | "agentVoiceFailed" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentAttachDuplicate" | "agentAttachTooMany" | "agentAttachTooMuch" | "agentAnswerNeeded" | "agentToolDone" | "agentToolFailed" | "agentToolRunning" | "agentToolResult" | "agentTokens" | "agentSummarizing" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCompact" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentNothingToCompact" | "agentCompacted" | "agentSummary" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, __Error_Key__: "base.error.gatewayTimeout" | "base.error.serverUnavailable" | "base.error.serverUnreachable" | "base.error.unexpectedResponse" | "agent.error.llmUnavailable" | "agent.error.deepseekRequestFailed";
@@ -41,6 +41,8 @@ export interface McpRouterProps {
41
41
  export declare class McpRouter {
42
42
  #private;
43
43
  static readonly logger: Logger;
44
+ /** Past this a listing is a meaningful slice of a model's window, which is where it becomes worth saying so. */
45
+ static readonly listingWarnBytes: number;
44
46
  constructor(props: McpRouterProps);
45
47
  createRoutes(): HttpRoutes;
46
48
  /**
@@ -22,6 +22,16 @@ export interface McpExposedEndpoint {
22
22
  */
23
23
  export type McpRefusal = AgentRefusal;
24
24
  export type McpUndescribed = AgentUndescribed;
25
+ /** What one signal contributes to a listing, so a catalogue that grew can say where it grew. */
26
+ export interface McpSignalCost {
27
+ refName: string;
28
+ entries: number;
29
+ bytes: number;
30
+ }
31
+ export interface McpListingCost {
32
+ bytes: number;
33
+ bySignal: McpSignalCost[];
34
+ }
25
35
  /**
26
36
  * Turns the serialized signal registry into the three MCP catalogues and answers the lookups `tools/call` and
27
37
  * `resources/read` need. Pure: no IO and no DI — the sibling of `createOpenApiDocument`.
@@ -46,6 +56,15 @@ export declare class McpDocument {
46
56
  /** What is published with no description of its own, which is the field a model picks a tool by. */
47
57
  readonly undescribed: McpUndescribed[];
48
58
  constructor(serializedSignal: Record<string, SerializedSignal>, options?: McpDocumentOptions);
59
+ /**
60
+ * Roughly what a `tools/list` plus `prompts/list` costs the caller, and which signals it went to.
61
+ *
62
+ * Worth reporting because the number is nobody's intuition: MCP has no shared component section and forbids a
63
+ * `$ref` across entries, so every entry inlines the full schema of every model it mentions — a plain 21-field
64
+ * model with one named slice ships 12KB across its eight entries, three quarters of it the same four schemas
65
+ * repeated. A catalogue is re-sent whole to every agent that connects, before its first turn.
66
+ */
67
+ get listingCost(): McpListingCost;
49
68
  findTool(name: string): McpExposedEndpoint | undefined;
50
69
  /** Returns the catalogue entry alongside the endpoint: `prompts/get` validates against the published one. */
51
70
  findPrompt(name: string): {
@@ -13,7 +13,6 @@ export declare class McpUriTemplate {
13
13
  #private;
14
14
  static readonly scheme = "akan";
15
15
  static model(refName: string): string;
16
- static light(refName: string): string;
17
16
  /**
18
17
  * The model's own unfiltered list is the bare `…/list`, never `…/list/<token>`. A named slice occupies the
19
18
  * third segment, and a slice key is an author-chosen identifier — so any token put there for the root list
@@ -29,7 +29,15 @@ export type SliceCls<SrvModule extends ServiceModel = ServiceModel, SliceInfoObj
29
29
  createGuards: GuardCls[];
30
30
  updateGuards: GuardCls[];
31
31
  removeGuards: GuardCls[];
32
+ mcp: ResolvedSliceMcp;
32
33
  };
34
+ /** The generated verbs, resolved to a plain answer each. `root` is not here: it rides on the root slice itself. */
35
+ export interface ResolvedSliceMcp {
36
+ get: boolean;
37
+ create: boolean;
38
+ update: boolean;
39
+ remove: boolean;
40
+ }
33
41
  interface RootSliceOption {
34
42
  guards?: {
35
43
  root?: GuardCls | GuardCls[];
@@ -39,6 +47,23 @@ interface RootSliceOption {
39
47
  update?: GuardCls | GuardCls[];
40
48
  remove?: GuardCls | GuardCls[];
41
49
  };
50
+ /**
51
+ * Which of the entries this call generates belong on an agent's shelf. Keyed exactly like `guards` above and
52
+ * scoped to exactly what `guards` governs — the root slice and the generated CRUD — so `false` here narrows the
53
+ * catalogue and nothing else. `create`/`update`/`remove` fall back to `cru` the same way their guards do, and
54
+ * the bare `false` is every key at once.
55
+ *
56
+ * A named slice and a custom endpoint declare their own `mcp` in their own signal option, exactly as they
57
+ * declare their own guards. This is curation, not authorization: HTTP is unchanged.
58
+ */
59
+ mcp?: boolean | {
60
+ root?: boolean;
61
+ get?: boolean;
62
+ cru?: boolean;
63
+ create?: boolean;
64
+ update?: boolean;
65
+ remove?: boolean;
66
+ };
42
67
  prefix?: string;
43
68
  }
44
69
  type RootSliceQueryKey<Filter extends FilterInstance> = Extract<keyof Filter["query"], string>;
@@ -74,6 +74,17 @@ export interface SignalOption<Response = any, Nullable extends boolean = false,
74
74
  method?: HttpMutationMethod;
75
75
  /** Marks this mutation as the framework file-upload endpoint (see resolveFileUploadCapability). */
76
76
  fileUpload?: boolean;
77
+ /**
78
+ * Whether this endpoint belongs on an agent's shelf. `true` — the default — publishes it to MCP subject to the
79
+ * guard and shape rules; `false` keeps it out of the catalogue entirely.
80
+ *
81
+ * This is curation, not authorization: HTTP serves the endpoint exactly as before, and the guards are still the
82
+ * only thing deciding who may call it. Write it where an endpoint is perfectly guarded and still has no business
83
+ * on a shelf — a step of a UI-driven state machine (`requestPhoneCodeForSignin`), or a read a model would only
84
+ * ever call by mistake. Every catalogue entry carries the model schemas it mentions, so one endpoint dropped
85
+ * here is kilobytes off every `tools/list`.
86
+ */
87
+ mcp?: boolean;
77
88
  /**
78
89
  * What a `pubsub(Binary)` does when a subscriber cannot keep up. `"coalesce"` (the default) keeps only the
79
90
  * newest frame per room, which is what a telemetry or video stream wants — an old frame is worthless once a
@@ -95,6 +106,13 @@ interface SerializedSignalOption {
95
106
  guards?: string[];
96
107
  method?: HttpMutationMethod;
97
108
  fileUpload?: boolean;
109
+ /**
110
+ * Only ever `false`, and only when something declared it: `true` is the default, so serializing it would ship a
111
+ * field per endpoint that says nothing. Resolved here rather than left to the reader because the browser API
112
+ * explorer has guard *names* and no classes, and a second implementation of the rule would eventually disagree
113
+ * with the catalogue.
114
+ */
115
+ mcp?: false;
98
116
  }
99
117
  export interface SerializedSlice extends SerializedSignalOption {
100
118
  }
@@ -144,6 +162,18 @@ export interface SerializedSignal {
144
162
  createGuards?: string[];
145
163
  updateGuards?: string[];
146
164
  removeGuards?: string[];
165
+ /**
166
+ * Which generated CRUD verbs this model keeps off the agent shelf. Carried on the signal because the endpoints
167
+ * it names do not exist until `FetchClient.getBaseEndpoint` synthesizes them. Only the `false` keys travel.
168
+ */
169
+ mcp?: SerializedSignalMcp;
170
+ }
171
+ /** Keyed by generated verb, mirroring the `guards` map `slice()` takes. The root slice's own flag rides on `slice[""]`. */
172
+ export interface SerializedSignalMcp {
173
+ get?: false;
174
+ create?: false;
175
+ update?: false;
176
+ remove?: false;
147
177
  }
148
178
  export type SignalType = "restapi" | "websocket";
149
179
  export type WebsocketReqData = {
@@ -40,7 +40,7 @@ export interface ProviderProps {
40
40
  /** Root route component used by CSR page loading. */
41
41
  of: (props: unknown) => ReactNode | null;
42
42
  }
43
- export declare const Common: () => import("react/jsx-runtime").JSX.Element;
43
+ export declare const Common: () => null;
44
44
  export declare function ManifestLink({ manifest }: {
45
45
  manifest?: WebAppManifest;
46
46
  }): import("react/jsx-runtime").JSX.Element | null;
package/ui/Signal/Doc.tsx CHANGED
@@ -247,7 +247,7 @@ const Zone = ({ refName, fetch, openAll }: ZoneProps) => {
247
247
  const desc = dictText(l, `${refName}.modelDesc`);
248
248
  const entries = endpointEntriesOf(refName, fetch);
249
249
  const wsEntries = entries.filter(({ endpoint }) => isWsEndpoint(endpoint));
250
- const mcpEntries = entries.filter(({ endpoint }) => !mcpRefusalOf(endpoint));
250
+ const mcpEntries = entries.filter(({ key, endpoint }) => !mcpRefusalOf(endpoint, { refName, key }));
251
251
  return (
252
252
  <div className="flex break-after-page flex-col gap-6">
253
253
  <div className="flex flex-col gap-1">
@@ -110,7 +110,7 @@ const RestApiEndpoint = ({
110
110
  const [viewStatus, setViewStatus] = useState<"doc" | "test">("doc");
111
111
  const path = FetchClient.makeHttpUrl(endpointKey, endpoint, signalPrefix, new Map());
112
112
 
113
- const mcpRefusal = mcpRefusalOf(endpoint);
113
+ const mcpRefusal = mcpRefusalOf(endpoint, { refName, key: endpointKey });
114
114
  const guards = guardsOf(endpoint);
115
115
  const label = dictText(l, `${refName}.signal.${endpointKey}`);
116
116
  const desc = dictText(l, `${refName}.signal.${endpointKey}.desc`);
@@ -44,7 +44,7 @@ export interface ProviderProps {
44
44
  }
45
45
 
46
46
  export const Common = () => {
47
- return <></>;
47
+ return null;
48
48
  };
49
49
 
50
50
  export function ManifestLink({ manifest }: { manifest?: WebAppManifest }) {