@cyanmycelium/mcp-broker 1.2.0 → 1.3.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 (49) hide show
  1. package/.mcp-broker.example/CONFIGURATION-EN.md +300 -44
  2. package/.mcp-broker.example/CONFIGURATION-FR.md +313 -44
  3. package/.mcp-broker.example/README.md +73 -2
  4. package/.mcp-broker.example/config.json +18 -9
  5. package/.mcp-broker.example/config.stdio-bridge.json +16 -0
  6. package/README.md +407 -27
  7. package/dist/bin.js +215 -20
  8. package/dist/bin.js.map +1 -1
  9. package/dist/chunk-BZUZYXVA.js +5955 -0
  10. package/dist/chunk-BZUZYXVA.js.map +1 -0
  11. package/dist/grammars/claude/en.json +12 -0
  12. package/dist/grammars/claude/fr.json +12 -0
  13. package/dist/grammars/default/en.json +40 -0
  14. package/dist/grammars/default/fr.json +40 -0
  15. package/dist/grammars/default/zh.json +40 -0
  16. package/dist/index.d.ts +991 -25
  17. package/dist/index.js +1 -1
  18. package/package.json +3 -3
  19. package/src/auth/index.ts +3 -1
  20. package/src/auth/provider.auth.ts +126 -8
  21. package/src/authorization/policy.engine.ts +11 -2
  22. package/src/authorization/policy.types.ts +25 -1
  23. package/src/bin.ts +325 -28
  24. package/src/broker/adapters/broker.adapter.diagnose.ts +45 -0
  25. package/src/broker/adapters/broker.adapter.guide.ts +108 -0
  26. package/src/broker/aggregate/aggregate.server.ts +82 -15
  27. package/src/broker/aggregate/provider.client.session.ts +85 -11
  28. package/src/broker/behaviors/broker.behavior.diagnose.ts +47 -0
  29. package/src/broker/behaviors/broker.behavior.guide.ts +79 -0
  30. package/src/broker/broker.context.ts +65 -0
  31. package/src/broker/broker.diagnostics.ts +495 -0
  32. package/src/broker/broker.guides.ts +1029 -0
  33. package/src/broker/broker.server.ts +23 -7
  34. package/src/broker/broker.slots.ts +36 -0
  35. package/src/broker/grammars/claude/en.json +12 -0
  36. package/src/broker/grammars/claude/fr.json +12 -0
  37. package/src/broker/grammars/default/en.json +40 -0
  38. package/src/broker/grammars/default/fr.json +40 -0
  39. package/src/broker/grammars/default/zh.json +40 -0
  40. package/src/config.ts +191 -4
  41. package/src/index.ts +38 -3
  42. package/src/remote.transports.ts +127 -10
  43. package/src/remote.upstream.ts +4 -1
  44. package/src/ws/ws.interfaces.ts +148 -3
  45. package/src/ws/ws.tunnel.builder.ts +63 -1
  46. package/src/ws/ws.tunnel.ts +1150 -173
  47. package/web/README.md +31 -4
  48. package/dist/chunk-FTDKH2C4.js +0 -3670
  49. package/dist/chunk-FTDKH2C4.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { AuthError, BROKER_PROVIDER_NAME, BrokerInfoBehavior, BrokerProvidersBehavior, ConfigPolicyEngine, ConfiguredCapabilityClassifier, DEFAULT_CONFIG_FILENAME, DefaultSlotResourceResolver, HttpAuthGuard, JwtSubjectMapper, JwtTokenValidator, PACKAGE_NAME, RemoteUpstream, ResourcePath, ResourcePathPattern, SharedSecretProviderAuthenticator, StdioUpstream, SubjectMappingError, VERSION, WsTunnel, WsTunnelBuilder, authorizationWithEngine, brokerGrammarKey, buildJwtAuth, buildResourceMetadata, compileAuthorizationPolicy, hasAuthorizationPolicies, iterAvailableBrokerGrammars, iterBrokerGrammarsFrom, loadBrokerConfig, loadBrokerGrammar, loadMcpbBundle, normalizeProviderAuthentication, providerMayPublish, scopesOf, startBrokerServer, unzipMcpb, validateCapability } from './chunk-FTDKH2C4.js';
