akanjs 3.0.0-alpha.100 → 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 (71) hide show
  1. package/common/TrustedProxy.ts +23 -1
  2. package/common/mcpExposure.ts +5 -0
  3. package/constant/cascadePaths.ts +20 -20
  4. package/constant/fieldInfo.ts +22 -16
  5. package/fetch/client/fetchClient.ts +8 -3
  6. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  7. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  8. package/package.json +1 -1
  9. package/server/akanApp.ts +19 -0
  10. package/server/akanAppHeaders.ts +1 -1
  11. package/server/akanOption.ts +7 -6
  12. package/server/akanServer.ts +61 -4
  13. package/server/di/diLifecycle.ts +108 -7
  14. package/server/di/utils.ts +1 -1
  15. package/server/index.ts +1 -0
  16. package/server/mcp/McpAuth.ts +107 -29
  17. package/server/mcp/McpDispatcher.ts +5 -3
  18. package/server/mcp/McpRateLimiter.ts +88 -0
  19. package/server/mcp/McpRouter.ts +117 -22
  20. package/server/mcp/index.ts +1 -0
  21. package/server/oauth/OAuthAuthorize.ts +84 -0
  22. package/server/oauth/OAuthClientIdMetadata.ts +139 -0
  23. package/server/oauth/OAuthErrors.ts +77 -0
  24. package/server/oauth/OAuthMetadata.ts +59 -0
  25. package/server/oauth/OAuthPkce.ts +29 -0
  26. package/server/oauth/OAuthRedirect.ts +57 -0
  27. package/server/oauth/OAuthRegistration.ts +86 -0
  28. package/server/oauth/OAuthRevocation.ts +60 -0
  29. package/server/oauth/OAuthToken.ts +115 -0
  30. package/server/oauth/index.ts +10 -0
  31. package/server/oauth/oauthTypes.ts +79 -0
  32. package/server/resolver/CascadeRunner.ts +1 -1
  33. package/signal/guard.ts +14 -1
  34. package/signal/mcp/McpDocument.ts +38 -18
  35. package/signal/mcp/mcpProtocol.ts +2 -0
  36. package/signal/schema/JsonSchemaBuilder.ts +55 -18
  37. package/signal/serializer/fetch.serializer.ts +15 -0
  38. package/signal/signalContext.ts +39 -6
  39. package/signal/types.ts +7 -0
  40. package/types/common/TrustedProxy.d.ts +12 -0
  41. package/types/common/mcpExposure.d.ts +2 -0
  42. package/types/constant/cascadePaths.d.ts +4 -2
  43. package/types/constant/fieldInfo.d.ts +15 -8
  44. package/types/server/akanApp.d.ts +11 -0
  45. package/types/server/akanOption.d.ts +4 -3
  46. package/types/server/akanServer.d.ts +27 -1
  47. package/types/server/di/diLifecycle.d.ts +12 -1
  48. package/types/server/di/utils.d.ts +1 -1
  49. package/types/server/index.d.ts +1 -0
  50. package/types/server/mcp/McpAuth.d.ts +52 -13
  51. package/types/server/mcp/McpRateLimiter.d.ts +38 -0
  52. package/types/server/mcp/McpRouter.d.ts +9 -0
  53. package/types/server/mcp/index.d.ts +1 -0
  54. package/types/server/oauth/OAuthAuthorize.d.ts +26 -0
  55. package/types/server/oauth/OAuthClientIdMetadata.d.ts +53 -0
  56. package/types/server/oauth/OAuthErrors.d.ts +40 -0
  57. package/types/server/oauth/OAuthMetadata.d.ts +37 -0
  58. package/types/server/oauth/OAuthPkce.d.ts +9 -0
  59. package/types/server/oauth/OAuthRedirect.d.ts +20 -0
  60. package/types/server/oauth/OAuthRegistration.d.ts +21 -0
  61. package/types/server/oauth/OAuthRevocation.d.ts +31 -0
  62. package/types/server/oauth/OAuthToken.d.ts +35 -0
  63. package/types/server/oauth/index.d.ts +10 -0
  64. package/types/server/oauth/oauthTypes.d.ts +49 -0
  65. package/types/server/oauth.d.ts +1 -0
  66. package/types/signal/guard.d.ts +9 -0
  67. package/types/signal/mcp/McpDocument.d.ts +12 -3
  68. package/types/signal/mcp/mcpProtocol.d.ts +2 -0
  69. package/types/signal/schema/JsonSchemaBuilder.d.ts +24 -3
  70. package/types/signal/signalContext.d.ts +6 -0
  71. package/types/signal/types.d.ts +7 -0
