@cyanmycelium/mcp-broker 0.4.0 → 1.2.0

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 (151) hide show
  1. package/.mcp-broker.example/CONFIGURATION-EN.md +861 -0
  2. package/.mcp-broker.example/CONFIGURATION-FR.md +871 -0
  3. package/.mcp-broker.example/README.md +8 -3
  4. package/.mcp-broker.example/config.json +86 -0
  5. package/README.md +122 -3
  6. package/dist/bin.d.ts +0 -1
  7. package/dist/bin.js +180 -208
  8. package/dist/bin.js.map +1 -1
  9. package/dist/chunk-FTDKH2C4.js +3670 -0
  10. package/dist/chunk-FTDKH2C4.js.map +1 -0
  11. package/dist/{broker/grammars → grammars}/claude/en.json +1 -1
  12. package/dist/{broker/grammars → grammars}/claude/fr.json +1 -1
  13. package/dist/index.d.ts +1790 -18
  14. package/dist/index.js +2 -14
  15. package/dist/index.js.map +1 -1
  16. package/package.json +14 -8
  17. package/scripts/copy-assets.mjs +16 -10
  18. package/scripts/gen-cert.mjs +5 -5
  19. package/scripts/pack-mcpb.mjs +5 -5
  20. package/scripts/sign-bundle.mjs +6 -6
  21. package/src/auth/auth.config.ts +98 -0
  22. package/src/auth/auth.types.ts +120 -0
  23. package/src/auth/http.auth.ts +128 -0
  24. package/src/auth/index.ts +34 -0
  25. package/src/auth/jwt.validator.ts +63 -0
  26. package/src/auth/provider.auth.ts +114 -0
  27. package/src/auth/resource.metadata.ts +34 -0
  28. package/src/authorization/audit.ts +35 -0
  29. package/src/authorization/capability.classifier.ts +114 -0
  30. package/src/authorization/index.ts +37 -0
  31. package/src/authorization/policy.engine.ts +212 -0
  32. package/src/authorization/policy.types.ts +105 -0
  33. package/src/authorization/resource.path.ts +126 -0
  34. package/src/authorization/runtime.ts +56 -0
  35. package/src/authorization/slot.resource.ts +50 -0
  36. package/src/authorization/subject.mapper.ts +81 -0
  37. package/src/bin.ts +90 -7
  38. package/src/broker/adapters/broker.adapter.info.ts +3 -3
  39. package/src/broker/adapters/broker.adapter.providers.ts +3 -3
  40. package/src/broker/aggregate/aggregate.catalog.ts +49 -21
  41. package/src/broker/aggregate/aggregate.server.ts +149 -19
  42. package/src/broker/aggregate/provider.client.session.ts +22 -22
  43. package/src/broker/behaviors/broker.behavior.info.ts +4 -4
  44. package/src/broker/behaviors/broker.behavior.providers.ts +6 -6
  45. package/src/broker/broker.context.ts +10 -4
  46. package/src/broker/broker.grammars.ts +16 -13
  47. package/src/broker/broker.server.ts +12 -9
  48. package/src/broker/grammars/claude/en.json +1 -1
  49. package/src/broker/grammars/claude/fr.json +1 -1
  50. package/src/broker/index.ts +9 -9
  51. package/src/config.ts +81 -8
  52. package/src/index.ts +121 -20
  53. package/src/{mcpb.loader.ts → mcpb/mcpb.loader.ts} +24 -21
  54. package/src/{mcpb.unzip.ts → mcpb/mcpb.unzip.ts} +2 -2
  55. package/src/remote.transports.ts +11 -8
  56. package/src/remote.upstream.ts +11 -8
  57. package/src/stdio.upstream.ts +9 -6
  58. package/src/upstream.ts +6 -3
  59. package/src/ws/ws.interfaces.ts +357 -0
  60. package/src/{ws.tunnel.builder.ts → ws/ws.tunnel.builder.ts} +112 -9
  61. package/src/{ws.tunnel.ts → ws/ws.tunnel.ts} +646 -457
  62. package/web/README.md +94 -0
  63. package/web/assets/logo.png +0 -0
  64. package/web/broker-self-mcp.html +338 -0
  65. package/web/css/styles.css +580 -0
  66. package/web/demos/DemoPlaceholder.html +258 -0
  67. package/web/demos/broker-explorer/css/app.css +393 -0
  68. package/web/demos/broker-explorer/index.html +94 -0
  69. package/web/demos/broker-explorer/js/app.js +271 -0
  70. package/web/demos/broker-explorer/js/mcp-ws-client.js +132 -0
  71. package/web/demos/oauth-lab/README.md +94 -0
  72. package/web/demos/oauth-lab/config.json +117 -0
  73. package/web/demos/oauth-lab/css/app.css +1097 -0
  74. package/web/demos/oauth-lab/index.html +323 -0
  75. package/web/demos/oauth-lab/js/app.js +654 -0
  76. package/web/demos/oauth-lab/server/auth-server.mjs +426 -0
  77. package/web/demos/oauth-lab/server/factory-provider.mjs +269 -0
  78. package/web/demos/oauth-lab/server/smoke-test.mjs +308 -0
  79. package/web/demos/oauth-lab/server/start.mjs +106 -0
  80. package/web/demos/provider-tunnel/css/app.css +384 -0
  81. package/web/demos/provider-tunnel/index.html +99 -0
  82. package/web/demos/provider-tunnel/js/app.js +226 -0
  83. package/web/demos/provider-tunnel/js/toolbox-server.js +186 -0
  84. package/web/index.html +558 -0
  85. package/web/js/lib/broker-tunnel.js +173 -0
  86. package/dist/broker/adapters/broker.adapter.info.d.ts +0 -16
  87. package/dist/broker/adapters/broker.adapter.info.js +0 -43
  88. package/dist/broker/adapters/broker.adapter.info.js.map +0 -1
  89. package/dist/broker/adapters/broker.adapter.providers.d.ts +0 -18
  90. package/dist/broker/adapters/broker.adapter.providers.js +0 -61
  91. package/dist/broker/adapters/broker.adapter.providers.js.map +0 -1
  92. package/dist/broker/aggregate/aggregate.catalog.d.ts +0 -54
  93. package/dist/broker/aggregate/aggregate.catalog.js +0 -105
  94. package/dist/broker/aggregate/aggregate.catalog.js.map +0 -1
  95. package/dist/broker/aggregate/aggregate.server.d.ts +0 -47
  96. package/dist/broker/aggregate/aggregate.server.js +0 -151
  97. package/dist/broker/aggregate/aggregate.server.js.map +0 -1
  98. package/dist/broker/aggregate/provider.client.session.d.ts +0 -52
  99. package/dist/broker/aggregate/provider.client.session.js +0 -140
  100. package/dist/broker/aggregate/provider.client.session.js.map +0 -1
  101. package/dist/broker/behaviors/broker.behavior.info.d.ts +0 -15
  102. package/dist/broker/behaviors/broker.behavior.info.js +0 -41
  103. package/dist/broker/behaviors/broker.behavior.info.js.map +0 -1
  104. package/dist/broker/behaviors/broker.behavior.providers.d.ts +0 -19
  105. package/dist/broker/behaviors/broker.behavior.providers.js +0 -69
  106. package/dist/broker/behaviors/broker.behavior.providers.js.map +0 -1
  107. package/dist/broker/broker.context.d.ts +0 -59
  108. package/dist/broker/broker.context.js +0 -2
  109. package/dist/broker/broker.context.js.map +0 -1
  110. package/dist/broker/broker.grammars.d.ts +0 -130
  111. package/dist/broker/broker.grammars.js +0 -229
  112. package/dist/broker/broker.grammars.js.map +0 -1
  113. package/dist/broker/broker.server.d.ts +0 -66
  114. package/dist/broker/broker.server.js +0 -73
  115. package/dist/broker/broker.server.js.map +0 -1
  116. package/dist/broker/index.d.ts +0 -9
  117. package/dist/broker/index.js +0 -7
  118. package/dist/broker/index.js.map +0 -1
  119. package/dist/config.d.ts +0 -136
  120. package/dist/config.js +0 -61
  121. package/dist/config.js.map +0 -1
  122. package/dist/mcpb.loader.d.ts +0 -24
  123. package/dist/mcpb.loader.js +0 -161
  124. package/dist/mcpb.loader.js.map +0 -1
  125. package/dist/mcpb.unzip.d.ts +0 -6
  126. package/dist/mcpb.unzip.js +0 -95
  127. package/dist/mcpb.unzip.js.map +0 -1
  128. package/dist/remote.transports.d.ts +0 -16
  129. package/dist/remote.transports.js +0 -297
  130. package/dist/remote.transports.js.map +0 -1
  131. package/dist/remote.upstream.d.ts +0 -36
  132. package/dist/remote.upstream.js +0 -52
  133. package/dist/remote.upstream.js.map +0 -1
  134. package/dist/stdio.upstream.d.ts +0 -45
  135. package/dist/stdio.upstream.js +0 -85
  136. package/dist/stdio.upstream.js.map +0 -1
  137. package/dist/upstream.d.ts +0 -33
  138. package/dist/upstream.js +0 -2
  139. package/dist/upstream.js.map +0 -1
  140. package/dist/version.d.ts +0 -2
  141. package/dist/version.js +0 -9
  142. package/dist/version.js.map +0 -1
  143. package/dist/ws.tunnel.builder.d.ts +0 -139
  144. package/dist/ws.tunnel.builder.js +0 -205
  145. package/dist/ws.tunnel.builder.js.map +0 -1
  146. package/dist/ws.tunnel.d.ts +0 -373
  147. package/dist/ws.tunnel.js +0 -1090
  148. package/dist/ws.tunnel.js.map +0 -1
  149. /package/dist/{broker/grammars → grammars}/default/en.json +0 -0
  150. /package/dist/{broker/grammars → grammars}/default/fr.json +0 -0
  151. /package/dist/{broker/grammars → grammars}/default/zh.json +0 -0
