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.
- package/common/TrustedProxy.ts +17 -0
- package/common/mcpExposure.ts +5 -0
- 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/akanServer.ts +60 -3
- package/server/di/diLifecycle.ts +108 -7
- package/server/mcp/McpAuth.ts +44 -10
- package/server/mcp/McpDispatcher.ts +1 -0
- package/server/mcp/McpRateLimiter.ts +88 -0
- package/server/mcp/McpRouter.ts +95 -21
- package/server/mcp/index.ts +1 -0
- package/server/oauth/OAuthClientIdMetadata.ts +43 -2
- package/server/oauth/OAuthMetadata.ts +4 -1
- package/server/oauth/OAuthRevocation.ts +60 -0
- package/server/oauth/OAuthToken.ts +6 -3
- package/server/oauth/index.ts +1 -0
- 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 +36 -5
- package/signal/types.ts +7 -0
- package/types/common/TrustedProxy.d.ts +7 -0
- package/types/common/mcpExposure.d.ts +2 -0
- package/types/server/akanApp.d.ts +11 -0
- package/types/server/akanServer.d.ts +27 -1
- package/types/server/di/diLifecycle.d.ts +12 -1
- package/types/server/mcp/McpAuth.d.ts +24 -8
- 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/OAuthClientIdMetadata.d.ts +22 -4
- package/types/server/oauth/OAuthMetadata.d.ts +3 -1
- package/types/server/oauth/OAuthRevocation.d.ts +31 -0
- package/types/server/oauth/OAuthToken.d.ts +5 -0
- package/types/server/oauth/index.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
|
@@ -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) {
|
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
|
|
|
@@ -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/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;
|
|
@@ -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
|
package/server/di/diLifecycle.ts
CHANGED
|
@@ -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(
|
|
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
|
|
193
|
-
serviceCandidates
|
|
194
|
-
modules
|
|
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.
|
package/server/mcp/McpAuth.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
183
|
+
return this.#props.resource ?? new URL(this.#props.path, this.publicOrigin(req)).href;
|
|
182
184
|
}
|
|
183
185
|
|
|
184
186
|
/**
|
|
185
|
-
* The
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
|
|
190
|
-
|
|
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.
|
|
196
|
-
*
|
|
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;
|
|
@@ -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
|
+
}
|