1
+ export { AuthError, BROKER_AGGREGATE_NAME, BROKER_GUIDES, BROKER_GUIDE_MIME_TYPE, BROKER_GUIDE_TOPICS, BROKER_GUIDE_URI_PREFIX, BROKER_GUIDE_URI_TEMPLATE, BROKER_PROVIDER_NAME, BROKER_RESERVED_SLOTS, BrokerDiagnoseAdapter, BrokerDiagnoseBehavior, BrokerGuideAdapter, BrokerGuideBehavior, BrokerInfoBehavior, BrokerProvidersBehavior, ConfigPolicyEngine, ConfiguredCapabilityClassifier, DEFAULT_CONFIG_FILENAME, DefaultSlotResourceResolver, HttpAuthGuard, JwtSubjectMapper, JwtTokenValidator, PACKAGE_NAME, RemoteUpstream, ResourcePath, ResourcePathPattern, SharedSecretProviderAuthenticator, StdioUpstream, SubjectMappingError, VERSION, WsTunnel, WsTunnelBuilder, authorizationWithEngine, brokerGrammarKey, brokerGuide, brokerGuideIndex, brokerGuideTopicFromUri, brokerGuideUri, buildJwtAuth, buildResourceMetadata, compileAuthorizationPolicy, compileProviderAllowedResources, diagnoseBroker, hasAuthorizationPolicies, isReservedBrokerSlot, iterAvailableBrokerGrammars, iterBrokerGrammarsFrom, loadBrokerConfig, loadBrokerGrammar, loadMcpbBundle, normalizeProviderAuthentication, providerMayPublish, providerPublishDecision, resolveOpenTarget, scopesOf, startBrokerServer, unzipMcpb, validateCapability } from './chunk-BZUZYXVA.js';
2
2
  //# sourceMappingURL=index.js.map
3
3
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanmycelium/mcp-broker",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "WebSocket-based Model Context Protocol broker. Aggregates multiple MCP providers behind a single endpoint with stdio, SSE, and Streamable HTTP client transports.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -68,13 +68,13 @@
68
68
  "prepublishOnly": "npm run lint && npm run build && npm test"
69
69
  },
70
70
  "dependencies": {
71
- "@cyanmycelium/mcp-core": "^0.7.0",
71
+ "@cyanmycelium/mcp-core": "^1.0.0",
72
72
  "jose": "^6.2.3",
73
73
  "open": "^11.0.0",
74
74
  "ws": "^8.18.0"
75
75
  },
