akanjs 3.0.0-alpha.101 → 3.0.0-alpha.102

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.
Files changed (46) hide show
  1. package/common/TrustedProxy.ts +17 -0
  2. package/common/mcpExposure.ts +5 -0
  3. package/fetch/client/fetchClient.ts +8 -3
  4. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  5. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  6. package/package.json +1 -1
  7. package/server/akanApp.ts +19 -0
  8. package/server/akanServer.ts +60 -3
  9. package/server/di/diLifecycle.ts +108 -7
  10. package/server/mcp/McpAuth.ts +44 -10
  11. package/server/mcp/McpDispatcher.ts +1 -0
  12. package/server/mcp/McpRateLimiter.ts +88 -0
  13. package/server/mcp/McpRouter.ts +95 -21
  14. package/server/mcp/index.ts +1 -0
  15. package/server/oauth/OAuthClientIdMetadata.ts +43 -2
  16. package/server/oauth/OAuthMetadata.ts +4 -1
  17. package/server/oauth/OAuthRevocation.ts +60 -0
  18. package/server/oauth/OAuthToken.ts +6 -3
  19. package/server/oauth/index.ts +1 -0
  20. package/signal/guard.ts +14 -1
  21. package/signal/mcp/McpDocument.ts +38 -18
  22. package/signal/mcp/mcpProtocol.ts +2 -0
  23. package/signal/schema/JsonSchemaBuilder.ts +55 -18
  24. package/signal/serializer/fetch.serializer.ts +15 -0
  25. package/signal/signalContext.ts +36 -5
  26. package/signal/types.ts +7 -0
  27. package/types/common/TrustedProxy.d.ts +7 -0
  28. package/types/common/mcpExposure.d.ts +2 -0
  29. package/types/server/akanApp.d.ts +11 -0
  30. package/types/server/akanServer.d.ts +27 -1
  31. package/types/server/di/diLifecycle.d.ts +12 -1
  32. package/types/server/mcp/McpAuth.d.ts +24 -8
  33. package/types/server/mcp/McpRateLimiter.d.ts +38 -0
  34. package/types/server/mcp/McpRouter.d.ts +9 -0
  35. package/types/server/mcp/index.d.ts +1 -0
  36. package/types/server/oauth/OAuthClientIdMetadata.d.ts +22 -4
  37. package/types/server/oauth/OAuthMetadata.d.ts +3 -1
  38. package/types/server/oauth/OAuthRevocation.d.ts +31 -0
  39. package/types/server/oauth/OAuthToken.d.ts +5 -0
  40. package/types/server/oauth/index.d.ts +1 -0
  41. package/types/signal/guard.d.ts +9 -0
  42. package/types/signal/mcp/McpDocument.d.ts +12 -3
  43. package/types/signal/mcp/mcpProtocol.d.ts +2 -0
  44. package/types/signal/schema/JsonSchemaBuilder.d.ts +24 -3
  45. package/types/signal/signalContext.d.ts +6 -0
  46. package/types/signal/types.d.ts +7 -0
@@ -67,6 +67,23 @@ export class TrustedProxy {
67
67
  return peerAddress ? normalizeIpAddress(peerAddress) : null;
68
68
  }
69
69
 