@@ -0,0 +1,63 @@
1
+ import { createRemoteJWKSet, jwtVerify, errors, type JWTVerifyGetKey } from "jose";
2
+ import { AuthError, type IAccessTokenClaims, type ITokenValidator } from "./auth.types";
3
+
4
+ /**
5
+ * Options for {@link JwtTokenValidator}. `jwksUri` points at the authorization
6
+ * server's JWKS endpoint; `issuer` (when set) is checked against the token `iss`.
7
+ */
8
+ export interface IJwtValidatorOptions {
9
+ /** URL of the authorization server's JWKS document. */
10
+ jwksUri: string;
11
+ /** Expected token issuer(s). When omitted, the `iss` claim is not checked. */
12
+ issuer?: string | string[];
13
+ /** Leeway in seconds for `exp`/`nbf` checks. Defaults to jose's `0`. */
14
+ clockToleranceSec?: number;
15
+ }
16
+
17
+ /**
18
+ * An {@link ITokenValidator} that verifies JWT access tokens **statelessly** by
19
+ * checking the signature against the authorization server's JWKS and validating
20
+ * the standard claims. This is the default validator for a public deployment:
21
+ * no per-request round-trip to the AS, no shared introspection secret.
22
+ *
23
+ * Audience binding (RFC 8707) is enforced by passing the per-request canonical
24
+ * resource URI as the required `audience`, so a token minted for another slot ,
25
+ * or another service, is rejected.
26
+ *
27
+ * The JWKS is fetched lazily and cached (with rotation/cooldown) by
28
+ * `jose.createRemoteJWKSet`.
29
+ */
30
+ export class JwtTokenValidator implements ITokenValidator {
31
+ private readonly _jwks: JWTVerifyGetKey;
32
+ private readonly _issuer?: string | string[];
33
+ private readonly _clockTolerance: number;
34
+
35
+ constructor(options: IJwtValidatorOptions) {
36
+ this._jwks = createRemoteJWKSet(new URL(options.jwksUri));
37
+ this._issuer = options.issuer;
38
+ this._clockTolerance = options.clockToleranceSec ?? 0;
39
+ }
40
+
41
+ async validate(token: string, resource: string): Promise<IAccessTokenClaims> {
42
+ try {
43
+ const { payload } = await jwtVerify(token, this._jwks, {
44
+ audience: resource,
45
+ issuer: this._issuer,
46
+ clockTolerance: this._clockTolerance,
47
+ });
48
+ return payload as IAccessTokenClaims;
49
+ } catch (err) {
50
+ // Token-level failures (bad signature, wrong audience/issuer, expired,
51
+ // no matching key) are JOSEError subclasses ⇒ a genuine 401. Anything
52
+ // else (JWKS endpoint unreachable, malformed config) is infrastructure:
53
+ // rethrow so the caller surfaces a 500 instead of a misleading 401.
54
+ if (err instanceof errors.JOSEError) {
55
+ throw new AuthError(401, "invalid_token", err.message);
56
+ }
57
+ throw err;
58
+ }
59
+ }
60
+ }
61
+
62
+ /** @deprecated Use {@link IJwtValidatorOptions}. */
63
+ export type JwtValidatorOptions = IJwtValidatorOptions;
@@ -0,0 +1,114 @@
1
+ import { timingSafeEqual } from "crypto";
2
+ import type { IncomingMessage } from "http";
3
+ import { ResourcePath, ResourcePathPattern } from "../authorization/index";
4
+
5
+ export interface IProviderPrincipal {
6
+ readonly id: string;
7
+ readonly subjects?: readonly string[];
8
+ readonly allowedResources?: readonly string[];
9
+ readonly metadata?: Readonly<Record<string, unknown>>;
10
+ }
11
+
12
+ export type ProviderAuthenticationResult = { readonly authenticated: true; readonly principal: IProviderPrincipal } | { readonly authenticated: false; readonly reason?: string };
13
+
14
+ export type ProviderAuthenticatorReturn = boolean | ProviderAuthenticationResult;
15
+
16
+ /**
17
+ * Authenticates a **provider** (the engine that connects _into_ the broker to
18
+ * serve a slot) at the WebSocket upgrade handshake. This is a distinct concern
19
+ * from the OAuth 2.1 resource-server layer that guards *clients*: a provider is
20
+ * not an OAuth client acting for a resource owner, it is the backend claiming a
21
+ * slot. Authenticating it is what stops a stranger from occupying a free slot
22
+ * (`ws://host/provider/<slot>`) and impersonating the real engine.
23
+ */
24
+ export interface IProviderAuthenticator {
25
+ /**
26
+ * Returns a structured result, or a legacy boolean for backward
27
+ * compatibility. `slot` is the dedicated slot name for
28
+ * `/provider/<slot>`, or `undefined` for the multiplexed `/providers`
29
+ * socket, which is authenticated before any provider name is known.
30
+ */
31
+ authenticate(req: IncomingMessage, slot: string | undefined): ProviderAuthenticatorReturn | Promise<ProviderAuthenticatorReturn>;
32
+ }
33
+
34
+ export function normalizeProviderAuthentication(result: ProviderAuthenticatorReturn, fallbackId = "legacy-provider"): ProviderAuthenticationResult {
35
+ if (typeof result === "boolean") {
36
+ return result ? { authenticated: true, principal: { id: fallbackId, allowedResources: ["**"] } } : { authenticated: false };
37
+ }
38
+ if (!result.authenticated) return result;
39
+ if (!result.principal.id) return { authenticated: false, reason: "provider principal id is empty" };
40
+ return result;
41
+ }
42
+
43
+ export function providerMayPublish(principal: IProviderPrincipal, resource: ResourcePath): boolean {
44
+ const allowed = principal.allowedResources;
45
+ if (allowed === undefined) return true;
46
+ if (allowed.length === 0) return false;
47
+ try {
48
+ const patterns = allowed.map((pattern) => ResourcePathPattern.parse(pattern));
49
+ return patterns.some((pattern) => pattern.matches(resource));
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /** Constant-time string comparison; `false` on any length mismatch. */
56
+ function safeEqual(a: string, b: string): boolean {
57
+ const ab = Buffer.from(a);
58
+ const bb = Buffer.from(b);
59
+ if (ab.length !== bb.length) return false;
60
+ return timingSafeEqual(ab, bb);
61
+ }
62
+
63
+ /**
64
+ * Reads the secret a provider presents, from either the `X-Provider-Token`
65
+ * header or an `Authorization: Bearer <secret>` header.
66
+ */
67
+ function presentedSecret(req: IncomingMessage): string | null {
68
+ const xheader = req.headers["x-provider-token"];
69
+ if (typeof xheader === "string" && xheader.trim()) return xheader.trim();
70
+
71
+ const auth = req.headers["authorization"];
72
+ if (typeof auth === "string") {
73
+ const match = /^Bearer\s+(.+)$/i.exec(auth.trim());
74
+ if (match) return match[1].trim();
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * The default {@link IProviderAuthenticator}: every provider connection must
81
+ * present a single shared secret (via `X-Provider-Token` or `Authorization:
82
+ * Bearer`). Compared in constant time. Suitable when the broker and its
83
+ * providers are operated by the same party; swap in a custom authenticator for
84
+ * per-slot secrets, mTLS, or a signed handshake.
85
+ */
86
+ export class SharedSecretProviderAuthenticator implements IProviderAuthenticator {
87
+ private readonly _secret: string;
88
+
89
+ constructor(secret: string) {
90
+ if (!secret) {
91
+ throw new Error("provider auth: a non-empty shared secret is required.");
92
+ }
93
+ this._secret = secret;
94
+ }
95
+
96
+ authenticate(req: IncomingMessage): ProviderAuthenticationResult {
97
+ const presented = presentedSecret(req);
98
+ if (!presented || !safeEqual(presented, this._secret)) {
99
+ return { authenticated: false };
100
+ }
101
+ return {
102
+ authenticated: true,
103
+ principal: {
104
+ id: "shared-secret",
105
+ allowedResources: ["**"],
106
+ },
107
+ };
108
+ }
109
+ }
110
+
111
+ /** @deprecated Use {@link IProviderPrincipal}. */
112
+ export type ProviderPrincipal = IProviderPrincipal;
113
+ /** @deprecated Use {@link IProviderAuthenticator}. */
114
+ export type ProviderAuthenticator = IProviderAuthenticator;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * OAuth 2.0 Protected Resource Metadata (RFC 9728).
3
+ *
4
+ * Each slot exposed over HTTP is an independent resource server; its metadata
5
+ * document tells an MCP client which authorization server(s) issue tokens for
6
+ * that slot and which bearer methods it accepts. Served (unauthenticated, it is
7
+ * public discovery data) at
8
+ * `<publicBaseUrl>/.well-known/oauth-protected-resource/<slot>/<mcp>`.
9
+ */
10
+
11
+ import { buildProtectedResourceMetadata, type IProtectedResourceMetadata } from "@cyanmycelium/mcp-core";
12
+
13
+ /** The RFC 9728 metadata document for a single protected resource. */
14
+ export type { IProtectedResourceMetadata } from "@cyanmycelium/mcp-core";
15
+
16
+ /**
17
+ * Builds the RFC 9728 metadata document for one slot.
18
+ *
19
+ * The document itself is shaped by `mcp-core`: it is defined by the MCP
20
+ * specification, identically for every server. What is broker-specific is only
21
+ * which resource a slot maps to, and that is decided by the caller.
22
+ * `bearer_methods_supported` comes out fixed to `["header"]`, matching the
23
+ * broker's refusal to read a token from anywhere but the header (OAuth 2.1 §5).
24
+ */
25
+ export function buildResourceMetadata(params: { resource: string; authorizationServers: string[]; scopesSupported?: string[] }): IProtectedResourceMetadata {
26
+ return buildProtectedResourceMetadata({
27
+ resource: params.resource,
28
+ authorizationServers: params.authorizationServers,
29
+ scopesSupported: params.scopesSupported,
30
+ });
31
+ }
32
+
33
+ /** @deprecated Use {@link IProtectedResourceMetadata}. */
34
+ export type ProtectedResourceMetadata = IProtectedResourceMetadata;
@@ -0,0 +1,35 @@
1
+ import type { IAuthorizationAuditEvent, IAuthorizationDecision, IAuthorizationSubject } from "./policy.types";
2
+ import type { ResourcePath } from "./resource.path";
3
+
4
+ export interface IAuditContext {
5
+ readonly subject: IAuthorizationSubject;
6
+ readonly slot: string;
7
+ readonly resource?: ResourcePath;
8
+ readonly capability?: string;
9
+ readonly provider?: string;
10
+ readonly tool?: string;
11
+ }
12
+
13
+ export function makeAuthorizationAuditEvent(context: IAuditContext, decision: IAuthorizationDecision): IAuthorizationAuditEvent {
14
+ const clientId = context.subject.ids.find((id) => id.startsWith("client:"))?.slice("client:".length);
15
+ return {
16
+ timestamp: new Date().toISOString(),
17
+ allowed: decision.allowed,
18
+ subjectIds: context.subject.ids,
19
+ clientId,
20
+ slot: context.slot,
21
+ resource: context.resource?.value,
22
+ capability: context.capability,
23
+ provider: context.provider,
24
+ tool: context.tool,
25
+ reason: decision.reason,
26
+ matchedPolicies: decision.matchedPolicies,
27
+ };
28
+ }
29
+
30
+ export function writeAuthorizationAuditEvent(event: IAuthorizationAuditEvent): void {
31
+ console.error(`[broker] authorization ${JSON.stringify(event)}`);
32
+ }
33
+
34
+ /** @deprecated Use {@link IAuditContext}. */
35
+ export type AuditContext = IAuditContext;
@@ -0,0 +1,114 @@
1
+ import { ResourcePath, ResourcePathPattern } from "./resource.path";
2
+
3
+ export interface IMcpOperation {
4
+ readonly method?: string;
5
+ readonly params?: unknown;
6
+ }
7
+
8
+ export interface IClassifiedCapability {
9
+ readonly capability: string;
10
+ readonly tool?: string;
11
+ }
12
+
13
+ export interface ICapabilityClassifier {
14
+ classify(operation: IMcpOperation, resource: ResourcePath, provider?: string): IClassifiedCapability | undefined;
15
+ }
16
+
17
+ interface IProviderMapping {
18
+ readonly pattern: ResourcePathPattern;
19
+ readonly tools: Readonly<Record<string, string>>;
20
+ }
21
+
22
+ const STATIC_CAPABILITIES: Readonly<Record<string, string>> = {
23
+ "resources/list": "mcp.resources.read",
24
+ "resources/read": "mcp.resources.read",
25
+ "tools/list": "mcp.tools.list",
26
+ "prompts/list": "mcp.prompts.read",
27
+ "prompts/get": "mcp.prompts.read",
28
+ "completion/complete": "mcp.completion.use",
29
+ "notifications/resources/list_changed": "mcp.resources.read",
30
+ "notifications/resources/updated": "mcp.resources.read",
31
+ "notifications/tools/list_changed": "mcp.tools.list",
32
+ "notifications/prompts/list_changed": "mcp.prompts.read",
33
+ };
34
+
35
+ const BROKER_READ_TOOLS = new Set(["broker_info", "providers_list", "provider_status"]);
36
+
37
+ function toolNameFrom(params: unknown): string | undefined {
38
+ if (typeof params !== "object" || params === null || Array.isArray(params)) return undefined;
39
+ const name = (params as Readonly<Record<string, unknown>>)["name"];
40
+ return typeof name === "string" && name ? name : undefined;
41
+ }
42
+
43
+ export function validateCapability(capability: string, label = "capability"): void {
44
+ if (typeof capability !== "string" || (capability !== "*" && !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(capability))) {
45
+ throw new Error(`authorization ${label}: "${capability}" is malformed.`);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Classifies MCP methods without deriving privilege from arbitrary tool names.
51
+ * Provider-qualified mappings win over global mappings, then tools/call falls
52
+ * back to `mcp.tools.call`.
53
+ */
54
+ export class ConfiguredCapabilityClassifier implements ICapabilityClassifier {
55
+ private readonly _globalTools: Readonly<Record<string, string>>;
56
+ private readonly _providerMappings: readonly IProviderMapping[];
57
+
58
+ constructor(toolCapabilities: Readonly<Record<string, string>> = {}, providerToolCapabilities: Readonly<Record<string, Readonly<Record<string, string>>>> = {}) {
59
+ for (const [tool, capability] of Object.entries(toolCapabilities)) {
60
+ if (!tool) throw new Error("authorization toolCapabilities: tool names must not be empty.");
61
+ validateCapability(capability, `toolCapabilities["${tool}"]`);
62
+ }
63
+ this._globalTools = Object.freeze({ ...toolCapabilities });
64
+
65
+ const mappings: IProviderMapping[] = [];
66
+ for (const [patternValue, tools] of Object.entries(providerToolCapabilities)) {
67
+ const pattern = ResourcePathPattern.parse(patternValue);
68
+ for (const [tool, capability] of Object.entries(tools)) {
69
+ if (!tool) throw new Error(`authorization providerToolCapabilities["${patternValue}"]: tool names must not be empty.`);
70
+ validateCapability(capability, `providerToolCapabilities["${patternValue}"]["${tool}"]`);
71
+ }
72
+ mappings.push({ pattern, tools: Object.freeze({ ...tools }) });
73
+ }
74
+ mappings.sort((left, right) => right.pattern.specificity - left.pattern.specificity || left.pattern.value.localeCompare(right.pattern.value));
75
+ this._providerMappings = Object.freeze(mappings);
76
+ }
77
+
78
+ classify(operation: IMcpOperation, resource: ResourcePath, provider?: string): IClassifiedCapability | undefined {
79
+ const method = operation.method;
80
+ if (!method) return undefined;
81
+
82
+ if (provider === "_broker") {
83
+ if (method === "tools/list" || method === "resources/list" || method === "resources/read") {
84
+ return { capability: "broker.providers.read" };
85
+ }
86
+ if (method === "tools/call") {
87
+ const tool = toolNameFrom(operation.params);
88
+ if (tool && BROKER_READ_TOOLS.has(tool)) return { capability: "broker.providers.read", tool };
89
+ }
90
+ }
91
+
92
+ if (method !== "tools/call") {
93
+ const capability = STATIC_CAPABILITIES[method];
94
+ return capability ? { capability } : undefined;
95
+ }
96
+
97
+ const tool = toolNameFrom(operation.params);
98
+ if (!tool) return { capability: "mcp.tools.call" };
99
+ for (const mapping of this._providerMappings) {
100
+ if (mapping.pattern.matches(resource)) {
101
+ const capability = mapping.tools[tool];
102
+ if (capability) return { capability, tool };
103
+ }
104
+ }
105
+ return { capability: this._globalTools[tool] ?? "mcp.tools.call", tool };
106
+ }
107
+ }
108
+
109
+ /** @deprecated Use {@link IMcpOperation}. */
110
+ export type McpOperation = IMcpOperation;
111
+ /** @deprecated Use {@link IClassifiedCapability}. */
112
+ export type ClassifiedCapability = IClassifiedCapability;
113
+ /** @deprecated Use {@link ICapabilityClassifier}. */
114
+ export type CapabilityClassifier = ICapabilityClassifier;
@@ -0,0 +1,37 @@
1
+ export { ResourcePath, ResourcePathPattern } from "./resource.path";
2
+ export { ConfiguredCapabilityClassifier, validateCapability } from "./capability.classifier";
3
+ export type { CapabilityClassifier, ClassifiedCapability, ICapabilityClassifier, IClassifiedCapability, IMcpOperation, McpOperation } from "./capability.classifier";
4
+ export { ConfigPolicyEngine } from "./policy.engine";
5
+ export { JwtSubjectMapper, SubjectMappingError } from "./subject.mapper";
6
+ export type { ISubjectMapper, SubjectMapper } from "./subject.mapper";
7
+ export { DefaultSlotResourceResolver } from "./slot.resource";
8
+ export type { ISlotResourceResolver, SlotResourceResolver } from "./slot.resource";
9
+ export { authorizationWithEngine, compileAuthorizationPolicy, hasAuthorizationPolicies } from "./runtime";
10
+ export type { IPolicyAuthorization, PolicyAuthorization } from "./runtime";
11
+ export { makeAuthorizationAuditEvent, writeAuthorizationAuditEvent } from "./audit";
12
+ export type { AuditContext, IAuditContext } from "./audit";
13
+ export type {
14
+ AuthorizationAuditConfig,
15
+ AuthorizationAuditEvent,
16
+ AuthorizationDecision,
17
+ AuthorizationDecisionReason,
18
+ AuthorizationPolicyConfig,
19
+ AuthorizationRequest,
20
+ AuthorizationSubject,
21
+ DenyPolicy,
22
+ IAuthorizationAuditConfig,
23
+ IAuthorizationAuditEvent,
24
+ IAuthorizationDecision,
25
+ IAuthorizationPolicyConfig,
26
+ IAuthorizationRequest,
27
+ IAuthorizationSubject,
28
+ IDenyPolicy,
29
+ IPolicyAssignment,
30
+ IPolicyEngine,
31
+ IRoleDefinition,
32
+ ISubjectMappingConfig,
33
+ PolicyAssignment,
34
+ PolicyEngine,
35
+ RoleDefinition,
36
+ SubjectMappingConfig,
37
+ } from "./policy.types";
@@ -0,0 +1,212 @@
1
+ import { validateCapability } from "./capability.classifier";
2
+ import type { IAuthorizationDecision, IAuthorizationRequest, IDenyPolicy, IPolicyAssignment, IPolicyEngine, IRoleDefinition } from "./policy.types";
3
+ import { ResourcePathPattern } from "./resource.path";
4
+
5
+ interface ICompiledAssignment {
6
+ readonly id: string;
7
+ readonly subject: string;
8
+ readonly capabilities: ReadonlySet<string>;
9
+ readonly resource: ResourcePathPattern;
10
+ }
11
+
12
+ interface ICompiledDeny {
13
+ readonly id: string;
14
+ readonly subject: string;
15
+ readonly capabilities: ReadonlySet<string>;
16
+ readonly resource: ResourcePathPattern;
17
+ }
18
+
19
+ function validateName(value: string, label: string): void {
20
+ if (typeof value !== "string" || !value || value.trim() !== value || value.includes("/")) {
21
+ throw new Error(`authorization ${label}: "${value}" is invalid.`);
22
+ }
23
+ }
24
+
25
+ function validateSubject(subject: string, label: string): void {
26
+ if (typeof subject !== "string" || !/^[A-Za-z][A-Za-z0-9_-]*:.+$/.test(subject)) {
27
+ throw new Error(`authorization ${label}: subject "${subject}" must use a non-empty extensible prefix such as "user:alice".`);
28
+ }
29
+ }
30
+
31
+ function validatePolicyId(id: string, label: string): void {
32
+ if (typeof id !== "string" || !id || id.trim() !== id) {
33
+ throw new Error(`authorization ${label}: policy ids must be non-empty and must not contain surrounding whitespace.`);
34
+ }
35
+ }
36
+
37
+ function expandRoles(roles: Readonly<Record<string, IRoleDefinition>>): ReadonlyMap<string, ReadonlySet<string>> {
38
+ const expanded = new Map<string, ReadonlySet<string>>();
39
+ const visiting: string[] = [];
40
+
41
+ for (const [name, role] of Object.entries(roles)) {
42
+ validateName(name, "role");
43
+ for (const key of Object.keys(role)) {
44
+ if (key !== "inherits" && key !== "capabilities") {
45
+ throw new Error(`authorization role "${name}": unsupported field "${key}"; roles contain capabilities, not resource paths.`);
46
+ }
47
+ }
48
+ if (!Array.isArray(role.capabilities)) {
49
+ throw new Error(`authorization role "${name}": capabilities must be an array.`);
50
+ }
51
+ if (role.inherits !== undefined && !Array.isArray(role.inherits)) {
52
+ throw new Error(`authorization role "${name}": inherits must be an array.`);
53
+ }
54
+ for (const capability of role.capabilities) validateCapability(capability, `role "${name}" capability`);
55
+ for (const inherited of role.inherits ?? []) {
56
+ validateName(inherited, `role "${name}" inheritance`);
57
+ if (!roles[inherited]) {
58
+ throw new Error(`authorization role "${name}": inherited role "${inherited}" is not defined.`);
59
+ }
60
+ }
61
+ }
62
+
63
+ const visit = (name: string): ReadonlySet<string> => {
64
+ const cached = expanded.get(name);
65
+ if (cached) return cached;
66
+
67
+ const cycleStart = visiting.indexOf(name);
68
+ if (cycleStart >= 0) {
69
+ const cycle = [...visiting.slice(cycleStart), name].join(" -> ");
70
+ throw new Error(`authorization roles: inheritance cycle detected: ${cycle}.`);
71
+ }
72
+
73
+ const role = roles[name];
74
+ if (!role) throw new Error(`authorization roles: role "${name}" is not defined.`);
75
+ visiting.push(name);
76
+ const capabilities = new Set<string>();
77
+ for (const inherited of role.inherits ?? []) {
78
+ for (const capability of visit(inherited)) capabilities.add(capability);
79
+ }
80
+ for (const capability of role.capabilities) capabilities.add(capability);
81
+ visiting.pop();
82
+ const frozen = Object.freeze(capabilities);
83
+ expanded.set(name, frozen);
84
+ return frozen;
85
+ };
86
+
87
+ for (const name of Object.keys(roles)) visit(name);
88
+ return expanded;
89
+ }
90
+
91
+ function indexBySubject<T extends { readonly subject: string }>(entries: readonly T[]): ReadonlyMap<string, readonly T[]> {
92
+ const mutable = new Map<string, T[]>();
93
+ for (const entry of entries) {
94
+ const bucket = mutable.get(entry.subject);
95
+ if (bucket) bucket.push(entry);
96
+ else mutable.set(entry.subject, [entry]);
97
+ }
98
+ const result = new Map<string, readonly T[]>();
99
+ for (const [subject, bucket] of mutable) result.set(subject, Object.freeze(bucket));
100
+ return result;
101
+ }
102
+
103
+ function grants(capabilities: ReadonlySet<string>, requested: string): boolean {
104
+ return capabilities.has("*") || capabilities.has(requested);
105
+ }
106
+
107
+ /**
108
+ * Immutable, indexed in-memory policy engine. Role inheritance and resource
109
+ * expressions are fully compiled in the constructor.
110
+ */
111
+ export class ConfigPolicyEngine implements IPolicyEngine {
112
+ private readonly _assignmentsBySubject: ReadonlyMap<string, readonly ICompiledAssignment[]>;
113
+ private readonly _deniesBySubject: ReadonlyMap<string, readonly ICompiledDeny[]>;
114
+
115
+ constructor(roles: Readonly<Record<string, IRoleDefinition>>, assignments: readonly IPolicyAssignment[], denies: readonly IDenyPolicy[]) {
116
+ const expandedRoles = expandRoles(roles);
117
+ const policyIds = new Set<string>();
118
+
119
+ const reserveId = (id: string, label: string): void => {
120
+ validatePolicyId(id, label);
121
+ if (policyIds.has(id)) throw new Error(`authorization policies: duplicate policy id "${id}".`);
122
+ policyIds.add(id);
123
+ };
124
+
125
+ const compiledAssignments = assignments.map((assignment, index): ICompiledAssignment => {
126
+ const id = assignment.id ?? `assignment:${index}`;
127
+ reserveId(id, `assignment ${index}`);
128
+ validateSubject(assignment.subject, `assignment "${id}"`);
129
+ validateName(assignment.role, `assignment "${id}" role`);
130
+ const capabilities = expandedRoles.get(assignment.role);
131
+ if (!capabilities) {
132
+ throw new Error(`authorization assignment "${id}": role "${assignment.role}" is not defined.`);
133
+ }
134
+ if (!assignment.resource) throw new Error(`authorization assignment "${id}": resource must not be empty.`);
135
+ return {
136
+ id,
137
+ subject: assignment.subject,
138
+ capabilities,
139
+ resource: ResourcePathPattern.parse(assignment.resource),
140
+ };
141
+ });
142
+
143
+ const compiledDenies = denies.map((deny, index): ICompiledDeny => {
144
+ const id = deny.id ?? `deny:${index}`;
145
+ reserveId(id, `deny ${index}`);
146
+ validateSubject(deny.subject, `deny "${id}"`);
147
+ if (deny.effect !== undefined && deny.effect !== "deny") {
148
+ throw new Error(`authorization deny "${id}": effect must be "deny".`);
149
+ }
150
+ if (!Array.isArray(deny.capabilities) || deny.capabilities.length === 0) {
151
+ throw new Error(`authorization deny "${id}": at least one capability is required.`);
152
+ }
153
+ const capabilities = new Set<string>();
154
+ for (const capability of deny.capabilities) {
155
+ validateCapability(capability, `deny "${id}" capability`);
156
+ capabilities.add(capability);
157
+ }
158
+ if (!deny.resource) throw new Error(`authorization deny "${id}": resource must not be empty.`);
159
+ return {
160
+ id,
161
+ subject: deny.subject,
162
+ capabilities: Object.freeze(capabilities),
163
+ resource: ResourcePathPattern.parse(deny.resource),
164
+ };
165
+ });
166
+
167
+ this._assignmentsBySubject = indexBySubject(compiledAssignments);
168
+ this._deniesBySubject = indexBySubject(compiledDenies);
169
+ }
170
+
171
+ authorize(request: IAuthorizationRequest): IAuthorizationDecision {
172
+ if (!request.resource) return { allowed: false, reason: "invalid-resource" };
173
+ try {
174
+ validateCapability(request.capability, "request capability");
175
+ } catch {
176
+ return { allowed: false, reason: "no-matching-grant" };
177
+ }
178
+
179
+ const matchingDenies = new Set<string>();
180
+ for (const subject of request.subject.ids) {
181
+ for (const deny of this._deniesBySubject.get(subject) ?? []) {
182
+ if (deny.resource.matches(request.resource) && grants(deny.capabilities, request.capability)) {
183
+ matchingDenies.add(deny.id);
184
+ }
185
+ }
186
+ }
187
+ if (matchingDenies.size > 0) {
188
+ return {
189
+ allowed: false,
190
+ reason: "explicit-deny",
191
+ matchedPolicies: Object.freeze([...matchingDenies].sort()),
192
+ };
193
+ }
194
+
195
+ const matchingGrants = new Set<string>();
196
+ for (const subject of request.subject.ids) {
197
+ for (const assignment of this._assignmentsBySubject.get(subject) ?? []) {
198
+ if (assignment.resource.matches(request.resource) && grants(assignment.capabilities, request.capability)) {
199
+ matchingGrants.add(assignment.id);
200
+ }
201
+ }
202
+ }
203
+ if (matchingGrants.size > 0) {
204
+ return {
205
+ allowed: true,
206
+ reason: "role-grant",
207
+ matchedPolicies: Object.freeze([...matchingGrants].sort()),
208
+ };
209
+ }
210
+ return { allowed: false, reason: "no-matching-grant" };
211
+ }
212
+ }