@@ -51,9 +51,14 @@ export class TrustedProxy {
51
51
  /**
52
52
  * The caller's address as recorded by a proxy, but only when the peer is one. Falls back to the peer itself,
53
53
  * which is the right answer for a process nothing is proxying, and `null` when there is no peer either.
54
+ *
55
+ * `null` and `undefined` mean different things here. `null` is what `Server.requestIP` answers for a socket
56
+ * that has no address — a unix socket, which is how a gateway reaches its children — and only a process on
57
+ * this machine can open one, so the headers it wrote are a local proxy's and are believed. `undefined` is
58
+ * "nobody asked": no resolver was registered, and a header from an unknown peer is a header the client wrote.
54
59
  */
55
60
  static clientAddress(headers: Headers, peerAddress: string | null | undefined): string | null {
56
- if (TrustedProxy.isTrusted(peerAddress)) {
61
+ if (peerAddress === null || TrustedProxy.isTrusted(peerAddress)) {
57
62
  const realIp = headers.get("x-real-ip")?.trim();
58
63
  if (realIp) return normalizeIpAddress(realIp);
59
64
  const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
@@ -62,6 +67,23 @@ export class TrustedProxy {
62
67
  return peerAddress ? normalizeIpAddress(peerAddress) : null;
63
68
  }
64
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
+
65
87
  /** `10/8`, `172.16/12`, `192.168/16`, `127/8`, `169.254/16`, and their IPv6 counterparts `::1`, `fc00::/7`, `fe80::/10`. */
66
88
  static #isLocalRange(bytes: Uint8Array): boolean {
67
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
 
@@ -6,8 +6,10 @@ import type { ConstantModelRef } from "./via";
6
6
  * Which end of a relation goes away with the other. `removeRef` removes what the field points at when this
7
7
  * document is removed; `removeWith` removes this document when what the field points at is removed. The two
8
8
  * read identically on a relation field, so the value has to name the direction — a mistake here is a data loss.
9
+ * `removeWithAny` is `removeWith` over an owner that is unknowable at build time, and it names the whole
10
+ * decision in one value so the widening cannot be declared apart from the direction it widens.
9
11
  */
10
- export const cascadeActions = ["removeRef", "removeWith"] as const;
12
+ export const cascadeActions = ["removeRef", "removeWith", "removeWithAny"] as const;
11
13
  export type CascadeAction = (typeof cascadeActions)[number];
12
14
 
13
15
  /** How a `removeWith` field names the owner whose removal takes this document with it. */
@@ -21,7 +23,7 @@ export interface CascadeWithPath {
21
23
  readonly typeKey: string | null;
22
24
  /** The refNames `typeKey` may hold. Empty unless the field is polymorphic over an enum. */
23
25
  readonly typeValues: readonly string[];
24
- /** Set by `polymorphic: "any"`: `typeKey` is free-form, so the owner is whatever refName the row holds. */
26
+ /** Set by `cascade: "removeWithAny"`: `typeKey` is free-form, so the owner is whatever refName the row holds. */
25
27
  readonly anyOwner: boolean;
26
28
  }
27
29
 
@@ -35,7 +37,6 @@ export class CascadePaths {
35
37
 
36
38
  collect(fieldMap: FieldObject) {
37
39
  for (const [key, field] of Object.entries(fieldMap)) {
38
- this.#assertPolymorphicIsWiredUp(key, field);
39
40
  if (!field.cascade) continue;
40
41
  this.#assertKnownAction(key, field.cascade);
41
42
  if (field.cascade === "removeRef") this.removeRef.set(key, this.#readOwnedRelation(key, field));
@@ -44,16 +45,6 @@ export class CascadePaths {
44
45
  return this;
45
46
  }
46
47
 
47
- /** `polymorphic` widens one thing and nothing else, so anywhere else it is a declaration that does nothing. */
48
- #assertPolymorphicIsWiredUp(key: string, field: ConstantField) {
49
- if (!field.polymorphic) return;
50
- if (field.cascade === "removeWith" && field.refPath) return;
51
- throw new Error(
52
- `Field "${key}" declares polymorphic: "${field.polymorphic}", which only widens a ` +
53
- `cascade: "removeWith" field that names its owner type with refPath`,
54
- );
55
- }
56
-
57
48
  #assertKnownAction(key: string, action: CascadeAction) {
58
49
 
59
50
  if (!cascadeActions.includes(action)) {
@@ -74,7 +65,15 @@ export class CascadePaths {
74
65
 
75
66
  if (field.arrDepth > 0) throw new Error(`Cascade field "${key}" is an array and names more than one owner`);
76
67
  if (field.isMap) throw new Error(`Cascade field "${key}" is a Map and names no owner`);
77
- if (field.refPath) return this.#readPolymorphicOwner(key, field, fieldMap);
68
+ const anyOwner = field.cascade === "removeWithAny";
69
+ if (field.refPath) return this.#readPolymorphicOwner(key, field, fieldMap, anyOwner);
70
+
71
+ if (anyOwner) {
72
+ throw new Error(
73
+ `Cascade field "${key}" declares cascade: "removeWithAny" and must name the field holding the owner's ` +
74
+ `refName with refPath: "<typeField>"`,
75
+ );
76
+ }
78
77
  if (field.ref) {
79
78
  this.#assertHoldsId(key, field);
80
79
  return { key, modelRef: null, refName: field.ref, typeKey: null, typeValues: [], anyOwner: false };
@@ -88,18 +87,18 @@ export class CascadePaths {
88
87
  );
89
88
  }
90
89
 
91
- #readPolymorphicOwner(key: string, field: ConstantField, fieldMap: FieldObject): CascadeWithPath {
90
+ #readPolymorphicOwner(key: string, field: ConstantField, fieldMap: FieldObject, anyOwner: boolean): CascadeWithPath {
92
91
  if (field.ref) throw new Error(`Cascade field "${key}" declares both ref and refPath; keep one`);
93
92
  this.#assertHoldsId(key, field);
94
93
  const typeKey = field.refPath as string;
95
94
  const typeField = fieldMap[typeKey];
96
95
  if (!typeField) throw new Error(`Cascade field "${key}" declares refPath: "${typeKey}", which is not a field`);
97
- if (field.polymorphic === "any") return this.#readAnyOwner(key, typeKey, typeField);
96
+ if (anyOwner) return this.#readAnyOwner(key, typeKey, typeField);
98
97
 
99
98
  if (!typeField.enum) {
100
99
  throw new Error(
101
100
  `Cascade field "${key}" declares refPath: "${typeKey}", which must be an enumOf(...) naming the owner ` +
102
- `refNames it may hold; declare polymorphic: "any" to pay for a sweep on every removal instead`,
101
+ `refNames it may hold; declare cascade: "removeWithAny" to pay for a sweep on every removal instead`,
103
102
  );
104
103
  }
105
104
  const typeValues = typeField.enum.values.map((value) => String(value));
@@ -110,14 +109,15 @@ export class CascadePaths {
110
109
 
111
110
  if (typeField.enum) {
112
111
  throw new Error(
113
- `Cascade field "${key}" declares polymorphic: "any" and a refPath naming an enumOf(...); keep one`,
112
+ `Cascade field "${key}" declares cascade: "removeWithAny" and a refPath naming an enumOf(...), which ` +
113
+ `already names its owners; use cascade: "removeWith"`,
114
114
  );
115
115
  }
116
116
 
117
117
  if (this.#primitiveNameOf(typeField) !== "String") {
118
118
  throw new Error(
119
- `Cascade field "${key}" declares polymorphic: "any", so refPath: "${typeKey}" must be a String field ` +
120
- `holding the owner's refName`,
119
+ `Cascade field "${key}" declares cascade: "removeWithAny", so refPath: "${typeKey}" must be a String ` +
120
+ `field holding the owner's refName`,
121
121
  );
122
122
  }
123
123
  return { key, modelRef: null, refName: null, typeKey, typeValues: [], anyOwner: true };
@@ -106,11 +106,6 @@ export interface ConstantFieldProps<
106
106
  validate?: (value: FieldValue, model: any) => boolean;
107
107
  text?: TextFieldRole;
108
108
  cascade?: CascadeAction;
109
- /**
110
- * Widens a `cascade: "removeWith"` field whose `refPath` names a free-form `String` instead of an `enumOf`:
111
- * the owner is whatever refName the row happens to hold, found by sweeping at removal time.
112
- */
113
- polymorphic?: "any";
114
109
  /**
115
110
  * Renders on the page, never reaches an agent. Stripped wherever a value is masked for an AI caller — the
116
111
  * in-page agent's reads and every MCP result — and left untouched everywhere else, so a `File`'s blur
@@ -209,7 +204,6 @@ interface ConstantFieldBuildProps<
209
204
  validate?: (value: FieldValue, model: any) => boolean;
210
205
  text?: TextFieldRole;
211
206
  cascade?: CascadeAction;
212
- polymorphic?: "any";
213
207
  visual: boolean;
214
208
  modelRef: ConstantModelRef;
215
209
  arrDepth: number;
@@ -335,7 +329,6 @@ export class ConstantField<
335
329
  readonly validate?: (value: FieldValue, model: any) => boolean;
336
330
  readonly text?: TextFieldRole;
337
331
  readonly cascade?: CascadeAction;
338
- readonly polymorphic?: "any";
339
332
  readonly visual: boolean;
340
333
  readonly modelRef: ConstantModelRef;
341
334
  readonly arrDepth: number;
@@ -369,7 +362,6 @@ export class ConstantField<
369
362
  this.validate = props.validate;
370
363
  this.text = props.text;
371
364
  this.cascade = props.cascade;
372
- this.polymorphic = props.polymorphic;
373
365
  this.visual = props.visual;
374
366
  this.modelRef = props.modelRef;
375
367
  this.arrDepth = props.arrDepth;
@@ -450,7 +442,6 @@ export class ConstantField<
450
442
  validate: option.validate,
451
443
  text: option.text,
452
444
  cascade: option.cascade,
453
- polymorphic: option.polymorphic,
454
445
  visual: option.visual ?? false,
455
446
  modelRef,
456
447
  arrDepth: arrDepth,
@@ -498,7 +489,6 @@ export class ConstantField<
498
489
  validate: this.validate,
499
490
  text: this.text,
500
491
  cascade: this.cascade,
501
- polymorphic: this.polymorphic,
502
492
  visual: this.visual,
503
493
  modelRef: this.modelRef,
504
494
  arrDepth: this.arrDepth,
@@ -523,19 +513,35 @@ export interface FieldObject {
523
513
  */
524
514
  type WithoutTextRole<Option> = Option extends unknown ? Omit<Option, "text"> : never;
525
515
 
516
+ /**
517
+ * A wildcard owner is read off the row at removal time, so `removeWithAny` without a `refPath` names nothing to
518
+ * read it from — and the price of that action (no cascade in the app removes in one query) is one nobody should
519
+ * pay for a declaration that cascades nothing. Pairing the two in the option type refuses it at the call site;
520
+ * `CascadePaths` keeps the same refusal for the macro and bundled paths that reach it without a typecheck.
521
+ */
522
+ type CascadeOption =
523
+ | { cascade?: "removeRef" | "removeWith"; refPath?: string }
524
+ | { cascade: "removeWithAny"; refPath: string };
525
+
526
+ type WithCascadePair<Option> = Omit<Option, "cascade" | "refPath"> & CascadeOption;
527
+
526
528
  type FieldOption<
527
529
  Value extends ConstantFieldTypeInput,
528
530
  MapValue = Value extends MapConstructor ? typeof PrimitiveScalar : never,
529
531
  Metadata extends { [key: string]: any } = { [key: string]: any },
530
532
  _FieldToValue = FieldToValue<Value, MapValue> | null | undefined,
531
533
  > =
532
- | Omit<
533
- ConstantFieldProps<ConstantFieldKind, _FieldToValue, MapValue, Metadata>,
534
- "enum" | "meta" | "nullable" | "fieldType" | "select"
534
+ | WithCascadePair<
535
+ Omit<
536
+ ConstantFieldProps<ConstantFieldKind, _FieldToValue, MapValue, Metadata>,
537
+ "enum" | "meta" | "nullable" | "fieldType" | "select"
538
+ >
535
539
  >
536
- | Omit<
537
- ConstantFieldProps<ConstantFieldKind, SingleValue<_FieldToValue>, MapValue, Metadata>,
538
- "enum" | "meta" | "nullable" | "fieldType" | "select"
540
+ | WithCascadePair<
541
+ Omit<
542
+ ConstantFieldProps<ConstantFieldKind, SingleValue<_FieldToValue>, MapValue, Metadata>,
543
+ "enum" | "meta" | "nullable" | "fieldType" | "select"
544
+ >
539
545
  >[];
540
546
 
541
547
  export type PlainTypeToFieldType<PlainType> = PlainType extends [infer First, ...infer Rest]
@@ -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.100",
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",
@@ -23,7 +23,7 @@ export function makeAkanChildProxyHeaders(req: Request, childIdx: number, peer?:
23
23
  for (const key of HOP_BY_HOP_HEADERS) headers.delete(key);
24
24
  const forwardedFor = headers.get("x-forwarded-for");
25
25
 
26
- const clientAddress = TrustedProxy.clientAddress(headers, peer?.address);
26
+ const clientAddress = TrustedProxy.clientAddress(headers, peer === null ? null : peer?.address);
27
27
  const host = headers.get("host");
28
28
 
29
29
  if (clientAddress) {
@@ -20,7 +20,7 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
20
20
  readonly #adaptorOverrides: AdaptorOverride[] = [];
21
21
  readonly #webProxies: WebProxyRegistration[] = [];
22
22
  readonly #getLlms: ((env: Env) => LlmOption)[] = [];
23
- #mcp: boolean | McpServerOption | undefined;
23
+ #getMcp: ((env: Env) => boolean | McpServerOption) | undefined;
24
24
  #agentAccess: GuardCls | GuardCls[] | null | undefined;
25
25
  #crossSite: CrossSiteOption | undefined;
26
26
  constructor() {
@@ -48,10 +48,11 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
48
48
  /**
49
49
  * MCP server settings for the app mounting this option, merged over the `AKAN_MCP_*` environment and under an
50
50
  * option the server is constructed with. The app's own `option.ts` is the last lib the server reads, so it wins
51
- * over every library it depends on.
51
+ * over every library it depends on. The function form receives the server env, for a setting derived from it —
52
+ * a token verifier built on the app's signing secret, the issuer an authorization server publishes under.
52
53
  */
53
- setMcp(mcp: boolean | McpServerOption = true) {
54
- this.#mcp = mcp;
54
+ setMcp(mcpOrFn: boolean | McpServerOption | ((env: Env) => boolean | McpServerOption) = true) {
55
+ this.#getMcp = typeof mcpOrFn === "function" ? mcpOrFn : () => mcpOrFn;
55
56
  return this;
56
57
  }
57
58
  /**
@@ -91,8 +92,8 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
91
92
  getWebProxies(): WebProxyRegistration[] {
92
93
  return this.#webProxies;
93
94
  }
94
- getMcp(): boolean | McpServerOption | undefined {
95
- return this.#mcp;
95
+ getMcp(env: Env): boolean | McpServerOption | undefined {
96
+ return this.#getMcp?.(env);
96
97
  }
97
98
  getAgentAccess(): GuardCls | GuardCls[] | null | undefined {
98
99
  return this.#agentAccess;
@@ -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;
@@ -195,7 +221,7 @@ export class AkanServer {
195
221
  this.openapi = options?.openapi ?? this.openapi;
196
222
 
197
223
  libs.forEach((lib) => {
198
- const mcp = lib.option.getMcp();
224
+ const mcp = lib.option.getMcp(this.env);
199
225
  if (mcp !== undefined) this.setMcp(mcp);
200
226
  const agentAccess = lib.option.getAgentAccess();
201
227
  if (agentAccess !== undefined) AgentRelayAccess.use(agentAccess);
@@ -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