drupal-mcp-connector 2.6.1 → 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.
@@ -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(
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { getSiteConfig } from "../lib/config.js";
15
+ import { describeTarget, getRequestIdentity } from "../lib/principal.js";
15
16
  import {
16
17
  resolveSecurityConfig,
17
18
  getSecuritySummary,
@@ -117,8 +118,13 @@ async function whoami({ site: siteName }) {
117
118
  // When no OAuth scopes are configured, hasScope() is a no-op (preset-only).
118
119
  const canWrite = !sec.readOnly && hasScope(site, "mcp_write");
119
120
  const canConfig = hasScope(site, "mcp_config");
121
+ const identity = getRequestIdentity();
120
122
  return {
121
123
  site: site._name,
124
+ target: describeTarget(site, siteName ? "hint" : "default"),
125
+ principal: identity
126
+ ? { sub: identity.sub, clientId: identity.clientId, scopes: [...(identity.scopes ?? [])] }
127
+ : null,
122
128
  tier: inferTier(site, sec),
123
129
  preset: summary.preset,
124
130
  scopes: site.oauth?.scopes ?? [],
package/src/tools/site.js CHANGED
@@ -9,6 +9,7 @@
9
9
  import { getSiteConfig, listSiteNames } from "../lib/config.js";
10
10
  import { governanceStatus } from "../lib/governance.js";
11
11
  import { resolveBackend } from "../lib/backends/index.js";
12
+ import { getRequestIdentity, resolveGrantedSiteNames, visibleSiteTargets } from "../lib/principal.js";
12
13
 
13
14
  // ---------------------------------------------------------------------------
14
15
  // Implementations
@@ -43,11 +44,21 @@ async function listContentTypes({ site: siteName }) {
43
44
  }
44
45
 
45
46
  /**
46
- * List all named sites from config.json. No backend call and no credentials.
47
- * @returns {Promise<{sites: string[]}>}
47
+ * List named sites this principal may address. No backend call and no credentials.
48
+ * `sites` stays a name list for compatibility; `targets` is the authoritative
49
+ * resolved-target record.
50
+ * @returns {Promise<{sites: string[], targets: Array<object>}>}
48
51
  */
49
52
  async function listConfiguredSites() {
50
- return { sites: listSiteNames() };
53
+ const names = listSiteNames();
54
+ const resolvable = names.flatMap((name) => {
55
+ try {
56
+ return [getSiteConfig(name)];
57
+ } catch {
58
+ return [];
59
+ }
60
+ });
61
+ return visibleSiteTargets(getRequestIdentity(), resolvable, names);
51
62
  }
52
63
 
53
64
  /**
@@ -71,7 +82,10 @@ export function classifySiteResolutionFailure(message) {
71
82
  }
72
83
 
73
84
  async function getGovernanceStatus({ site: siteName } = {}) {
74
- const names = siteName ? [siteName] : listSiteNames();
85
+ const identity = getRequestIdentity();
86
+ const configured = listSiteNames();
87
+ const allowed = identity ? resolveGrantedSiteNames(identity, configured) : configured;
88
+ const names = siteName ? [siteName] : allowed;
75
89
  const resolved = [];
76
90
  const unresolved = [];
77
91
  for (const name of names) {
@@ -123,7 +137,7 @@ export const definitions = [
123
137
  },
124
138
  {
125
139
  name: "drupal_list_sites",
126
- description: "List all named Drupal sites configured in config.json. Useful for multi-site setups.",
140
+ description: "List the Drupal sites this principal may address. Each target includes the authoritative site name and base URL.",
127
141
  inputSchema: {
128
142
  type: "object",
129
143
  properties: {},