70
+ /**
71
+ * Whether an address is one no public name should resolve to: the local ranges `isTrusted` reads, plus the
72
+ * unspecified address, the carrier-grade NAT block `100.64/10`, multicast and the reserved top of IPv4. Used
73
+ * to refuse a fetch whose destination a caller chose by name, so it judges the address alone and never the
74
+ * configured proxy list — a trusted proxy is still not somewhere to send an outbound request.
75
+ */
76
+ static isPrivateAddress(address: string): boolean {
77
+ const bytes = TrustedProxy.#toBytes(normalizeIpAddress(address));
78
+ if (!bytes) return true;
79
+ if (TrustedProxy.#isLocalRange(bytes)) return true;
80
+ if (bytes.length === 4) {
81
+ const [a = 0, b = 0] = bytes;
82
+ return a === 0 || (a === 100 && b >= 64 && b <= 127) || a >= 224;
83
+ }
84
+ return bytes.every((byte) => byte === 0) || bytes[0] === 0xff;
85
+ }
86
+
70
87
  /** `10/8`, `172.16/12`, `192.168/16`, `127/8`, `169.254/16`, and their IPv6 counterparts `::1`, `fc00::/7`, `fe80::/10`. */
71
88
  static #isLocalRange(bytes: Uint8Array): boolean {
72
89
  if (bytes.length === 4) {
@@ -17,6 +17,8 @@ export interface McpExposureEndpoint {
17
17
  guards?: string[];
18
18
  fileUpload?: boolean;
19
19
  mcp?: boolean;
20
+ /** `false` when a guard declares `static agents = false`: no model may ever pass, whatever else the guards say. */
21
+ agents?: boolean;
20
22
  }
21
23
 
22
24
  export interface McpExposureOption {
@@ -68,6 +70,9 @@ export const mcpRefusalOf = (
68
70
  if (endpoint.mcp === false)
69
71
  return "it declares `mcp: false`, so it is deliberately off the agent shelf. HTTP still serves it.";
70
72
 
73
+ if (endpoint.agents === false)
74
+ return `its guards (${(endpoint.guards ?? []).join(", ")}) admit no agent — an act reserved for a person, so it is off the agent shelf. HTTP still serves it.`;
75
+
71
76
  if (!endpoint.guards?.length)
72
77
  return "it declares no guards, and exposure follows them — write `guards: [Public]` if anonymous access is the intent.";
73
78
 
@@ -456,8 +456,10 @@ export class FetchClient {
456
456
  const updateGuards = signal.updateGuards ?? signal.cruGuards;
457
457
  const removeGuards = signal.removeGuards ?? signal.cruGuards;
458
458
 
459
- const mcp = (verb: keyof NonNullable<SerializedSignal["mcp"]>) =>
460
- signal.mcp?.[verb] === false ? { mcp: false as const } : {};
459
+ const mcp = (verb: keyof NonNullable<SerializedSignal["mcp"]>) => ({
460
+ ...(signal.mcp?.[verb] === false ? { mcp: false as const } : {}),
461
+ ...(signal.agents?.[verb] === false ? { agents: false as const } : {}),
462
+ });
461
463
  const endpoint: { [key: string]: SerializedEndpoint } = {};
462
464
  if (signal.getGuards) {
463
465
  endpoint[names.model] = {
@@ -631,7 +633,10 @@ export class FetchClient {
631
633
  live: `${refName}Live${capSuffix}`,
632
634
  };
633
635
 
634
- const mcp = slice.mcp === false ? { mcp: false as const } : {};
636
+ const mcp = {
637
+ ...(slice.mcp === false ? { mcp: false as const } : {}),
638
+ ...(slice.agents === false ? { agents: false as const } : {}),
639
+ };
635
640
  const endpoint: { [key: string]: SerializedEndpoint } = {
636
641
  [names.list]: {
637
642
  type: "query",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.101",
3
+ "version": "3.0.0-alpha.102",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -96,6 +96,17 @@ export interface AkanAppOptions {
96
96
  * module. Handed down as `AKAN_MODULES`, since each replica builds its own container.
97
97
  */
98
98
  modules?: string[];
99
+ /**
100
+ * The inverse of `modules`: mount everything except these and whatever reaches them, in every child. Written
101
+ * for a lib the app depends on but does not serve — `server.ts` is generated from the dependency graph, so a
102
+ * lib cannot be dropped by editing it. Handed down as `AKAN_DISABLE_MODULES`, and applied after `modules`.
103
+ */
104
+ disableModules?: string[];
105
+ /**
106
+ * The same, by owning lib: every module the named libs registered stays out of every child, along with
107
+ * everything that reaches one. Handed down as `AKAN_DISABLE_LIBS`.
108
+ */
109
+ disableLibs?: string[];
99
110
  /**
100
111
  * Run the one replica in this process instead of spawning it — see `#startSolo`. Defaults on for a single
101
112
  * traffic replica the environment configured, and off whenever `replica` is passed here, because code that
@@ -141,6 +152,8 @@ export class AkanApp {
141
152
  /** The gateway hands `/_akan/client|styles|fonts` straight off disk, so it needs the same answer its children do. */
142
153
  readonly #web = getWebConfigFromEnv();
143
154
  readonly #modules: string[];
155
+ readonly #disableModules: string[];
156
+ readonly #disableLibs: string[];
144
157
  readonly #solo: boolean;
145
158
  readonly #children = new Map<number, ChildState>();
146
159
  readonly #roomChildren = new Map<string, Set<number>>();
@@ -182,6 +195,8 @@ export class AkanApp {
182
195
  this.#prefix = normalizeRoutePrefix(resolvedOptions.prefix) ?? getApiPrefix();
183
196
  this.#websocketPrefix = normalizeRoutePrefix(resolvedOptions.websocketPrefix) ?? getWsPrefix();
184
197
  this.#modules = resolvedOptions.modules ?? [];
198
+ this.#disableModules = resolvedOptions.disableModules ?? [];
199
+ this.#disableLibs = resolvedOptions.disableLibs ?? [];
185
200
  this.#solo = AkanApp.#resolveSolo(resolvedOptions, this.#replica);
186
201
  this.#logHub = LogHub.attach();
187
202
  }
@@ -322,6 +337,8 @@ export class AkanApp {
322
337
  AKAN_WS_PREFIX: this.#websocketPrefix,
323
338
  ...(this.#openapi === undefined ? {} : { AKAN_OPENAPI: this.#openapi ? "true" : "false" }),
324
339
  ...(this.#modules.length ? { AKAN_MODULES: this.#modules.join(",") } : {}),
340
+ ...(this.#disableModules.length ? { AKAN_DISABLE_MODULES: this.#disableModules.join(",") } : {}),
341
+ ...(this.#disableLibs.length ? { AKAN_DISABLE_LIBS: this.#disableLibs.join(",") } : {}),
325
342
  });
326
343
 
327
344
  resetEnvCache();
@@ -415,6 +432,8 @@ export class AkanApp {
415
432
  AKAN_WS_PREFIX: this.#websocketPrefix,
416
433
  ...(this.#openapi === undefined ? {} : { AKAN_OPENAPI: this.#openapi ? "true" : "false" }),
417
434
  ...(this.#modules.length ? { AKAN_MODULES: this.#modules.join(",") } : {}),
435
+ ...(this.#disableModules.length ? { AKAN_DISABLE_MODULES: this.#disableModules.join(",") } : {}),
436
+ ...(this.#disableLibs.length ? { AKAN_DISABLE_LIBS: this.#disableLibs.join(",") } : {}),
418
437
  },
419
438
  ipc: (message) => this.#handleMessage(idx, message as AkanIpcMessage, proc),
420
439
  stdout: "pipe",
@@ -33,7 +33,7 @@ import { LogForwarder } from "./logging/logForwarder";
33
33
  import { LogHub } from "./logging/logHub";
34
34
  import { LogStreamRoute } from "./logging/logStreamRoute";
35
35
  import { RotatingLogWriter } from "./logging/rotatingLogWriter";
36
- import { type McpAuthOption, McpRouter } from "./mcp";
36
+ import { type McpAuthOption, type McpRateLimitOption, McpRouter } from "./mcp";
37
37
  import { ProcessMetricsCollector } from "./processMetricsCollector";
38
38
  import { WebProxyRunner } from "./proxy";
39
39
  import { SignalResolver } from "./resolver";
@@ -65,6 +65,18 @@ export interface AkanServerOptions {
65
65
  * services, signals, routes and schedules do not exist. Omitted or empty mounts every enabled module.
66
66
  */
67
67
  modules?: string[];
68
+ /**
69
+ * The inverse of `modules`: mount everything except these and whatever reaches them. Written for a lib the
70
+ * app depends on but does not serve — `server.ts` is generated from the dependency graph, so a lib cannot be
71
+ * dropped by editing it. Applied after `modules`, so a module named by both stays out.
72
+ */
73
+ disableModules?: string[];
74
+ /**
75
+ * The same, by owning lib: every module the named libs registered stays out, along with everything that
76
+ * reaches one. This is the spelling for a lib the app depends on but does not serve — it does not drift as
77
+ * the lib gains modules.
78
+ */
79
+ disableLibs?: string[];
68
80
  }
69
81
 
70
82
  export interface McpServerOption {
@@ -106,8 +118,20 @@ export interface McpServerOption {
106
118
  * half turns this off and halves what each of those calls costs the model. `AKAN_MCP_LEGACY_TEXT=false`.
107
119
  */
108
120
  legacyTextBlock?: boolean;
121
+ /**
122
+ * How much of a result's shape each tool advertises: `shallow` (default) names nested models instead of inlining
123
+ * them, `full` inlines the whole closure, `none` publishes no `outputSchema` and keeps the text block on. The
124
+ * listing is re-sent to every agent that connects, and the nested closures were most of it. `AKAN_MCP_OUTPUT_SCHEMA`.
125
+ */
126
+ outputSchema?: "full" | "shallow" | "none";
109
127
  /** OAuth resource-server identity: which issuers a client may authenticate with, and the scopes to demand. */
110
128
  auth?: McpAuthOption;
129
+ /**
130
+ * Per-caller budget for `tools/call`, `resources/read` and `prompts/get`: 120 calls a minute and 8 in flight by
131
+ * default, counted per process. `false` takes it off. `AKAN_MCP_RATE_LIMIT` (`<calls>` or `<calls>/<seconds>`,
132
+ * `off` to disable) and `AKAN_MCP_CONCURRENT`.
133
+ */
134
+ rateLimit?: McpRateLimitOption | false;
111
135
  }
112
136
 
113
137
  interface AkanAppPrepared {
@@ -156,6 +180,8 @@ export class AkanServer {
156
180
  /** Resolved at `init`: what this process actually serves, after env and artifact availability. */
157
181
  web: AkanWebConfig = getWebConfigFromEnv();
158
182
  modules: string[];
183
+ disableModules: string[];
184
+ disableLibs: string[];
159
185
  shutdownTimeoutMs = AkanServer.#defaultShutdownTimeoutMs();
160
186
 
161
187
  #di: DiLifecycle;
@@ -206,7 +232,12 @@ export class AkanServer {
206
232
  this.serverMode = serverMode;
207
233
 
208
234
  this.modules = options?.modules ?? AkanServer.#envList("AKAN_MODULES") ?? [];
209
- this.#di = new DiLifecycle({ env: this.env, modules: this.modules }, ...libs);
235
+ this.disableModules = options?.disableModules ?? AkanServer.#envList("AKAN_DISABLE_MODULES") ?? [];
236
+ this.disableLibs = options?.disableLibs ?? AkanServer.#envList("AKAN_DISABLE_LIBS") ?? [];
237
+ this.#di = new DiLifecycle(
238
+ { env: this.env, modules: this.modules, disableModules: this.disableModules, disableLibs: this.disableLibs },
239
+ ...libs,
240
+ );
210
241
  }
211
242
  setPrefix(prefix: string) {
212
243
  if (this.status !== "stopped") throw new Error("Route prefix must be set before app initialization.");
@@ -813,7 +844,11 @@ export class AkanServer {
813
844
  !("database" in value) &&
814
845
  !("service" in value) &&
815
846
  !("scalar" in value) &&
816
- ("openapi" in value || "mcp" in value || "modules" in value),
847
+ ("openapi" in value ||
848
+ "mcp" in value ||
849
+ "modules" in value ||
850
+ "disableModules" in value ||
851
+ "disableLibs" in value),
817
852
  );
818
853
  }
819
854
 
@@ -884,9 +919,31 @@ export class AkanServer {
884
919
  ...(Number.isInteger(pageSize) && pageSize > 0 ? { pageSize } : {}),
885
920
  ...(process.env.AKAN_MCP_LANGUAGE ? { language: process.env.AKAN_MCP_LANGUAGE } : {}),
886
921
  ...(AkanServer.#isEnvOff("AKAN_MCP_LEGACY_TEXT") ? { legacyTextBlock: false } : {}),
922
+ ...AkanServer.#mcpOutputSchemaFromEnv(),
923
+ ...AkanServer.#mcpRateLimitFromEnv(),
887
924
  };
888
925
  }
889
926
 
927
+ /** `AKAN_MCP_RATE_LIMIT=off` disables; `120` is per minute, `120/30` per thirty seconds; `AKAN_MCP_CONCURRENT` caps in-flight calls. */
928
+ static #mcpRateLimitFromEnv(): Pick<McpServerOption, "rateLimit"> {
929
+ const raw = process.env.AKAN_MCP_RATE_LIMIT?.trim().toLowerCase();
930
+ if (raw === "off" || raw === "false" || raw === "0") return { rateLimit: false };
931
+ const option: McpRateLimitOption = {};
932
+ if (raw) {
933
+ const [calls, seconds] = raw.split("/").map((part) => Number(part));
934
+ if (Number.isInteger(calls) && calls > 0) option.calls = calls;
935
+ if (Number.isInteger(seconds) && seconds > 0) option.windowMs = seconds * 1000;
936
+ }
937
+ const concurrent = Number(process.env.AKAN_MCP_CONCURRENT);
938
+ if (Number.isInteger(concurrent) && concurrent >= 0) option.concurrent = concurrent;
939
+ return Object.keys(option).length ? { rateLimit: option } : {};
940
+ }
941
+
942
+ static #mcpOutputSchemaFromEnv(): Pick<McpServerOption, "outputSchema"> {
943
+ const value = process.env.AKAN_MCP_OUTPUT_SCHEMA;
944
+ return value === "full" || value === "shallow" || value === "none" ? { outputSchema: value } : {};
945
+ }
946
+
890
947
  /**
891
948
  * Both consumers build their path by concatenation — the route key, and the RFC 9728 metadata path the resource
892
949
  * identifier is inserted into — so `AKAN_MCP_PATH=mcp` served a route named `mcp` and published its metadata at
@@ -50,6 +50,17 @@ export interface DiLifecycleProps {
50
50
  * empty mounts every module whose service is enabled.
51
51
  */
52
52
  modules?: string[];
53
+ /**
54
+ * Drop these modules and everything that reaches them, leaving the rest mounted. Applied over `modules`
55
+ * rather than beside it, so a module named by both stays out.
56
+ */
57
+ disableModules?: string[];
58
+ /**
59
+ * The same, by owning lib: every database and service module the named libs registered goes, along with
60
+ * everything that reaches one. What a lib's `option.ts` contributes — middleware, web proxies, adaptor
61
+ * overrides — is untouched, as it is under `modules`.
62
+ */
63
+ disableLibs?: string[];
53
64
  }
54
65
 
55
66
  /**
@@ -100,7 +111,7 @@ export class DiLifecycle {
100
111
  return !names.some((name) => process.env[name] === "false" || process.env[name] === "0");
101
112
  }
102
113
 
103
- constructor({ env, modules = [] }: DiLifecycleProps, ...libs: AkanLib[]) {
114
+ constructor({ env, modules = [], disableModules = [], disableLibs = [] }: DiLifecycleProps, ...libs: AkanLib[]) {
104
115
  this.#env = env;
105
116
 
106
117
  this.#predefinedAdaptor = { ...getPredefinedAdaptor(getEnv().databaseMode ?? "single") };
@@ -122,6 +133,8 @@ export class DiLifecycle {
122
133
  this.webProxies.push(...defaultOption.getWebProxies());
123
134
  const databaseCandidates = new Map<string, DiModuleCandidate>();
124
135
  const serviceCandidates = new Map<string, DiModuleCandidate>();
136
+
137
+ const moduleLibs = new Map<string, string>();
125
138
  libs.forEach((lib) => {
126
139
  lib.option.getMiddlewares().forEach((middleware) => {
127
140
  this.#middleware.set(middleware.refName, middleware);
@@ -137,15 +150,24 @@ export class DiLifecycle {
137
150
  this.webProxies.push(...lib.option.getWebProxies());
138
151
  lib.database.forEach((mod) => {
139
152
  databaseCandidates.set(mod.constant.refName, { refName: mod.constant.refName, module: mod });
153
+ moduleLibs.set(mod.constant.refName, lib.name);
140
154
  });
141
155
  lib.service.forEach((mod) => {
142
156
  serviceCandidates.set(mod.service.srv.refName, { refName: mod.service.srv.refName, module: mod });
157
+ moduleLibs.set(mod.service.srv.refName, lib.name);
143
158
  });
144
159
  lib.scalar.forEach((mod) => {
145
160
  this.#scalar.set(mod.constant.refName, mod);
146
161
  });
147
162
  });
148
- const disabledModules = this.#resolveDisabledModules(databaseCandidates, serviceCandidates, modules);
163
+ const disabledModules = this.#resolveDisabledModules({
164
+ databaseCandidates,
165
+ serviceCandidates,
166
+ modules,
167
+ disableModules,
168
+ disableLibs,
169
+ moduleLibs,
170
+ });
149
171
  databaseCandidates.forEach(({ refName, module }) => {
150
172
  if (disabledModules.has(refName)) return;
151
173
  this.#database.set(refName, module as DatabaseModule);
@@ -188,11 +210,21 @@ export class DiLifecycle {
188
210
  assertUniqueRegistrations("adaptor", adaptorRegistrations);
189
211
  }
190
212
 
191
- #resolveDisabledModules(
192
- databaseCandidates: Map<string, DiModuleCandidate>,
193
- serviceCandidates: Map<string, DiModuleCandidate>,
194
- modules: string[],
195
- ) {
213
+ #resolveDisabledModules({
214
+ databaseCandidates,
215
+ serviceCandidates,
216
+ modules,
217
+ disableModules,
218
+ disableLibs,
219
+ moduleLibs,
220
+ }: {
221
+ databaseCandidates: Map<string, DiModuleCandidate>;
222
+ serviceCandidates: Map<string, DiModuleCandidate>;
223
+ modules: string[];
224
+ disableModules: string[];
225
+ disableLibs: string[];
226
+ moduleLibs: Map<string, string>;
227
+ }) {
196
228
  const candidates = new Map<string, DiModuleCandidate>([...databaseCandidates, ...serviceCandidates]);
197
229
  const disabledReasons = new Map<string, string>();
198
230
 
@@ -208,6 +240,13 @@ export class DiLifecycle {
208
240
  });
209
241
  }
210
242
 
243
+ const excluded = this.#resolveExcludedModules({ candidates, disableModules, disableLibs, moduleLibs });
244
+
245
+ excluded.forEach((reason, refName) => {
246
+ if (!disabledReasons.has(refName)) disabledReasons.set(refName, reason);
247
+ });
248
+ const excludedClosure = new Set(excluded.keys());
249
+
211
250
  let changed = true;
212
251
  while (changed) {
213
252
  changed = false;
@@ -218,6 +257,7 @@ export class DiLifecycle {
218
257
  const dependencyReason = disabledReasons.get(dependencyRefName);
219
258
  if (!dependencyReason) continue;
220
259
  disabledReasons.set(refName, `depends on disabled module "${dependencyRefName}"`);
260
+ if (excludedClosure.has(dependencyRefName)) excludedClosure.add(refName);
221
261
  changed = true;
222
262
  break;
223
263
  }
@@ -228,9 +268,70 @@ export class DiLifecycle {
228
268
  this.disabledModules.set(refName, reason);
229
269
  this.logger.verbose(`Skipping disabled module "${refName}": ${reason}`);
230
270
  });
271
+
272
+ const cascaded = [...excludedClosure]
273
+ .filter((refName) => !excluded.has(refName))
274
+ .sort((a, b) => a.localeCompare(b));
275
+ if (cascaded.length) {
276
+ const option = disableLibs.length
277
+ ? disableModules.length
278
+ ? "disableModules/disableLibs"
279
+ : "disableLibs"
280
+ : "disableModules";
281
+ this.logger.info(`${option} also dropped ${cascaded.length} dependent module(s): ${cascaded.join(", ")}`);
282
+ }
231
283
  return new Set(disabledReasons.keys());
232
284
  }
233
285
 
286
+ /**
287
+ * The modules the caller took off, by name and by owning lib, each mapped to the reason it is gone. An
288
+ * unknown name is refused for the mirror of the reason `modules` refuses one: a typo there drops a module
289
+ * silently, and a typo here keeps one running silently.
290
+ */
291
+ #resolveExcludedModules({
292
+ candidates,
293
+ disableModules,
294
+ disableLibs,
295
+ moduleLibs,
296
+ }: {
297
+ candidates: Map<string, DiModuleCandidate>;
298
+ disableModules: string[];
299
+ disableLibs: string[];
300
+ moduleLibs: Map<string, string>;
301
+ }) {
302
+ const excluded = new Map<string, string>();
303
+ if (disableModules.length) {
304
+ const known = new Set([...candidates.keys(), ...this.#service.keys()]);
305
+ const unknown = disableModules.filter((refName) => !known.has(refName));
306
+ if (unknown.length) {
307
+ const registered = [...known].sort((a, b) => a.localeCompare(b)).join(", ");
308
+ throw new Error(
309
+ `[DI:disableModules] unknown module ${unknown.map((refName) => `"${refName}"`).join(", ")}. Registered: ${registered}`,
310
+ );
311
+ }
312
+ disableModules.forEach((refName) => {
313
+ if (candidates.has(refName)) excluded.set(refName, 'named by the "disableModules" option');
314
+ });
315
+ }
316
+ if (disableLibs.length) {
317
+
318
+ const known = new Set(this.#libs.map((lib) => lib.name));
319
+ const unknown = disableLibs.filter((name) => !known.has(name));
320
+ if (unknown.length) {
321
+ const registered = [...known].sort((a, b) => a.localeCompare(b)).join(", ");
322
+ throw new Error(
323
+ `[DI:disableLibs] unknown lib ${unknown.map((name) => `"${name}"`).join(", ")}. Mounted: ${registered}`,
324
+ );
325
+ }
326
+ const excludedLibs = new Set(disableLibs);
327
+ moduleLibs.forEach((libName, refName) => {
328
+ if (!excludedLibs.has(libName) || !candidates.has(refName) || excluded.has(refName)) return;
329
+ excluded.set(refName, `in lib "${libName}", named by the "disableLibs" option`);
330
+ });
331
+ }
332
+ return excluded;
333
+ }
334
+
234
335
  /**
235
336
  * The named modules closed over everything they reach: the services and signals they inject, and the cascade
236
337
  * edges whose absence fails `CascadeRunner.seal`. `null` means no selection was asked for.
@@ -1,4 +1,6 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { PromiseOrObject } from "akanjs/base";
3
+ import { clientAddressFromHeaders } from "akanjs/common";
2
4
  import type { HttpRoutes } from "../types";
3
5
 
4
6
  export interface McpAuthOption {
@@ -170,7 +172,7 @@ export class McpAuth {
170
172
  return [
171
173
  "Bearer",
172
174
  [
173
- `resource_metadata="${new URL(McpAuth.wellKnownPath + this.#props.path, McpAuth.origin(req)).href}"`,
175
+ `resource_metadata="${new URL(McpAuth.wellKnownPath + this.#props.path, this.publicOrigin(req)).href}"`,
174
176
  ...(failure ? [`error="${failure.error}"`, `error_description="${failure.description}"`] : []),
175
177
  ...(scopes.length ? [`scope="${scopes.join(" ")}"`] : []),
176
178
  ].join(", "),
@@ -178,22 +180,37 @@ export class McpAuth {
178
180
  }
179
181
 
180
182
  #resource(req: Request) {
181
- return this.#props.resource ?? new URL(this.#props.path, McpAuth.origin(req)).href;
183
+ return this.#props.resource ?? new URL(this.#props.path, this.publicOrigin(req)).href;
182
184
  }
183
185
 
184
186
  /**
185
- * The public origin, not the one the request arrived on. Behind a proxy `req.url` names the internal host the
186
- * proxy dialed, and publishing that as the resource identifier is not cosmetic: an authorization server issues
187
- * `aud` for the URL the *client* used, so every correctly-issued token would then fail the audience check.
188
- *
189
- * `McpRouter` compares an `Origin` header against this too — the same proxy that makes `req.url` wrong for the
190
- * metadata document makes it wrong for a same-origin check, and there the cost is a flat 403.
187
+ * The origin this server is known by. A configured `resource` settles it — that URL is what an authorization
188
+ * server issued `aud` for, so nothing a request says can be more authoritative — and only a server with none
189
+ * falls back to what the request reports. Every place that names the server to a client reads this: the
190
+ * resource identifier, the `resource_metadata` URL in the challenge, and `McpRouter`'s same-origin check.
191
+ */
192
+ publicOrigin(req: Request) {
193
+ if (this.#props.resource) {
194
+ try {
195
+ return new URL(this.#props.resource).origin;
196
+ } catch {
197
+ }
198
+ }
199
+ return McpAuth.origin(req);
200
+ }
201
+
202
+ /**
203
+ * The public origin as the request reports it, not the one the request arrived on. Behind a proxy `req.url`
204
+ * names the internal host the proxy dialed, and publishing that as the resource identifier is not cosmetic: an
205
+ * authorization server issues `aud` for the URL the *client* used, so every correctly-issued token would then
206
+ * fail the audience check.
191
207
  *
192
208
  * `x-forwarded-host` is a request header, so this is exactly as trustworthy as the edge that overwrites it, and
193
209
  * a deployment whose edge merely appends leaves both the same-origin comparison and the audience a token is
194
210
  * checked against to the caller. A browser cannot reach it — a custom header is not on a preflight, and the
195
- * `OPTIONS` is judged on the real host — but a direct caller can. `AKAN_MCP_RESOURCE` pins the identifier for a
196
- * deployment that cannot make the guarantee.
211
+ * `OPTIONS` is judged on the real host — but a direct caller can. That is why `publicOrigin` prefers a
212
+ * configured `resource` (`AKAN_MCP_RESOURCE`, or what `libs/shared` derives from its issuer) and reads this only
213
+ * for a server that pinned nothing.
197
214
  */
198
215
  static origin(req: Request) {
199
216
  const url = new URL(req.url);
@@ -202,6 +219,23 @@ export class McpAuth {
202
219
  return `${forwarded("x-forwarded-proto") ?? url.protocol.replace(":", "")}://${host}`;
203
220
  }
204
221
 
222
+ /**
223
+ * Who a rate limit counts. A bearer token is one caller however many connections it opens: its session id,
224
+ * failing that its token id or subject, failing that the token itself hashed — an opaque token has nothing else.
225
+ * With no token the caller is the address a proxy recorded, and callers with no recorded address share one
226
+ * bucket, which is the right shape for a server that nothing fronts and that admits anonymous calls.
227
+ *
228
+ * Read unverified on purpose: this runs after `reject`, so a token that reaches it has already passed whatever
229
+ * verification the server has, and a key needs no more than that.
230
+ */
231
+ static callerKey(req: Request): string {
232
+ const token = McpAuth.#bearer(req);
233
+ if (!token) return `ip:${clientAddressFromHeaders(req.headers) ?? "anonymous"}`;
234
+ const claims = McpAuth.#claims(token);
235
+ const id = [claims?.sid, claims?.jti, claims?.sub].find((value) => typeof value === "string" && value);
236
+ return typeof id === "string" ? `token:${id}` : `token:${createHash("sha256").update(token).digest("base64url")}`;
237
+ }
238
+
205
239
  static #bearer(req: Request): string | null {
206
240
  const [scheme, token] = (req.headers.get("authorization") ?? "").split(" ");
207
241
  return scheme === "Bearer" && token ? token : null;
@@ -154,6 +154,7 @@ export class McpDispatcher {
154
154
  endpointInfo: found.endpointInfo,
155
155
  adaptor: found.endpoint,
156
156
  ctx: new McpExecutionContext(req, {}),
157
+ origin: "mcp",
157
158
  }).canListForAccount();
158
159
  cached.set(key, verdict);
159
160
  return await verdict;
@@ -0,0 +1,88 @@
1
+ export interface McpRateLimitOption {
2
+ /** Calls one caller may make per window. `0` turns the counter off. */
3
+ calls?: number;
4
+ windowMs?: number;
5
+ /** Calls one caller may have running at the same time. `0` turns the cap off. */
6
+ concurrent?: number;
7
+ }
8
+
9
+ export type McpRateLimitVerdict =
10
+ | { ok: true; release: () => void }
11
+ | { ok: false; reason: "calls" | "concurrent"; retryAfterMs: number };
12
+
13
+ interface McpRateBucket {
14
+ windowStart: number;
15
+ count: number;
16
+ inFlight: number;
17
+ }
18
+
19
+ /**
20
+ * Per-caller budget for the MCP methods that execute an endpoint.
21
+ *
22
+ * Two limits because they stop two different loops. A fixed window of `calls` stops a model that re-reads a list
23
+ * slice as fast as the client lets it; a cap on `concurrent` calls stops one that fans out a ten-second query in
24
+ * parallel, which the window alone would admit until the window filled. Counted in this process only — a
25
+ * deployment with N replicas grants N budgets — and keyed by the caller the router derives, so a reset is a
26
+ * restart and nothing else.
27
+ */
28
+ export class McpRateLimiter {
29
+ static readonly defaults = { calls: 120, windowMs: 60_000, concurrent: 8 } as const;
30
+
31
+ readonly calls: number;
32
+ readonly windowMs: number;
33
+ readonly concurrent: number;
34
+ readonly #buckets = new Map<string, McpRateBucket>();
35
+ #sweptAt = 0;
36
+
37
+ constructor({
38
+ calls = McpRateLimiter.defaults.calls,
39
+ windowMs = McpRateLimiter.defaults.windowMs,
40
+ concurrent = McpRateLimiter.defaults.concurrent,
41
+ }: McpRateLimitOption = {}) {
42
+ this.calls = Math.max(0, Math.floor(calls));
43
+ this.windowMs = Math.max(1, Math.floor(windowMs));
44
+ this.concurrent = Math.max(0, Math.floor(concurrent));
45
+ }
46
+
47
+ acquire(key: string, now = Date.now()): McpRateLimitVerdict {
48
+ this.#sweep(now);
49
+ const bucket = this.#bucketOf(key, now);
50
+ if (this.concurrent && bucket.inFlight >= this.concurrent)
51
+ return { ok: false, reason: "concurrent", retryAfterMs: 1_000 };
52
+ if (this.calls && bucket.count >= this.calls)
53
+ return { ok: false, reason: "calls", retryAfterMs: Math.max(1, bucket.windowStart + this.windowMs - now) };
54
+ bucket.count += 1;
55
+ bucket.inFlight += 1;
56
+ let released = false;
57
+ return {
58
+ ok: true,
59
+ release: () => {
60
+ if (released) return;
61
+ released = true;
62
+ bucket.inFlight = Math.max(0, bucket.inFlight - 1);
63
+ },
64
+ };
65
+ }
66
+
67
+ describe() {
68
+ const window = this.calls ? `${this.calls} calls / ${Math.round(this.windowMs / 1000)}s` : "unlimited calls";
69
+ const inFlight = this.concurrent ? `${this.concurrent} in flight` : "unlimited in flight";
70
+ return `${window}, ${inFlight}, per caller`;
71
+ }
72
+
73
+ #bucketOf(key: string, now: number) {
74
+ const found = this.#buckets.get(key);
75
+ if (found && now - found.windowStart < this.windowMs) return found;
76
+ const bucket: McpRateBucket = { windowStart: now, count: 0, inFlight: found?.inFlight ?? 0 };
77
+ this.#buckets.set(key, bucket);
78
+ return bucket;
79
+ }
80
+
81
+ /** Buckets are dropped once idle for a window, so the map is bounded by the callers of the last window, not ever. */
82
+ #sweep(now: number) {
83
+ if (now - this.#sweptAt < this.windowMs) return;
84
+ this.#sweptAt = now;
85
+ for (const [key, bucket] of this.#buckets)
86
+ if (!bucket.inFlight && now - bucket.windowStart >= this.windowMs) this.#buckets.delete(key);
87
+ }
88
+ }