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.
- package/common/TrustedProxy.ts +23 -1
- package/common/mcpExposure.ts +5 -0
- package/constant/cascadePaths.ts +20 -20
- package/constant/fieldInfo.ts +22 -16
- package/fetch/client/fetchClient.ts +8 -3
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/server/akanApp.ts +19 -0
- package/server/akanAppHeaders.ts +1 -1
- package/server/akanOption.ts +7 -6
- package/server/akanServer.ts +61 -4
- package/server/di/diLifecycle.ts +108 -7
- package/server/di/utils.ts +1 -1
- package/server/index.ts +1 -0
- package/server/mcp/McpAuth.ts +107 -29
- package/server/mcp/McpDispatcher.ts +5 -3
- package/server/mcp/McpRateLimiter.ts +88 -0
- package/server/mcp/McpRouter.ts +117 -22
- package/server/mcp/index.ts +1 -0
- package/server/oauth/OAuthAuthorize.ts +84 -0
- package/server/oauth/OAuthClientIdMetadata.ts +139 -0
- package/server/oauth/OAuthErrors.ts +77 -0
- package/server/oauth/OAuthMetadata.ts +59 -0
- package/server/oauth/OAuthPkce.ts +29 -0
- package/server/oauth/OAuthRedirect.ts +57 -0
- package/server/oauth/OAuthRegistration.ts +86 -0
- package/server/oauth/OAuthRevocation.ts +60 -0
- package/server/oauth/OAuthToken.ts +115 -0
- package/server/oauth/index.ts +10 -0
- package/server/oauth/oauthTypes.ts +79 -0
- package/server/resolver/CascadeRunner.ts +1 -1
- package/signal/guard.ts +14 -1
- package/signal/mcp/McpDocument.ts +38 -18
- package/signal/mcp/mcpProtocol.ts +2 -0
- package/signal/schema/JsonSchemaBuilder.ts +55 -18
- package/signal/serializer/fetch.serializer.ts +15 -0
- package/signal/signalContext.ts +39 -6
- package/signal/types.ts +7 -0
- package/types/common/TrustedProxy.d.ts +12 -0
- package/types/common/mcpExposure.d.ts +2 -0
- package/types/constant/cascadePaths.d.ts +4 -2
- package/types/constant/fieldInfo.d.ts +15 -8
- package/types/server/akanApp.d.ts +11 -0
- package/types/server/akanOption.d.ts +4 -3
- package/types/server/akanServer.d.ts +27 -1
- package/types/server/di/diLifecycle.d.ts +12 -1
- package/types/server/di/utils.d.ts +1 -1
- package/types/server/index.d.ts +1 -0
- package/types/server/mcp/McpAuth.d.ts +52 -13
- package/types/server/mcp/McpRateLimiter.d.ts +38 -0
- package/types/server/mcp/McpRouter.d.ts +9 -0
- package/types/server/mcp/index.d.ts +1 -0
- package/types/server/oauth/OAuthAuthorize.d.ts +26 -0
- package/types/server/oauth/OAuthClientIdMetadata.d.ts +53 -0
- package/types/server/oauth/OAuthErrors.d.ts +40 -0
- package/types/server/oauth/OAuthMetadata.d.ts +37 -0
- package/types/server/oauth/OAuthPkce.d.ts +9 -0
- package/types/server/oauth/OAuthRedirect.d.ts +20 -0
- package/types/server/oauth/OAuthRegistration.d.ts +21 -0
- package/types/server/oauth/OAuthRevocation.d.ts +31 -0
- package/types/server/oauth/OAuthToken.d.ts +35 -0
- package/types/server/oauth/index.d.ts +10 -0
- package/types/server/oauth/oauthTypes.d.ts +49 -0
- package/types/server/oauth.d.ts +1 -0
- package/types/signal/guard.d.ts +9 -0
- package/types/signal/mcp/McpDocument.d.ts +12 -3
- package/types/signal/mcp/mcpProtocol.d.ts +2 -0
- package/types/signal/schema/JsonSchemaBuilder.d.ts +24 -3
- package/types/signal/signalContext.d.ts +6 -0
- package/types/signal/types.d.ts +7 -0
package/common/TrustedProxy.ts
CHANGED
|
@@ -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) {
|
package/common/mcpExposure.ts
CHANGED
|
@@ -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
|
|
package/constant/cascadePaths.ts
CHANGED
|
@@ -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 `
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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 };
|
package/constant/fieldInfo.ts
CHANGED
|
@@ -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
|
-
|
|
|
533
|
-
|
|
534
|
-
|
|
534
|
+
| WithCascadePair<
|
|
535
|
+
Omit<
|
|
536
|
+
ConstantFieldProps<ConstantFieldKind, _FieldToValue, MapValue, Metadata>,
|
|
537
|
+
"enum" | "meta" | "nullable" | "fieldType" | "select"
|
|
538
|
+
>
|
|
535
539
|
>
|
|
536
|
-
|
|
|
537
|
-
|
|
538
|
-
|
|
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 =
|
|
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",
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
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",
|
package/server/akanAppHeaders.ts
CHANGED
|
@@ -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) {
|
package/server/akanOption.ts
CHANGED
|
@@ -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
|
-
#
|
|
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(
|
|
54
|
-
this.#
|
|
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.#
|
|
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;
|
package/server/akanServer.ts
CHANGED
|
@@ -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
|
|
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 ||
|
|
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
|