@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,105 @@
1
+ import type { ResourcePath } from "./resource.path";
2
+
3
+ export interface IAuthorizationSubject {
4
+ readonly ids: readonly string[];
5
+ readonly claims?: Readonly<Record<string, unknown>>;
6
+ }
7
+
8
+ export interface IAuthorizationRequest {
9
+ readonly subject: IAuthorizationSubject;
10
+ readonly capability: string;
11
+ readonly resource: ResourcePath;
12
+ readonly provider?: string;
13
+ readonly tool?: string;
14
+ }
15
+
16
+ export type AuthorizationDecisionReason = "explicit-deny" | "role-grant" | "no-matching-grant" | "invalid-resource" | "unknown-resource";
17
+
18
+ export interface IAuthorizationDecision {
19
+ readonly allowed: boolean;
20
+ readonly reason: AuthorizationDecisionReason;
21
+ readonly matchedPolicies?: readonly string[];
22
+ }
23
+
24
+ export interface IPolicyEngine {
25
+ authorize(request: IAuthorizationRequest): IAuthorizationDecision;
26
+ }
27
+
28
+ export interface IRoleDefinition {
29
+ readonly inherits?: readonly string[];
30
+ readonly capabilities: readonly string[];
31
+ }
32
+
33
+ export interface IPolicyAssignment {
34
+ readonly id?: string;
35
+ readonly subject: string;
36
+ readonly role: string;
37
+ readonly resource: string;
38
+ }
39
+
40
+ export interface IDenyPolicy {
41
+ readonly id?: string;
42
+ readonly subject: string;
43
+ readonly effect?: "deny";
44
+ readonly capabilities: readonly string[];
45
+ readonly resource: string;
46
+ }
47
+
48
+ export interface ISubjectMappingConfig {
49
+ readonly userClaim?: string;
50
+ readonly groupClaims?: readonly string[];
51
+ readonly clientClaim?: string;
52
+ readonly serviceClaims?: readonly string[];
53
+ }
54
+
55
+ export interface IAuthorizationAuditConfig {
56
+ readonly logAllowed?: boolean;
57
+ }
58
+
59
+ export interface IAuthorizationPolicyConfig {
60
+ readonly roles?: Readonly<Record<string, IRoleDefinition>>;
61
+ readonly assignments?: readonly IPolicyAssignment[];
62
+ readonly denies?: readonly IDenyPolicy[];
63
+ readonly subjectMapping?: ISubjectMappingConfig;
64
+ readonly slotResources?: Readonly<Record<string, string>>;
65
+ readonly toolCapabilities?: Readonly<Record<string, string>>;
66
+ readonly providerToolCapabilities?: Readonly<Record<string, Readonly<Record<string, string>>>>;
67
+ readonly audit?: IAuthorizationAuditConfig;
68
+ }
69
+
70
+ export interface IAuthorizationAuditEvent {
71
+ readonly timestamp: string;
72
+ readonly allowed: boolean;
73
+ readonly subjectIds: readonly string[];
74
+ readonly clientId?: string;
75
+ readonly slot: string;
76
+ readonly resource?: string;
77
+ readonly capability?: string;
78
+ readonly provider?: string;
79
+ readonly tool?: string;
80
+ readonly reason: AuthorizationDecisionReason;
81
+ readonly matchedPolicies?: readonly string[];
82
+ }
83
+
84
+ /** @deprecated Use {@link IAuthorizationSubject}. */
85
+ export type AuthorizationSubject = IAuthorizationSubject;
86
+ /** @deprecated Use {@link IAuthorizationRequest}. */
87
+ export type AuthorizationRequest = IAuthorizationRequest;
88
+ /** @deprecated Use {@link IAuthorizationDecision}. */
89
+ export type AuthorizationDecision = IAuthorizationDecision;
90
+ /** @deprecated Use {@link IPolicyEngine}. */
91
+ export type PolicyEngine = IPolicyEngine;
92
+ /** @deprecated Use {@link IRoleDefinition}. */
93
+ export type RoleDefinition = IRoleDefinition;
94
+ /** @deprecated Use {@link IPolicyAssignment}. */
95
+ export type PolicyAssignment = IPolicyAssignment;
96
+ /** @deprecated Use {@link IDenyPolicy}. */
97
+ export type DenyPolicy = IDenyPolicy;
98
+ /** @deprecated Use {@link ISubjectMappingConfig}. */
99
+ export type SubjectMappingConfig = ISubjectMappingConfig;
100
+ /** @deprecated Use {@link IAuthorizationAuditConfig}. */
101
+ export type AuthorizationAuditConfig = IAuthorizationAuditConfig;
102
+ /** @deprecated Use {@link IAuthorizationPolicyConfig}. */
103
+ export type AuthorizationPolicyConfig = IAuthorizationPolicyConfig;
104
+ /** @deprecated Use {@link IAuthorizationAuditEvent}. */
105
+ export type AuthorizationAuditEvent = IAuthorizationAuditEvent;
@@ -0,0 +1,126 @@
1
+ const WILDCARD = "*";
2
+ const RECURSIVE_WILDCARD = "**";
3
+
4
+ function assertString(value: string, label: string): void {
5
+ if (typeof value !== "string" || value.length === 0) {
6
+ throw new Error(`${label}: a non-empty string is required.`);
7
+ }
8
+ }
9
+
10
+ function splitAbsolutePath(value: string, label: string): readonly string[] {
11
+ assertString(value, label);
12
+ if (!value.startsWith("/")) {
13
+ throw new Error(`${label}: "${value}" must start with "/".`);
14
+ }
15
+ if (value === "/") return [];
16
+
17
+ const withoutTrailingSlash = value.endsWith("/") ? value.slice(0, -1) : value;
18
+ const segments = withoutTrailingSlash.slice(1).split("/");
19
+ for (const segment of segments) {
20
+ if (!segment) {
21
+ throw new Error(`${label}: "${value}" contains an empty path segment.`);
22
+ }
23
+ if (segment === "." || segment === "..") {
24
+ throw new Error(`${label}: "${value}" contains a forbidden "${segment}" segment.`);
25
+ }
26
+ }
27
+ return segments;
28
+ }
29
+
30
+ function normalizedValue(segments: readonly string[]): string {
31
+ return segments.length === 0 ? "/" : `/${segments.join("/")}`;
32
+ }
33
+
34
+ /**
35
+ * A normalized, case-sensitive hierarchical resource path.
36
+ *
37
+ * Parsing is deliberately independent from URLs and transports. A path is a
38
+ * stable resource identity, not a network address.
39
+ */
40
+ export class ResourcePath {
41
+ readonly value: string;
42
+ readonly segments: readonly string[];
43
+
44
+ private constructor(segments: readonly string[]) {
45
+ this.segments = Object.freeze([...segments]);
46
+ this.value = normalizedValue(this.segments);
47
+ }
48
+
49
+ static parse(value: string): ResourcePath {
50
+ const segments = splitAbsolutePath(value, "resource path");
51
+ for (const segment of segments) {
52
+ if (segment === WILDCARD || segment === RECURSIVE_WILDCARD) {
53
+ throw new Error(`resource path: wildcards are not allowed in "${value}".`);
54
+ }
55
+ }
56
+ return new ResourcePath(segments);
57
+ }
58
+
59
+ static tryParse(value: string): ResourcePath | undefined {
60
+ try {
61
+ return ResourcePath.parse(value);
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ toString(): string {
68
+ return this.value;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * A compiled resource expression supporting exact segments, `*` for one
74
+ * segment, and a final `**` for zero or more trailing segments.
75
+ */
76
+ export class ResourcePathPattern {
77
+ readonly value: string;
78
+ readonly segments: readonly string[];
79
+ readonly specificity: number;
80
+
81
+ private constructor(segments: readonly string[]) {
82
+ this.segments = Object.freeze([...segments]);
83
+ this.value = normalizedValue(this.segments);
84
+ this.specificity = this.segments.reduce((score, segment) => {
85
+ if (segment === RECURSIVE_WILDCARD) return score;
86
+ if (segment === WILDCARD) return score + 1;
87
+ return score + 4;
88
+ }, this.segments.length);
89
+ }
90
+
91
+ static parse(value: string): ResourcePathPattern {
92
+ const canonical = value === RECURSIVE_WILDCARD ? `/${RECURSIVE_WILDCARD}` : value;
93
+ const segments = splitAbsolutePath(canonical, "resource pattern");
94
+ let recursiveSeen = false;
95
+ for (let index = 0; index < segments.length; index += 1) {
96
+ const segment = segments[index];
97
+ if (segment === RECURSIVE_WILDCARD) {
98
+ if (index !== segments.length - 1) {
99
+ throw new Error(`resource pattern: "**" must be the final segment in "${value}".`);
100
+ }
101
+ recursiveSeen = true;
102
+ } else if (recursiveSeen) {
103
+ throw new Error(`resource pattern: no segment may follow "**" in "${value}".`);
104
+ } else if (segment !== WILDCARD && segment.includes(WILDCARD)) {
105
+ throw new Error(`resource pattern: wildcards must occupy a complete segment in "${value}".`);
106
+ }
107
+ }
108
+ return new ResourcePathPattern(segments);
109
+ }
110
+
111
+ matches(path: ResourcePath): boolean {
112
+ const pattern = this.segments;
113
+ const target = path.segments;
114
+ for (let index = 0; index < pattern.length; index += 1) {
115
+ const segment = pattern[index];
116
+ if (segment === RECURSIVE_WILDCARD) return true;
117
+ if (index >= target.length) return false;
118
+ if (segment !== WILDCARD && segment !== target[index]) return false;
119
+ }
120
+ return target.length === pattern.length;
121
+ }
122
+
123
+ toString(): string {
124
+ return this.value;
125
+ }
126
+ }
@@ -0,0 +1,56 @@
1
+ import { ConfiguredCapabilityClassifier, type ICapabilityClassifier } from "./capability.classifier";
2
+ import { ConfigPolicyEngine } from "./policy.engine";
3
+ import type { IAuthorizationAuditConfig, IAuthorizationPolicyConfig, IPolicyEngine } from "./policy.types";
4
+ import { DefaultSlotResourceResolver, type ISlotResourceResolver } from "./slot.resource";
5
+ import { JwtSubjectMapper, type ISubjectMapper } from "./subject.mapper";
6
+
7
+ export interface IPolicyAuthorization {
8
+ readonly engine: IPolicyEngine;
9
+ readonly subjectMapper: ISubjectMapper;
10
+ readonly slotResourceResolver: ISlotResourceResolver;
11
+ readonly capabilityClassifier: ICapabilityClassifier;
12
+ readonly audit: Readonly<Required<IAuthorizationAuditConfig>>;
13
+ }
14
+
15
+ export function hasAuthorizationPolicies(config: IAuthorizationPolicyConfig): boolean {
16
+ return Object.keys(config.roles ?? {}).length > 0 || (config.assignments?.length ?? 0) > 0 || (config.denies?.length ?? 0) > 0;
17
+ }
18
+
19
+ /** Compiles and validates the complete hierarchical authorization runtime. */
20
+ export function compileAuthorizationPolicy(config: IAuthorizationPolicyConfig): IPolicyAuthorization {
21
+ if (config.audit?.logAllowed !== undefined && typeof config.audit.logAllowed !== "boolean") {
22
+ throw new Error("authorization audit.logAllowed must be a boolean.");
23
+ }
24
+ const engine = new ConfigPolicyEngine(config.roles ?? {}, config.assignments ?? [], config.denies ?? []);
25
+ const subjectMapper = new JwtSubjectMapper(config.subjectMapping);
26
+ const slotResourceResolver = new DefaultSlotResourceResolver(config.slotResources);
27
+ const capabilityClassifier = new ConfiguredCapabilityClassifier(config.toolCapabilities, config.providerToolCapabilities);
28
+ return {
29
+ engine,
30
+ subjectMapper,
31
+ slotResourceResolver,
32
+ capabilityClassifier,
33
+ audit: Object.freeze({ logAllowed: config.audit?.logAllowed ?? false }),
34
+ };
35
+ }
36
+
37
+ export function authorizationWithEngine(
38
+ engine: IPolicyEngine,
39
+ overrides: {
40
+ readonly subjectMapper?: ISubjectMapper;
41
+ readonly slotResourceResolver?: ISlotResourceResolver;
42
+ readonly capabilityClassifier?: ICapabilityClassifier;
43
+ readonly audit?: IAuthorizationAuditConfig;
44
+ } = {}
45
+ ): IPolicyAuthorization {
46
+ return {
47
+ engine,
48
+ subjectMapper: overrides.subjectMapper ?? new JwtSubjectMapper(),
49
+ slotResourceResolver: overrides.slotResourceResolver ?? new DefaultSlotResourceResolver(),
50
+ capabilityClassifier: overrides.capabilityClassifier ?? new ConfiguredCapabilityClassifier(),
51
+ audit: Object.freeze({ logAllowed: overrides.audit?.logAllowed ?? false }),
52
+ };
53
+ }
54
+
55
+ /** @deprecated Use {@link IPolicyAuthorization}. */
56
+ export type PolicyAuthorization = IPolicyAuthorization;
@@ -0,0 +1,50 @@
1
+ import { ResourcePath } from "./resource.path";
2
+
3
+ export interface ISlotResourceResolver {
4
+ resolve(slot: string): ResourcePath | undefined;
5
+ }
6
+
7
+ const RESERVED_RESOURCES: Readonly<Record<string, string>> = {
8
+ _broker: "/_system/broker",
9
+ _all: "/_system/all",
10
+ };
11
+
12
+ function isReservedNamespace(resource: ResourcePath): boolean {
13
+ return resource.value === "/_system" || resource.value.startsWith("/_system/");
14
+ }
15
+
16
+ /** Default resolver with optional explicit technical-slot mappings. */
17
+ export class DefaultSlotResourceResolver implements ISlotResourceResolver {
18
+ private readonly _explicit = new Map<string, ResourcePath>();
19
+
20
+ constructor(slotResources: Readonly<Record<string, string>> = {}) {
21
+ for (const [slot, configuredPath] of Object.entries(slotResources)) {
22
+ if (!slot) throw new Error("authorization slotResources: slot names must not be empty.");
23
+ const resource = ResourcePath.parse(configuredPath);
24
+ const reserved = RESERVED_RESOURCES[slot];
25
+ if (isReservedNamespace(resource) && reserved !== resource.value) {
26
+ throw new Error(`authorization slotResources: slot "${slot}" may not use reserved resource "${resource.value}".`);
27
+ }
28
+ if (reserved && resource.value !== reserved) {
29
+ throw new Error(`authorization slotResources: reserved slot "${slot}" must resolve to "${reserved}".`);
30
+ }
31
+ this._explicit.set(slot, resource);
32
+ }
33
+ }
34
+
35
+ resolve(slot: string): ResourcePath | undefined {
36
+ const explicit = this._explicit.get(slot);
37
+ if (explicit) return explicit;
38
+
39
+ const reserved = RESERVED_RESOURCES[slot];
40
+ if (reserved) return ResourcePath.parse(reserved);
41
+ if (!slot) return undefined;
42
+
43
+ const candidate = slot.startsWith("/") ? slot : `/${slot}`;
44
+ const resource = ResourcePath.tryParse(candidate);
45
+ return resource && !isReservedNamespace(resource) ? resource : undefined;
46
+ }
47
+ }
48
+
49
+ /** @deprecated Use {@link ISlotResourceResolver}. */
50
+ export type SlotResourceResolver = ISlotResourceResolver;
@@ -0,0 +1,81 @@
1
+ import type { IAuthorizationSubject, ISubjectMappingConfig } from "./policy.types";
2
+
3
+ export interface ISubjectMapper {
4
+ map(claims: Readonly<Record<string, unknown>>): IAuthorizationSubject;
5
+ }
6
+
7
+ export class SubjectMappingError extends Error {
8
+ constructor(message: string) {
9
+ super(message);
10
+ this.name = "SubjectMappingError";
11
+ }
12
+ }
13
+
14
+ interface IClaimRule {
15
+ readonly prefix: string;
16
+ readonly claim: string;
17
+ }
18
+
19
+ function claimAt(claims: Readonly<Record<string, unknown>>, claimName: string): unknown {
20
+ const parts = claimName.split(".");
21
+ let current: unknown = claims;
22
+ for (const part of parts) {
23
+ if (!part || typeof current !== "object" || current === null || Array.isArray(current)) return undefined;
24
+ current = (current as Readonly<Record<string, unknown>>)[part];
25
+ }
26
+ return current;
27
+ }
28
+
29
+ function valuesOf(value: unknown, claimName: string): readonly string[] {
30
+ if (value === undefined || value === null) return [];
31
+ if (typeof value === "string") return value ? [value] : [];
32
+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) {
33
+ return value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
34
+ }
35
+ throw new SubjectMappingError(`JWT subject claim "${claimName}" must be a string or an array of strings.`);
36
+ }
37
+
38
+ function validateClaimName(value: string, label: string): void {
39
+ if (!value || value.split(".").some((part) => !part)) {
40
+ throw new Error(`authorization subject mapping: ${label} contains an invalid claim name.`);
41
+ }
42
+ }
43
+
44
+ /** Maps validated JWT claims to canonical, deduplicated authorization subjects. */
45
+ export class JwtSubjectMapper implements ISubjectMapper {
46
+ private readonly _rules: readonly IClaimRule[];
47
+
48
+ constructor(config: ISubjectMappingConfig = {}) {
49
+ const userClaim = config.userClaim ?? "sub";
50
+ const groupClaims = config.groupClaims ?? ["groups", "roles"];
51
+ const clientClaim = config.clientClaim ?? "client_id";
52
+ const serviceClaims = config.serviceClaims ?? [];
53
+
54
+ if (!Array.isArray(groupClaims)) throw new Error("authorization subject mapping: groupClaims must be an array.");
55
+ if (!Array.isArray(serviceClaims)) throw new Error("authorization subject mapping: serviceClaims must be an array.");
56
+ validateClaimName(userClaim, "userClaim");
57
+ validateClaimName(clientClaim, "clientClaim");
58
+ for (const claim of groupClaims) validateClaimName(claim, "groupClaims");
59
+ for (const claim of serviceClaims) validateClaimName(claim, "serviceClaims");
60
+
61
+ this._rules = Object.freeze([
62
+ { prefix: "user", claim: userClaim },
63
+ ...groupClaims.map((claim) => ({ prefix: "group", claim })),
64
+ { prefix: "client", claim: clientClaim },
65
+ ...serviceClaims.map((claim) => ({ prefix: "service", claim })),
66
+ ]);
67
+ }
68
+
69
+ map(claims: Readonly<Record<string, unknown>>): IAuthorizationSubject {
70
+ const ids = new Set<string>();
71
+ for (const rule of this._rules) {
72
+ for (const value of valuesOf(claimAt(claims, rule.claim), rule.claim)) {
73
+ ids.add(`${rule.prefix}:${value}`);
74
+ }
75
+ }
76
+ return { ids: Object.freeze([...ids]), claims };
77
+ }
78
+ }
79
+
80
+ /** @deprecated Use {@link ISubjectMapper}. */
81
+ export type SubjectMapper = ISubjectMapper;
package/src/bin.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * - Env-var paths (`MCP_BROKER_*_DIR`, `MCP_BROKER_TLS_*`) are resolved
17
17
  * against `process.cwd()`.
18
18
  * - Config-file paths (`tls.cert`, `www.mounts[*].dir`, `stdioUpstreams[*]`,
19
- * `mcpbBundles[*]`) are resolved against the **config file's directory** — so a config in
19
+ * `mcpbBundles[*]`) are resolved against the **config file's directory**: so a config in
20
20
  * `./.mcp-broker/config.json` referring to `"certs/cert.pem"` points at
21
21
  * `./.mcp-broker/certs/cert.pem`. The folder is self-contained.
22
22
  *
@@ -48,13 +48,15 @@
48
48
  * | | | provider (Claude Desktop bridge). |
49
49
  * | MCP_BROKER_LOCALE | en | Locale used for tool descriptions on the `_broker` |
50
50
  * | | | slot. ISO 639-1 base; regional variants accepted. |
51
+ * | MCP_BROKER_ALLOWED_ORIGINS | (none) | Comma-separated browser origins allowed on |
52
+ * | | | `/<slot>/mcp`. None means no browser origin passes. |
51
53
  */
52
54
  import * as fs from "fs";
53
55
  import * as path from "path";
54
56
  import open from "open";
55
- import { WsTunnelBuilder } from "./index.js";
56
- import { loadBrokerConfig } from "./config.js";
57
- import { loadMcpbBundle } from "./mcpb.loader.js";
57
+ import { WsTunnelBuilder } from "./index";
58
+ import { loadBrokerConfig } from "./config";
59
+ import { loadMcpbBundle } from "./mcpb/mcpb.loader";
58
60
 
59
61
  // ---------------------------------------------------------------------------
60
62
  // Configuration
@@ -65,7 +67,7 @@ const cwd = process.cwd();
65
67
 
66
68
  /**
67
69
  * Fills an env var from the config file when the env var is not already set.
68
- * Used for **non-path** scalars — path-bearing fields are read directly so
70
+ * Used for **non-path** scalars, path-bearing fields are read directly so
69
71
  * they can be resolved against `baseDir` (config file's directory) instead
70
72
  * of `cwd` (deploy environment).
71
73
  */
@@ -84,10 +86,15 @@ envFromConfig("MCP_BROKER_PROVIDER_PATH", config.paths?.provider);
84
86
  envFromConfig("MCP_BROKER_CLIENT_PATH", config.paths?.client);
85
87
  envFromConfig("MCP_BROKER_MCP_PATH", config.paths?.mcp);
86
88
  envFromConfig("MCP_BROKER_OPEN", config.www?.open === true ? "1" : undefined);
89
+ envFromConfig("MCP_BROKER_AUTH_ENABLED", config.auth?.enabled === true ? "1" : undefined);
90
+ envFromConfig("MCP_BROKER_PUBLIC_BASE_URL", config.auth?.publicBaseUrl);
91
+ envFromConfig("MCP_BROKER_JWKS", config.auth?.jwks);
92
+ envFromConfig("MCP_BROKER_ISSUER", config.auth?.issuer);
93
+ envFromConfig("MCP_BROKER_PROVIDER_SECRET", config.auth?.providerSecret);
87
94
 
88
95
  const stdioProvider = process.env["MCP_BROKER_STDIO_PROVIDER"];
89
96
 
90
- // In stdio mode stdout is reserved for JSON-RPC — redirect all console output to stderr.
97
+ // In stdio mode stdout is reserved for JSON-RPC, redirect all console output to stderr.
91
98
  if (stdioProvider) {
92
99
  const toStderr = (...args: unknown[]) => process.stderr.write(args.join(" ") + "\n");
93
100
  console.log = toStderr;
@@ -120,6 +127,30 @@ if (protocolOverride === "https" && (!tlsCertPath || !tlsKeyPath)) {
120
127
 
121
128
  const useTls = protocolOverride === "http" ? false : protocolOverride === "https" ? true : !!(tlsCertPath && tlsKeyPath);
122
129
 
130
+ // ── Allowed browser origins for /<slot>/mcp ──────────────────────────────────
131
+ // Absent leaves the endpoint closed to browsers, which is the safe default.
132
+ // The env var only carries the list form; a pattern needs the config file,
133
+ // since a regular expression cannot survive comma-splitting.
134
+ const envOrigins = process.env["MCP_BROKER_ALLOWED_ORIGINS"]
135
+ ?.split(",")
136
+ .map((origin) => origin.trim())
137
+ .filter(Boolean);
138
+
139
+ let allowedOrigins: readonly string[] | RegExp | undefined;
140
+ if (envOrigins?.length) {
141
+ allowedOrigins = envOrigins;
142
+ } else if (Array.isArray(config.allowedOrigins)) {
143
+ allowedOrigins = config.allowedOrigins;
144
+ } else if (config.allowedOrigins) {
145
+ try {
146
+ allowedOrigins = new RegExp(config.allowedOrigins.pattern, config.allowedOrigins.flags);
147
+ } catch (err) {
148
+ // Falling back to the closed default rather than starting with a rule
149
+ // the operator believes is in force but which never compiled.
150
+ console.error(`[mcp-broker] Ignoring allowedOrigins.pattern: ${(err as Error).message}`);
151
+ }
152
+ }
153
+
123
154
  // ── Env-var static mount shortcuts (relative to cwd) ─────────────────────────
124
155
  const envWwwDir = process.env["MCP_BROKER_WWW_DIR"] ? path.resolve(cwd, process.env["MCP_BROKER_WWW_DIR"]) : null;
125
156
  const envBundleDir = process.env["MCP_BROKER_BUNDLE_DIR"] ? path.resolve(cwd, process.env["MCP_BROKER_BUNDLE_DIR"]) : null;
@@ -144,6 +175,10 @@ async function main(): Promise<void> {
144
175
  builder.withTlsFiles(tlsCertPath!, tlsKeyPath!);
145
176
  }
146
177
 
178
+ if (allowedOrigins) {
179
+ builder.withAllowedOrigins(allowedOrigins);
180
+ }
181
+
147
182
  // ── Static mounts ────────────────────────────────────────────────────────
148
183
  // First the env-var shortcuts (relative to cwd), then the config `www.mounts`
149
184
  // (relative to baseDir). Order matters: /bundle before / so the prefix router
@@ -209,6 +244,52 @@ async function main(): Promise<void> {
209
244
  builder.withBrokerLocalGrammarsDir(localGrammarsDir);
210
245
  }
211
246
 
247
+ // ── Authorization (OAuth 2.1 resource server) ────────────────────────────
248
+ // Opt-in: only wired when MCP_BROKER_AUTH_ENABLED / config.auth.enabled is on.
249
+ // Env scalars win over config; array/object fields are read from config.
250
+ const authEnabledEnv = process.env["MCP_BROKER_AUTH_ENABLED"];
251
+ const authEnabled = authEnabledEnv === "1" || authEnabledEnv === "true";
252
+ if (authEnabled) {
253
+ const publicBaseUrl = process.env["MCP_BROKER_PUBLIC_BASE_URL"];
254
+ const jwks = process.env["MCP_BROKER_JWKS"];
255
+ const issuer = process.env["MCP_BROKER_ISSUER"];
256
+ const authorizationServers = config.auth?.authorizationServers ?? (issuer ? [issuer] : []);
257
+ if (!publicBaseUrl || !jwks || authorizationServers.length === 0) {
258
+ console.error(
259
+ "[mcp-broker] auth.enabled requires publicBaseUrl, jwks, and at least one " +
260
+ "authorizationServers entry (or issuer). Set them via config.auth or " +
261
+ "MCP_BROKER_PUBLIC_BASE_URL / MCP_BROKER_JWKS / MCP_BROKER_ISSUER."
262
+ );
263
+ process.exit(1);
264
+ }
265
+ builder.withJwtAuth({
266
+ publicBaseUrl,
267
+ authorizationServers,
268
+ jwksUri: jwks,
269
+ issuer,
270
+ scopesSupported: config.auth?.scopesSupported,
271
+ requiredScopes: config.auth?.requiredScopes,
272
+ perSlotScopes: config.auth?.perSlotScopes,
273
+ providerScopes: config.auth?.providerScopes,
274
+ subjectMapping: config.auth?.subjectMapping,
275
+ roles: config.auth?.roles,
276
+ assignments: config.auth?.assignments,
277
+ denies: config.auth?.denies,
278
+ slotResources: config.auth?.slotResources,
279
+ toolCapabilities: config.auth?.toolCapabilities,
280
+ providerToolCapabilities: config.auth?.providerToolCapabilities,
281
+ audit: config.auth?.audit,
282
+ });
283
+ }
284
+
285
+ // ── Provider authentication (independent of client OAuth) ────────────────
286
+ // Requires every provider connecting to /provider/<slot> or /providers to
287
+ // present the shared secret, closes off slot occupation by strangers.
288
+ const providerSecret = process.env["MCP_BROKER_PROVIDER_SECRET"];
289
+ if (providerSecret) {
290
+ builder.withProviderSecret(providerSecret);
291
+ }
292
+
212
293
  const tunnel = builder.build();
213
294
  await tunnel.start();
214
295
 
@@ -230,6 +311,8 @@ async function main(): Promise<void> {
230
311
  if (hasLocalGrammars) {
231
312
  console.log(`🌐 Local grammars ${localGrammarsDir}`);
232
313
  }
314
+ console.log(`🔐 Authorization ${authEnabled ? "OAuth 2.1 (Bearer required)" : "disabled (trusted network only)"}`);
315
+ console.log(`🛡️ Provider auth ${providerSecret ? "shared secret required" : "disabled"}`);
233
316
  console.log(hr);
234
317
  console.log(` Press Ctrl+C to stop.`);
235
318
  console.log();
@@ -245,7 +328,7 @@ async function main(): Promise<void> {
245
328
 
246
329
  // ── Signal handlers ─────────────────────────────────────────────────────
247
330
  const shutdown = async (signal: string): Promise<void> => {
248
- console.log(`\n⛔ ${signal} received — shutting down…`);
331
+ console.log(`\n⛔ ${signal} received, shutting down…`);
249
332
  await tunnel.stop();
250
333
  process.exit(0);
251
334
  };
@@ -1,16 +1,16 @@
1
1
  import { McpAdapterBase, McpToolResults } from "@cyanmycelium/mcp-core";
2
2
  import type { McpResourceContent, McpToolResult } from "@cyanmycelium/mcp-core";
3
- import type { BrokerContext } from "../broker.context.js";
3
+ import type { IBrokerContext } from "../broker.context";
4
4
 
5
5
  /** URI of the static resource backing the `broker_info` snapshot. */
6
6
  export const BROKER_INFO_URI = "broker://info";
7
7
 
8
8
  /**
9
9
  * Read-only adapter that produces a snapshot of the broker's identity and
10
- * configuration. Backed by the live {@link BrokerContext}.
10
+ * configuration. Backed by the live {@link IBrokerContext}.
11
11
  */
12
12
  export class BrokerInfoAdapter extends McpAdapterBase {
13
- constructor(private readonly _context: BrokerContext) {
13
+ constructor(private readonly _context: IBrokerContext) {
14
14
  super("broker");
15
15
  }
16
16
 
@@ -1,6 +1,6 @@
1
1
  import { McpAdapterBase, McpToolResults } from "@cyanmycelium/mcp-core";
2
2
  import type { McpResourceContent, McpToolResult } from "@cyanmycelium/mcp-core";
3
- import type { BrokerContext, BrokerProviderInfo } from "../broker.context.js";
3
+ import type { IBrokerContext, IBrokerProviderInfo } from "../broker.context";
4
4
 
5
5
  /** URI of the static resource that lists every provider slot. */
6
6
  export const PROVIDERS_URI = "broker://providers";
@@ -13,7 +13,7 @@ export const PROVIDER_URI_TEMPLATE = "broker://providers/{name}";
13
13
  * (read of `broker://providers`) and individually (`broker://providers/<name>`).
14
14
  */
15
15
  export class BrokerProvidersAdapter extends McpAdapterBase {
16
- constructor(private readonly _context: BrokerContext) {
16
+ constructor(private readonly _context: IBrokerContext) {
17
17
  super("broker");
18
18
  }
19
19
 
@@ -60,7 +60,7 @@ export class BrokerProvidersAdapter extends McpAdapterBase {
60
60
  }
61
61
  }
62
62
 
63
- private _listSnapshot(): { count: number; providers: BrokerProviderInfo[] } {
63
+ private _listSnapshot(): { count: number; providers: IBrokerProviderInfo[] } {
64
64
  const providers = this._context.getProvidersInfo();
65
65
  return { count: providers.length, providers };
66
66
  }