drupal-mcp-connector 2.6.0 → 2.7.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.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Load connector secrets the same way for every entry point.
3
+ *
4
+ * MCP clients often spawn `node src/index.js` directly. The shell launcher
5
+ * cannot be the only place that applies `config/secrets.map`, or a process
6
+ * that skipped the launcher starts, resolves zero sites, and advertises only
7
+ * diagnostic tools — the 2.6.0/#180 failure on this machine.
8
+ *
9
+ * The default table matches config/config.example.json. A gitignored
10
+ * config/secrets.map replaces that table for a deployment whose env-var
11
+ * names differ. Per-item Keychain misses stay silent (inert break-glass).
12
+ * If the active config.json names secret env vars and none of them are set
13
+ * after this step, the caller must refuse to start.
14
+ */
15
+
16
+ import { execFileSync } from "node:child_process";
17
+ import { readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ /** Shipped env-var → Keychain-item pairs. Matches config/config.example.json. */
21
+ export const DEFAULT_SECRET_PAIRS = Object.freeze([
22
+ ["MCP_CONTENT_PRODUCTION_SECRET", "drupal-mcp-content-production"],
23
+ ["MCP_CONTENT_STAGING_SECRET", "drupal-mcp-content-staging"],
24
+ ["MCP_DEVELOPER_DEVELOPMENT_SECRET", "drupal-mcp-developer-development"],
25
+ ["MCP_ADMIN_BREAKGLASS_SECRET", "drupal-mcp-admin-breakglass"],
26
+ ]);
27
+
28
+ /**
29
+ * Parse a secrets.map body. `#` comments and malformed lines are ignored.
30
+ * @param {string} text
31
+ * @returns {Array<[string, string]>}
32
+ */
33
+ export function parseSecretMap(text) {
34
+ const pairs = [];
35
+ for (const raw of String(text).split(/\r?\n/)) {
36
+ const line = raw.replace(/#.*$/, "").trim();
37
+ if (!line || !line.includes("=")) continue;
38
+ const eq = line.indexOf("=");
39
+ const varName = line.slice(0, eq).trim();
40
+ const item = line.slice(eq + 1).trim();
41
+ if (varName && item) pairs.push([varName, item]);
42
+ }
43
+ return pairs;
44
+ }
45
+
46
+ /**
47
+ * Collect clientSecretEnv / apiTokenEnv names from a parsed config object.
48
+ * @param {object} cfg
49
+ * @returns {string[]}
50
+ */
51
+ export function namedSecretEnvVars(cfg) {
52
+ const names = [];
53
+ const seen = new Set();
54
+ const walk = (value) => {
55
+ if (!value || typeof value !== "object") return;
56
+ if (Array.isArray(value)) {
57
+ value.forEach(walk);
58
+ return;
59
+ }
60
+ for (const [key, val] of Object.entries(value)) {
61
+ if (key.startsWith("_")) continue;
62
+ if ((key === "clientSecretEnv" || key === "apiTokenEnv") && typeof val === "string") {
63
+ const name = val.trim();
64
+ if (name && !seen.has(name)) {
65
+ seen.add(name);
66
+ names.push(name);
67
+ }
68
+ continue;
69
+ }
70
+ walk(val);
71
+ }
72
+ };
73
+ walk(cfg);
74
+ return names;
75
+ }
76
+
77
+ /**
78
+ * Look up one macOS Keychain generic password. Returns "" when missing or
79
+ * when not on Darwin. Never throws. Never logs the value.
80
+ * @param {string} item
81
+ * @returns {string}
82
+ */
83
+ export function lookupKeychainItem(item) {
84
+ if (process.platform !== "darwin" || !item) return "";
85
+ try {
86
+ const value = execFileSync("security", ["find-generic-password", "-s", item, "-w"], {
87
+ encoding: "utf8",
88
+ stdio: ["ignore", "pipe", "ignore"],
89
+ });
90
+ return String(value).replace(/\n$/, "");
91
+ } catch {
92
+ return "";
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Apply the secret table and report how the active config lines up with env.
98
+ *
99
+ * @param {object} [options]
100
+ * @param {string} [options.cwd]
101
+ * @param {NodeJS.ProcessEnv} [options.env] Mutated when a lookup succeeds.
102
+ * @param {typeof readFileSync} [options.readFile]
103
+ * @param {(item: string) => string} [options.lookup]
104
+ * @returns {{pairs: number, resolved: number, named: string[], unset: string[]}}
105
+ */
106
+ export function loadLocalSecrets({
107
+ cwd = process.cwd(),
108
+ env = process.env,
109
+ readFile = readFileSync,
110
+ lookup = lookupKeychainItem,
111
+ } = {}) {
112
+ let pairs;
113
+ try {
114
+ pairs = parseSecretMap(readFile(join(cwd, "config", "secrets.map"), "utf8"));
115
+ } catch {
116
+ pairs = DEFAULT_SECRET_PAIRS;
117
+ }
118
+
119
+ let resolved = 0;
120
+ for (const [varName, item] of pairs) {
121
+ const current = new Map(Object.entries(env)).get(varName);
122
+ if (current) continue;
123
+ const value = lookup(item);
124
+ if (!value) continue;
125
+ Object.defineProperty(env, varName, {
126
+ value,
127
+ writable: true,
128
+ enumerable: true,
129
+ configurable: true,
130
+ });
131
+ resolved += 1;
132
+ }
133
+
134
+ let named = [];
135
+ try {
136
+ named = namedSecretEnvVars(JSON.parse(readFile(join(cwd, "config", "config.json"), "utf8")));
137
+ } catch {
138
+ named = [];
139
+ }
140
+
141
+ const envMap = new Map(Object.entries(env));
142
+ const unset = named.filter((name) => !envMap.get(name));
143
+ return { pairs: pairs.length, resolved, named, unset };
144
+ }
145
+
146
+ /**
147
+ * Refuse to boot a server that can only advertise diagnostic tools.
148
+ * @param {{named: string[], unset: string[]}} loaded
149
+ * @returns {string|null} Fatal message, or null when start is allowed.
150
+ */
151
+ export function secretLoadFatalMessage(loaded) {
152
+ if (!loaded.named.length || loaded.unset.length !== loaded.named.length) return null;
153
+ return (
154
+ `every clientSecretEnv/apiTokenEnv named in config.json is unset (${loaded.unset.join(", ")}). ` +
155
+ "Refusing to start. Map those names in config/secrets.map (ENV_VAR=keychain-item) " +
156
+ "or export them before launch."
157
+ );
158
+ }
@@ -7,6 +7,24 @@
7
7
 
8
8
  import { Server } from "@modelcontextprotocol/server";
9
9
 
10
+ /**
11
+ * Whether a listed resource URI (possibly templated) covers a requested URI.
12
+ * @param {string} listed
13
+ * @param {string} requested
14
+ * @returns {boolean}
15
+ */
16
+ function resourceUriIsListed(listed, requested) {
17
+ if (listed === requested) return true;
18
+ if (typeof listed !== "string" || !listed.includes("{site}")) return false;
19
+ const marker = "{site}";
20
+ const at = listed.indexOf(marker);
21
+ const prefix = listed.slice(0, at);
22
+ const suffix = listed.slice(at + marker.length);
23
+ if (!requested.startsWith(prefix) || !requested.endsWith(suffix)) return false;
24
+ const captured = requested.slice(prefix.length, requested.length - suffix.length);
25
+ return captured.length > 0 && !captured.includes("/");
26
+ }
27
+
10
28
  /**
11
29
  * Create the server factory shared by HTTP and stdio transports.
12
30
  *
@@ -14,9 +32,9 @@ import { Server } from "@modelcontextprotocol/server";
14
32
  * @param {{name: string, version: string}} surface.serverInfo
15
33
  * @param {{definitions: Array<object>, list?: () => Promise<Array<object>>, call: (name: string, args: object, context: object) => Promise<object>}} surface.tools
16
34
  * `definitions` is the full static surface (schema projection); the optional
17
- * `list` hook decides what is DISCOVERABLE per request (governance gating).
18
- * @param {{definitions: Array<object>, read: (uri: string) => Promise<object>}} surface.resources
19
- * @param {{definitions: Array<object>, get: (name: string, args: object) => Array<object>}} surface.prompts
35
+ * `list` hook decides what is DISCOVERABLE per request (governance + entitlement).
36
+ * @param {{definitions: Array<object>, list?: () => Promise<Array<object>>, read: (uri: string) => Promise<object>}} surface.resources
37
+ * @param {{definitions: Array<object>, list?: () => Promise<Array<object>>, get: (name: string, args: object) => Array<object>}} surface.prompts
20
38
  * @returns {(context: import("@modelcontextprotocol/server").McpRequestContext) => Server}
21
39
  */
22
40
  export function createConnectorServerFactory({ serverInfo, tools, resources, prompts }) {
@@ -37,9 +55,14 @@ export function createConnectorServerFactory({ serverInfo, tools, resources, pro
37
55
  return server.projectCallToolResult(result, toolDefinitions.get(name)?.outputSchema);
38
56
  });
39
57
 
40
- server.setRequestHandler("resources/list", async () => ({ resources: resources.definitions }));
58
+ server.setRequestHandler("resources/list", async () => ({
59
+ resources: resources.list ? await resources.list() : resources.definitions,
60
+ }));
41
61
  server.setRequestHandler("resources/read", async (request) => {
42
62
  const { uri } = request.params;
63
+ const visible = resources.list ? await resources.list() : resources.definitions;
64
+ const listed = visible.some((resource) => resourceUriIsListed(resource.uri, uri));
65
+ if (!listed) throw new Error(`Unknown resource: "${uri}"`);
43
66
  try {
44
67
  const data = await resources.read(uri);
45
68
  return {
@@ -50,10 +73,13 @@ export function createConnectorServerFactory({ serverInfo, tools, resources, pro
50
73
  }
51
74
  });
52
75
 
53
- server.setRequestHandler("prompts/list", async () => ({ prompts: prompts.definitions }));
76
+ server.setRequestHandler("prompts/list", async () => ({
77
+ prompts: prompts.list ? await prompts.list() : prompts.definitions,
78
+ }));
54
79
  server.setRequestHandler("prompts/get", async (request) => {
55
80
  const { name, arguments: args } = request.params;
56
- const known = prompts.definitions.find((prompt) => prompt.name === name);
81
+ const visible = prompts.list ? await prompts.list() : prompts.definitions;
82
+ const known = visible.find((prompt) => prompt.name === name);
57
83
  if (!known) throw new Error(`Unknown prompt: "${name}"`);
58
84
  return { description: known.description, messages: prompts.get(name, args ?? {}) };
59
85
  });
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Inbound principal entitlement (#178).
3
+ *
4
+ * HTTPS resource-server requests carry a validated JWT identity. Discovery
5
+ * and invocation are filtered by that identity's server-resolved grants.
6
+ * Stdio, loopback shared-bearer, and unauthenticated loopback have no
7
+ * inbound principal and keep the existing site + source-governance filter
8
+ * so a local operator is not hollowed out.
9
+ *
10
+ * Caller-supplied site, environment, tenant, target, or scope fields are
11
+ * hints. They never become authority. Empty inbound scopes are no grants,
12
+ * not a wildcard.
13
+ */
14
+
15
+ import { AsyncLocalStorage } from "node:async_hooks";
16
+ import { getDefaultSiteName, getInboundGrants } from "./config.js";
17
+ import { inferOperation } from "./operations.js";
18
+ import { resolveSecurityConfig, SecurityError } from "./security.js";
19
+
20
+ const identityStore = new AsyncLocalStorage();
21
+
22
+ /** Caller fields that look like a target but are never authority. */
23
+ export const TARGET_HINT_KEYS = Object.freeze(["site", "environment", "tenant", "target"]);
24
+
25
+ /** Always discoverable; they are how an operator sees a denial. */
26
+ export const DIAGNOSTIC_TOOLS = new Set([
27
+ "drupal_list_sites",
28
+ "drupal_governance_status",
29
+ ]);
30
+
31
+ const CONFIG_TOOLS = new Set([
32
+ "drupal_config_get",
33
+ "drupal_config_list",
34
+ "drupal_config_set",
35
+ "drupal_drush_config_export",
36
+ "drupal_drush_config_import",
37
+ "drupal_drush_config_status",
38
+ ]);
39
+
40
+ /** inferOperation() leaves these as "read"; they self-gate in-handler. */
41
+ const WRITE_BY_NAME = new Set([
42
+ "drupal_entity_create",
43
+ "drupal_entity_update",
44
+ "drupal_entity_delete",
45
+ ]);
46
+
47
+ const FREE_FORM_TOOLS = new Set([
48
+ "drupal_graphql",
49
+ "drupal_graphql_introspect",
50
+ "drupal_drush_sql_query",
51
+ ]);
52
+
53
+ const WRITE_WORKFLOW_PROMPTS = new Set([
54
+ "drupal-create-article",
55
+ "drupal-seo-fix",
56
+ "drupal-user-cleanup",
57
+ ]);
58
+
59
+ const READ_WORKFLOW_PROMPTS = new Set([
60
+ "drupal-content-audit",
61
+ "drupal-full-audit",
62
+ ]);
63
+
64
+ /**
65
+ * Run `fn` with `identity` as the request principal (null = local operator).
66
+ * @param {object|null} identity
67
+ * @param {Function} fn
68
+ * @returns {*}
69
+ */
70
+ export function runWithIdentity(identity, fn) {
71
+ return identityStore.run({ identity: identity ?? null }, fn);
72
+ }
73
+
74
+ /**
75
+ * The inbound identity for the current request, or null on stdio / loopback.
76
+ * @returns {object|null}
77
+ */
78
+ export function getRequestIdentity() {
79
+ return identityStore.getStore()?.identity ?? null;
80
+ }
81
+
82
+ /**
83
+ * Inbound scope required to discover or invoke a tool. Diagnostics need none.
84
+ * @param {string} toolName
85
+ * @returns {string|null}
86
+ */
87
+ export function requiredScopeForTool(toolName) {
88
+ if (DIAGNOSTIC_TOOLS.has(toolName)) return null;
89
+ if (toolName === "drupal_drush_sql_query") return "mcp_admin";
90
+ if (CONFIG_TOOLS.has(toolName)) return "mcp_config";
91
+ if (WRITE_BY_NAME.has(toolName)) return "mcp_write";
92
+ const op = inferOperation(toolName);
93
+ if (op === "write" || op === "delete") return "mcp_write";
94
+ return "mcp_read";
95
+ }
96
+
97
+ /**
98
+ * @param {object|null} identity
99
+ * @param {string|null} scope
100
+ * @returns {boolean}
101
+ */
102
+ export function principalHasScope(identity, scope) {
103
+ if (!scope) return true;
104
+ return (identity?.scopes ?? []).includes(scope);
105
+ }
106
+
107
+ /**
108
+ * Site names this principal may address. Unknown names in a grant are dropped.
109
+ *
110
+ * @param {object|null} identity
111
+ * @param {string[]} configuredNames
112
+ * @param {object|null} [grants] `auth.grants` map; `undefined` reads config.
113
+ * @returns {string[]}
114
+ */
115
+ export function resolveGrantedSiteNames(identity, configuredNames, grants) {
116
+ if (!identity) return [...configuredNames];
117
+ const known = new Set(configuredNames);
118
+ const grantMap = grants === undefined ? getInboundGrants() : grants;
119
+
120
+ if (grantMap) {
121
+ const listed = identity.clientId
122
+ ? new Map(Object.entries(grantMap)).get(identity.clientId)
123
+ : undefined;
124
+ if (!Array.isArray(listed)) return [];
125
+ return listed.map(String).filter((name) => known.has(name));
126
+ }
127
+
128
+ if (Array.isArray(identity.sites)) {
129
+ return identity.sites.map(String).filter((name) => known.has(name));
130
+ }
131
+
132
+ return [...configuredNames];
133
+ }
134
+
135
+ /**
136
+ * @param {object|null} identity
137
+ * @param {Array<{_name: string}>} sites
138
+ * @param {object|null} [grants]
139
+ * @returns {Array<object>}
140
+ */
141
+ export function resolveGrantedSites(identity, sites, grants) {
142
+ const allowed = new Set(
143
+ resolveGrantedSiteNames(identity, sites.map((site) => site._name), grants),
144
+ );
145
+ return sites.filter((site) => allowed.has(site._name));
146
+ }
147
+
148
+ /**
149
+ * @param {object} site
150
+ * @param {string} toolName
151
+ * @returns {boolean}
152
+ */
153
+ function siteAllowsTool(site, toolName) {
154
+ const sec = resolveSecurityConfig(site);
155
+ if (toolName === "drupal_graphql" || toolName === "drupal_graphql_introspect") {
156
+ return Boolean(sec.allowGraphql);
157
+ }
158
+ if (toolName === "drupal_drush_sql_query") {
159
+ return site.drushSsh?.rawSql === "governed";
160
+ }
161
+ if (toolName === "drupal_config_set") {
162
+ return Boolean(sec.allowConfigWrite) && !sec.readOnly;
163
+ }
164
+ if (toolName === "drupal_config_get" || toolName === "drupal_config_list") {
165
+ return Boolean(sec.allowConfigRead);
166
+ }
167
+ const op = inferOperation(toolName);
168
+ const writeLike = op === "write" || op === "delete" || WRITE_BY_NAME.has(toolName);
169
+ if (writeLike && sec.readOnly) return false;
170
+ if ((op === "delete" || toolName === "drupal_entity_delete") && !sec.allowDestructive) {
171
+ return false;
172
+ }
173
+ return true;
174
+ }
175
+
176
+ /**
177
+ * @param {string} toolName
178
+ * @param {object|null} identity
179
+ * @param {Array<object>} sites
180
+ * @param {object|null} [grants]
181
+ * @returns {boolean}
182
+ */
183
+ export function principalMayUseTool(toolName, identity, sites, grants) {
184
+ if (!identity) return true;
185
+ if (DIAGNOSTIC_TOOLS.has(toolName)) return true;
186
+ if (!principalHasScope(identity, requiredScopeForTool(toolName))) return false;
187
+ const entitled = resolveGrantedSites(identity, sites, grants);
188
+ if (!entitled.length) return false;
189
+ if (FREE_FORM_TOOLS.has(toolName)) {
190
+ return entitled.some((site) => siteAllowsTool(site, toolName));
191
+ }
192
+ return entitled.some((site) => siteAllowsTool(site, toolName));
193
+ }
194
+
195
+ /**
196
+ * @param {Array<{name: string}>} definitions
197
+ * @param {Array<object>} sites
198
+ * @param {object|null} identity
199
+ * @param {object|null} [grants]
200
+ * @returns {Array<object>}
201
+ */
202
+ export function filterToolsByPrincipal(definitions, sites, identity, grants) {
203
+ if (!identity) return definitions;
204
+ return definitions.filter((definition) =>
205
+ principalMayUseTool(definition.name, identity, sites, grants));
206
+ }
207
+
208
+ /**
209
+ * @param {object} [args]
210
+ * @returns {Array<{key: string, value: string}>}
211
+ */
212
+ export function callerTargetHints(args = {}) {
213
+ const found = [];
214
+ for (const key of TARGET_HINT_KEYS) {
215
+ const value = new Map(Object.entries(args ?? {})).get(key);
216
+ if (typeof value === "string" && value.trim()) {
217
+ found.push({ key, value: value.trim() });
218
+ }
219
+ }
220
+ return found;
221
+ }
222
+
223
+ /**
224
+ * @param {object} site
225
+ * @param {string} source
226
+ * @returns {{name: string, baseUrl?: string, source: string}}
227
+ */
228
+ export function describeTarget(site, source) {
229
+ return {
230
+ name: site._name,
231
+ baseUrl: site.baseUrl,
232
+ source,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Public site list for tools/resources. Never includes credentials.
238
+ *
239
+ * @param {object|null} identity
240
+ * @param {Array<object>} resolvable
241
+ * @param {string[]} configuredNames
242
+ * @param {object|null} [grants]
243
+ * @returns {{sites: string[], targets: Array<{name: string, baseUrl?: string, source: string}>}}
244
+ */
245
+ export function visibleSiteTargets(identity, resolvable, configuredNames, grants) {
246
+ const granted = identity ? resolveGrantedSites(identity, resolvable, grants) : resolvable;
247
+ const names = identity
248
+ ? granted.map((site) => site._name)
249
+ : [...configuredNames];
250
+ const byName = new Map(granted.map((site) => [site._name, site]));
251
+ const source = identity ? "grant" : "config";
252
+ return {
253
+ sites: names,
254
+ targets: names.map((name) => {
255
+ const site = byName.get(name);
256
+ return site ? describeTarget(site, source) : { name, source };
257
+ }),
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Resolve the site this principal may use for a call.
263
+ *
264
+ * @param {object} args
265
+ * @param {object} identity
266
+ * @param {Array<object>} sites
267
+ * @param {{grants?: object|null, defaultSite?: string}} [options]
268
+ * @returns {{site: object, source: string, name: string}}
269
+ * @throws {SecurityError}
270
+ */
271
+ export function resolveAuthoritativeTarget(args, identity, sites, options = {}) {
272
+ const grantMap = options.grants === undefined ? getInboundGrants() : options.grants;
273
+ const entitled = resolveGrantedSites(identity, sites, grantMap);
274
+ const hints = callerTargetHints(args);
275
+ const unique = [...new Set(hints.map((hint) => hint.value))];
276
+
277
+ if (unique.length > 1) {
278
+ throw new SecurityError(
279
+ "Conflicting caller target hints do not select a single granted target.",
280
+ );
281
+ }
282
+
283
+ if (unique.length === 1) {
284
+ const site = entitled.find((entry) => entry._name === unique[0]);
285
+ if (!site) {
286
+ throw new SecurityError("Not entitled to the requested target.");
287
+ }
288
+ return { site, source: "hint", name: site._name };
289
+ }
290
+
291
+ const defaultName = options.defaultSite ?? getDefaultSiteName();
292
+ const fromDefault = entitled.find((entry) => entry._name === defaultName);
293
+ if (fromDefault) {
294
+ return { site: fromDefault, source: "default", name: fromDefault._name };
295
+ }
296
+ if (entitled.length === 1) {
297
+ return { site: entitled[0], source: "grant", name: entitled[0]._name };
298
+ }
299
+
300
+ throw new SecurityError(
301
+ entitled.length
302
+ ? "No authoritative target could be resolved from the principal grant."
303
+ : "Principal is not entitled to any configured target.",
304
+ );
305
+ }
306
+
307
+ /**
308
+ * Deny unauthorized invocation. Returns the resolved target, or null for
309
+ * tools that do not address a site.
310
+ *
311
+ * @param {object} params
312
+ * @returns {{site: object, source: string, name: string}|null}
313
+ * @throws {SecurityError}
314
+ */
315
+ export function assertPrincipalEntitlement({
316
+ toolName,
317
+ args,
318
+ identity,
319
+ sites,
320
+ grants,
321
+ defaultSite,
322
+ }) {
323
+ if (!identity) return null;
324
+ if (!principalMayUseTool(toolName, identity, sites, grants)) {
325
+ throw new SecurityError(`Not entitled to invoke ${toolName}.`);
326
+ }
327
+ // list_sites has no target. governance_status without a hint reports every
328
+ // granted site — pinning it to the default would hide the rest.
329
+ if (toolName === "drupal_list_sites") return null;
330
+ if (toolName === "drupal_governance_status" && callerTargetHints(args).length === 0) {
331
+ return null;
332
+ }
333
+ return resolveAuthoritativeTarget(args, identity, sites, { grants, defaultSite });
334
+ }
335
+
336
+ /**
337
+ * @param {Array<object>} resources
338
+ * @param {object|null} identity
339
+ * @param {Array<object>} sites
340
+ * @param {object|null} [grants]
341
+ * @returns {Array<object>}
342
+ */
343
+ export function filterResourcesByPrincipal(resources, identity, sites, grants) {
344
+ if (!identity) return resources;
345
+ const entitled = resolveGrantedSites(identity, sites, grants);
346
+ const canRead = principalHasScope(identity, "mcp_read") && entitled.length > 0;
347
+ return resources.filter((resource) => {
348
+ if (resource.uri === "drupal://sites") return true;
349
+ return canRead;
350
+ });
351
+ }
352
+
353
+ /**
354
+ * @param {Array<object>} prompts
355
+ * @param {object|null} identity
356
+ * @param {Array<{name: string}>} visibleTools
357
+ * @returns {Array<object>}
358
+ */
359
+ export function filterPromptsByPrincipal(prompts, identity, visibleTools) {
360
+ if (!identity) return prompts;
361
+ const visible = new Set((visibleTools ?? []).map((tool) => tool.name));
362
+ return prompts.filter((prompt) => {
363
+ if (WRITE_WORKFLOW_PROMPTS.has(prompt.name)) {
364
+ return principalHasScope(identity, "mcp_write");
365
+ }
366
+ if (READ_WORKFLOW_PROMPTS.has(prompt.name)) {
367
+ return principalHasScope(identity, "mcp_read");
368
+ }
369
+ const toolName = prompt.name.replace(/-/g, "_");
370
+ return visible.has(toolName);
371
+ });
372
+ }
package/src/lib/verify.js CHANGED
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { createHash } from "node:crypto";
21
21
  import { CLIENT_VERSION } from "./config.js";
22
+ import { resolveInboundAuthConfig, resolveInboundAuthMode } from "./http-auth.js";
22
23
 
23
24
  /** Check outcome vocabulary.
24
25
  *
@@ -44,6 +45,7 @@ export const STATIC_CHECKS = [
44
45
  "entitlement",
45
46
  "target_resolution",
46
47
  "tenant_neutrality",
48
+ "inbound_auth",
47
49
  ];
48
50
 
49
51
  /**
@@ -222,13 +224,14 @@ export function configDigest(config) {
222
224
  * credentials, no side effects.
223
225
  *
224
226
  * @param {object} config Parsed connector configuration.
225
- * @param {{source?: string, now?: () => Date}} [options]
227
+ * @param {{source?: string, now?: () => Date, env?: NodeJS.ProcessEnv}} [options]
226
228
  * `source` names what was verified (a path, or a label) for the evidence;
227
- * `now` is injectable so a run is reproducible in tests.
229
+ * `now` is injectable so a run is reproducible in tests;
230
+ * `env` is the process environment under verification (defaults to `process.env`).
228
231
  * @returns {object} Evidence document: tool, version, subject, checks,
229
232
  * residuals and a summary. Never contains secret values.
230
233
  */
231
- export function verifyStatic(config, { source = "config", now = () => new Date() } = {}) {
234
+ export function verifyStatic(config, { source = "config", now = () => new Date(), env = process.env } = {}) {
232
235
  const sites = Object.entries(config?.sites ?? {});
233
236
  const named = (name, message) => `${name}: ${message}`;
234
237
  const nothingToCheck = sites.length === 0;
@@ -364,12 +367,44 @@ export function verifyStatic(config, { source = "config", now = () => new Date()
364
367
  const tenantNeutrality = check(
365
368
  "tenant_neutrality",
366
369
  "The configuration names no real tenant hosts or identifiers",
367
- mentionedHosts(config?.sites ?? {})
370
+ mentionedHosts({ sites: config?.sites ?? {}, auth: config?.auth ?? {} })
368
371
  .filter(({ host }) => !isNeutralHost(host))
369
372
  .map(({ path, host }) => `${path}: "${host}" is not a documentation-reserved host; a shipped example must not name a real deployment.`),
370
373
  { skipped: nothingToCheck },
371
374
  );
372
375
 
376
+ const inbound = resolveInboundAuthConfig(config, env);
377
+ const inboundFindings = [];
378
+ if (inbound.issuer || inbound.audience || inbound.resource) {
379
+ if (!inbound.issuer) inboundFindings.push("auth.issuer is missing.");
380
+ else if (!String(inbound.issuer).startsWith("https://")) {
381
+ inboundFindings.push("auth.issuer is not HTTPS.");
382
+ }
383
+ if (!inbound.audience) inboundFindings.push("auth.audience is missing.");
384
+ if (inbound.resource && !String(inbound.resource).startsWith("https://")) {
385
+ inboundFindings.push("auth.resource is not HTTPS.");
386
+ }
387
+ if (inbound.introspectionUrl && !String(inbound.introspectionUrl).startsWith("https://")) {
388
+ inboundFindings.push("auth.introspectionUrl is not HTTPS.");
389
+ }
390
+ }
391
+ const transportName = env.MCP_TRANSPORT || "stdio";
392
+ if (transportName === "https" || transportName === "http") {
393
+ const decision = resolveInboundAuthMode({
394
+ bindHost: env.MCP_BIND_HOST || "0.0.0.0",
395
+ allowUnauth: env.MCP_ALLOW_UNAUTHENTICATED === "1",
396
+ sharedToken: env.MCP_AUTH_TOKEN || "",
397
+ resourceServer: inbound,
398
+ });
399
+ if (decision.mode === "fatal") inboundFindings.push(decision.reason);
400
+ }
401
+
402
+ const inboundAuth = check(
403
+ "inbound_auth",
404
+ "Network-facing HTTPS authenticates as an OAuth protected resource",
405
+ inboundFindings,
406
+ );
407
+
373
408
  const checks = [
374
409
  transport,
375
410
  principalAuth,
@@ -379,6 +414,7 @@ export function verifyStatic(config, { source = "config", now = () => new Date()
379
414
  entitlement,
380
415
  targetResolution,
381
416
  tenantNeutrality,
417
+ inboundAuth,
382
418
  ];
383
419
 
384
420
  const counts = checks.reduce(