76
76
  "devDependencies": {
77
- "@cyanmycelium/mcp-broker-provider": "^0.1.0",
77
+ "@cyanmycelium/mcp-broker-provider": "^0.2.0",
78
78
  "@types/node": "^20.11.0",
79
79
  "@types/ws": "^8.5.0",
80
80
  "@typescript-eslint/eslint-plugin": "^7.0.0",
package/src/auth/index.ts CHANGED
@@ -23,12 +23,14 @@ export { HttpAuthGuard } from "./http.auth";
23
23
  export { buildJwtAuth } from "./auth.config";
24
24
  export type { IJwtAuthOptions, JwtAuthOptions } from "./auth.config";
25
25
  export { SharedSecretProviderAuthenticator } from "./provider.auth";
26
- export { normalizeProviderAuthentication, providerMayPublish } from "./provider.auth";
26
+ export { compileProviderAllowedResources, normalizeProviderAuthentication, providerMayPublish, providerPublishDecision } from "./provider.auth";
27
27
  export type {
28
28
  IProviderAuthenticator,
29
29
  IProviderPrincipal,
30
+ IProviderPublishDecision,
30
31
  ProviderAuthenticationResult,
31
32
  ProviderAuthenticator,
32
33
  ProviderAuthenticatorReturn,
33
34
  ProviderPrincipal,
35
+ ProviderPublishDenialReason,
34
36
  } from "./provider.auth";
@@ -40,16 +40,134 @@ export function normalizeProviderAuthentication(result: ProviderAuthenticatorRet
40
40
  return result;
41
41
  }
42
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;
43
+ /** Wording shared by every "this pattern is not valid" diagnostic. */
44
+ const PATTERN_SYNTAX_HINT = 'Resource patterns are absolute and segment-based: "/enterprise/**", "/enterprise/*/line-3", "/enterprise/site-a/asset", or "**" for everything.';
45
+
46
+ /**
47
+ * Compiled `allowedResources` patterns, keyed by the exact pattern string, with
48
+ * the compile failure cached alongside the successes.
49
+ *
50
+ * {@link providerMayPublish} runs on every provider WebSocket upgrade, and the
51
+ * previous per-call `ResourcePathPattern.parse` inside a `try` meant a single
52
+ * typo re-threw on every registration and was mapped to a bare `false`. Caching
53
+ * the outcome makes the parse happen once per distinct pattern and gives the
54
+ * failure a stable identity we can report exactly once.
55
+ */
56
+ const compiledPatterns = new Map<string, ResourcePathPattern | Error>();
57
+
58
+ /** Malformed-pattern reports already emitted, so a wedged config logs once. */
59
+ const reportedMalformedPatterns = new Set<string>();
60
+
61
+ /** Parses one pattern, memoizing both the success and the failure. */
62
+ function compilePattern(pattern: string): ResourcePathPattern | Error {
63
+ const cached = compiledPatterns.get(pattern);
64
+ if (cached !== undefined) return cached;
65
+ let compiled: ResourcePathPattern | Error;
47
66
  try {
48
- const patterns = allowed.map((pattern) => ResourcePathPattern.parse(pattern));
49
- return patterns.some((pattern) => pattern.matches(resource));
50
- } catch {
51
- return false;
67
+ compiled = ResourcePathPattern.parse(pattern);
68
+ } catch (error) {
69
+ compiled = error instanceof Error ? error : new Error(String(error));
70
+ }
71
+ compiledPatterns.set(pattern, compiled);
72
+ return compiled;
73
+ }
74
+
75
+ /** Why a provider was refused a slot. `undefined` when it was allowed. */
76
+ export type ProviderPublishDenialReason = "no-allowed-resources" | "out-of-namespace" | "malformed-pattern";
77
+
78
+ /** The outcome of a provider namespace check, with the cause when it denies. */
79
+ export interface IProviderPublishDecision {
80
+ readonly allowed: boolean;
81
+ /** Machine-readable cause, present only when `allowed` is `false`. */
82
+ readonly reason?: ProviderPublishDenialReason;
83
+ /** Operator-facing explanation that already names the fix. */
84
+ readonly detail?: string;
85
+ }
86
+
87
+ /**
88
+ * Compiles a principal's `allowedResources`, throwing on the first malformed
89
+ * pattern with the offending pattern quoted in the message.
90
+ *
91
+ * Call this wherever a provider principal is *built* (config load, a custom
92
+ * authenticator's constructor) so a typo fails the broker at startup instead of
93
+ * silently 403-ing every provider on that principal forever.
94
+ * {@link providerPublishDecision} deliberately does not throw: it runs inside the
95
+ * WebSocket upgrade, where the only safe answer to "is this pattern valid?" is
96
+ * to deny loudly.
97
+ */
98
+ export function compileProviderAllowedResources(allowed: readonly string[], label = "provider allowedResources"): readonly ResourcePathPattern[] {
99
+ return allowed.map((pattern) => {
100
+ const compiled = compilePattern(pattern);
101
+ if (compiled instanceof Error) {
102
+ throw new Error(`${label}: "${pattern}" is not a valid resource pattern: ${compiled.message} ${PATTERN_SYNTAX_HINT}`);
103
+ }
104
+ return compiled;
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Decides whether a provider principal may claim `resource`, and says why when
110
+ * it may not.
111
+ *
112
+ * Every denial here surfaces to the provider as a bare 403 at the WebSocket
113
+ * handshake, which a browser reports as a contentless error event. The reason is
114
+ * therefore the only thing an operator (or an agent wiring this up) has to work
115
+ * from, so it is returned to the caller for the registration log and, for the
116
+ * configuration-fault case, logged here as well.
117
+ */
118
+ export function providerPublishDecision(principal: IProviderPrincipal, resource: ResourcePath): IProviderPublishDecision {
119
+ const allowed = principal.allowedResources;
120
+ if (allowed === undefined) return { allowed: true };
121
+ if (allowed.length === 0) {
122
+ return {
123
+ allowed: false,
124
+ reason: "no-allowed-resources",
125
+ detail:
126
+ `Provider principal "${principal.id}" has an empty allowedResources list, which forbids every slot. ` +
127
+ `Remove the key entirely to allow all slots, or list the namespaces this principal may publish, for example ["/enterprise/**"].`,
128
+ };
129
+ }
130
+
131
+ const patterns: ResourcePathPattern[] = [];
132
+ const malformed: string[] = [];
133
+ for (const pattern of allowed) {
134
+ const compiled = compilePattern(pattern);
135
+ if (compiled instanceof Error) malformed.push(`"${pattern}" (${compiled.message})`);
136
+ else patterns.push(compiled);
52
137
  }
138
+
139
+ if (malformed.length > 0) {
140
+ const detail =
141
+ `Provider principal "${principal.id}" carries ${malformed.length} malformed allowedResources pattern(s): ${malformed.join("; ")} ` +
142
+ `Every provider registration on this principal is refused with 403 until they are fixed, whatever slot it asks for. ${PATTERN_SYNTAX_HINT}`;
143
+ // Loud, but once per (principal, bad pattern set): this is a
144
+ // startup-class configuration fault the broker can never honor, and its
145
+ // request-time symptom is a 403 that names nothing.
146
+ const key = `${principal.id}|${malformed.join("|")}`;
147
+ if (!reportedMalformedPatterns.has(key)) {
148
+ reportedMalformedPatterns.add(key);
149
+ console.error(`[broker] provider auth: ${detail}`);
150
+ }
151
+ return { allowed: false, reason: "malformed-pattern", detail };
152
+ }
153
+
154
+ if (patterns.some((pattern) => pattern.matches(resource))) return { allowed: true };
155
+ return {
156
+ allowed: false,
157
+ reason: "out-of-namespace",
158
+ detail:
159
+ `Provider principal "${principal.id}" may not publish resource "${resource.value}": it is outside allowedResources [${allowed.join(", ")}]. ` +
160
+ `Either connect the provider to a slot inside one of those namespaces, or widen allowedResources for this principal.`,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Boolean form of {@link providerPublishDecision}, kept for callers that only
166
+ * need the verdict. Prefer the decision form so the refusal can be logged with
167
+ * its cause.
168
+ */
169
+ export function providerMayPublish(principal: IProviderPrincipal, resource: ResourcePath): boolean {
170
+ return providerPublishDecision(principal, resource).allowed;
53
171
  }
54
172
 
55
173
  /** Constant-time string comparison; `false` on any length mismatch. */
@@ -172,8 +172,17 @@ export class ConfigPolicyEngine implements IPolicyEngine {
172
172
  if (!request.resource) return { allowed: false, reason: "invalid-resource" };
173
173
  try {
174
174
  validateCapability(request.capability, "request capability");
175
- } catch {
176
- return { allowed: false, reason: "no-matching-grant" };
175
+ } catch (error) {
176
+ // Not a policy miss: the capability string itself is malformed, so no
177
+ // grant could ever match it. Reporting this as "no-matching-grant"
178
+ // pointed operators at their grants instead of at the mapping that
179
+ // produced the bad value.
180
+ const detail = error instanceof Error ? error.message : String(error);
181
+ console.error(
182
+ `[broker] authorization: capability "${String(request.capability)}" requested on resource "${request.resource.value}" is malformed, denying. ${detail} ` +
183
+ `Capability names look like "mcp.tools.call" or "broker.providers.read": fix the authorization.toolCapabilities / providerToolCapabilities entry that produced this value, or the caller that passed it.`
184
+ );
185
+ return { allowed: false, reason: "invalid-capability" };
177
186
  }
178
187
 
179
188
  const matchingDenies = new Set<string>();
@@ -13,7 +13,31 @@ export interface IAuthorizationRequest {
13
13
  readonly tool?: string;
14
14
  }
15
15
 
16
- export type AuthorizationDecisionReason = "explicit-deny" | "role-grant" | "no-matching-grant" | "invalid-resource" | "unknown-resource";
16
+ /**
17
+ * Why a decision came out the way it did, as written to the audit log.
18
+ *
19
+ * The distinction between a policy outcome and a fault is load-bearing: the
20
+ * audit log is what an operator actually reads, and reporting a fault as
21
+ * `"no-matching-grant"` sends them off writing grants that can never help.
22
+ *
23
+ * - `explicit-deny` a deny policy matched.
24
+ * - `role-grant` an assignment matched; the only allowing reason.
25
+ * - `no-matching-grant` no policy matched. A genuine policy outcome.
26
+ * - `invalid-resource` the request carried no resource at all.
27
+ * - `unknown-resource` the slot maps to no configured resource path.
28
+ * - `invalid-capability` the requested capability string is malformed, so no
29
+ * grant could ever match it. A configuration fault.
30
+ * - `evaluation-error` evaluation threw. Nothing was decided; the request is
31
+ * denied because that is the safe answer. A fault.
32
+ */
33
+ export type AuthorizationDecisionReason =
34
+ | "explicit-deny"
35
+ | "role-grant"
36
+ | "no-matching-grant"
37
+ | "invalid-resource"
38
+ | "unknown-resource"
39
+ | "invalid-capability"
40
+ | "evaluation-error";
17
41
 
18
42
  export interface IAuthorizationDecision {
19
43
  readonly allowed: boolean;