drupal-mcp-connector 2.6.1 → 2.7.1

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,176 @@
1
+ /**
2
+ * JSON:API PATCH preflight for Drupal core's working-copy guard (#201).
3
+ *
4
+ * `EntityResource::patchIndividual()` rejects a canonical PATCH when the
5
+ * stored entity is not both the latest and the default revision. That check
6
+ * runs against the revision table *before* the payload is deserialized.
7
+ * An empty-body PATCH that returns 2xx still calls `$entity->save()` (and
8
+ * often `setNewRevision`) — so the probe must fail *after* the guard and
9
+ * *before* save. Core next compares `data.id` to the URL entity UUID; a
10
+ * well-formed but non-matching id yields 400 "does not match the ID in the
11
+ * payload" with no row written. Content-moderation's `rel:latest-version` /
12
+ * `rel:working-copy` aliases can disagree with storage (a revision row with
13
+ * no `content_moderation_state`), which is how every read tool reports
14
+ * clean and the write then 400s.
15
+ *
16
+ * `workingCopy: null` and "latest-version vid === default vid" are not proof
17
+ * the node is writable. That is the #201 lie.
18
+ *
19
+ * Distinct from #166: there a working copy is visible and the fix is PATCH
20
+ * `?resourceVersion=rel:working-copy`. This module does not implement that.
21
+ */
22
+
23
+ import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
24
+
25
+ /** Stable error code for a core working-copy / not-latest-revision block. */
26
+ export const PATCH_BLOCKED_CODE = "PATCH_BLOCKED";
27
+
28
+ const WORKING_COPY_PATCH_RE = /has a working copy is not yet supported/i;
29
+
30
+ /**
31
+ * Actionable replacement for core's "has a working copy" 400.
32
+ * Clearing the blocking row is revision surgery outside JSON:API.
33
+ */
34
+ export const PATCH_BLOCKED_MESSAGE =
35
+ "This entity cannot be updated over JSON:API because the stored entity is not " +
36
+ "the latest revision (Drupal core #2795279). The JSON:API aliases " +
37
+ "rel:latest-version and rel:working-copy cannot show the blocking row. " +
38
+ "Clearing it requires revision surgery outside JSON:API (Drush / the entity API). " +
39
+ "See connector #201. Do not retry the same canonical PATCH.";
40
+
41
+ /**
42
+ * Thrown when the core working-copy PATCH guard rejects a write (or its probe).
43
+ */
44
+ export class PatchBlockedError extends Error {
45
+ /** @param {?Error} [cause] The original Drupal 400. */
46
+ constructor(cause) {
47
+ super(PATCH_BLOCKED_MESSAGE);
48
+ this.name = "PatchBlockedError";
49
+ this.code = PATCH_BLOCKED_CODE;
50
+ if (cause) this.cause = cause;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Whether an error is Drupal core's working-copy PATCH guard (core #2795279).
56
+ * @param {unknown} err
57
+ * @returns {boolean}
58
+ */
59
+ export function isWorkingCopyPatchError(err) {
60
+ return WORKING_COPY_PATCH_RE.test(String(err?.message || ""));
61
+ }
62
+
63
+ /**
64
+ * Rewrite a core working-copy 400 into {@link PatchBlockedError}; otherwise
65
+ * return the original value.
66
+ * @param {unknown} err
67
+ * @returns {unknown}
68
+ */
69
+ export function rewriteWorkingCopyPatchError(err) {
70
+ if (!isWorkingCopyPatchError(err)) return err;
71
+ return new PatchBlockedError(err instanceof Error ? err : new Error(String(err)));
72
+ }
73
+
74
+ /**
75
+ * Whether this update should run the PATCH probe.
76
+ * Skip unmoderated / non-revisionable bundles — the guard is about
77
+ * revisionable entities under content_moderation.
78
+ * @param {{existing?: ?object, attributes?: object}} input
79
+ * @returns {boolean}
80
+ */
81
+ export function shouldPreflightPatch({ existing, attributes } = {}) {
82
+ if (hasExplicitModerationState(attributes)) return true;
83
+ return entityLooksModerated(existing);
84
+ }
85
+
86
+ /**
87
+ * UUID used as `data.id` on the probe PATCH so it cannot match the URL
88
+ * entity. Core throws after the working-copy guard and before save.
89
+ * @see EntityResource::patchIndividual()
90
+ */
91
+ export const PATCH_PROBE_MISMATCH_ID = "00000000-0000-4000-a000-000000000001";
92
+
93
+ const ID_MISMATCH_RE = /does not match the ID in the payload/i;
94
+
95
+ /**
96
+ * Whether a probe error means the working-copy guard passed and no row
97
+ * was written (id mismatch, or a 422 during deserialize).
98
+ * @param {unknown} err
99
+ * @returns {boolean}
100
+ */
101
+ export function isProbePassedWithoutSave(err) {
102
+ const msg = String(err?.message || "");
103
+ return ID_MISMATCH_RE.test(msg) || /Drupal 422\b/.test(msg);
104
+ }
105
+
106
+ /**
107
+ * Probe the same guard core uses on the canonical PATCH URL.
108
+ *
109
+ * Sends a PATCH whose `data.id` does not match the URL entity. Core runs
110
+ * the working-copy check first; a match on that phrase means no row was
111
+ * written. An id-mismatch 400 (or deserialize 422) means the guard passed
112
+ * and save was not reached. A 2xx would have saved a revision and is
113
+ * treated as a probe failure. Do not treat "latest-version vid === default
114
+ * vid" as writable.
115
+ *
116
+ * @param {object} args
117
+ * @param {object} args.backend Backend with `rawQuery` + `resourcePath`.
118
+ * @param {string} args.entityType
119
+ * @param {string} args.bundle
120
+ * @param {string} args.id
121
+ * @param {?object} [args.existing]
122
+ * @param {object} [args.attributes]
123
+ * @returns {Promise<{probed: boolean, writable?: boolean|string, skipped?: string}>}
124
+ * @throws {PatchBlockedError} When the guard rejects the probe.
125
+ */
126
+ export async function preflightPatchWritable({
127
+ backend, entityType, bundle, id, existing, attributes,
128
+ }) {
129
+ if (!shouldPreflightPatch({ existing, attributes })) {
130
+ return { probed: false };
131
+ }
132
+ if (typeof backend?.rawQuery !== "function" || typeof backend?.resourcePath !== "function") {
133
+ return { probed: false, skipped: "backend cannot issue a raw PATCH probe" };
134
+ }
135
+ const path = `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
136
+ const type = `${entityType}--${bundle}`;
137
+ const probeId = id === PATCH_PROBE_MISMATCH_ID
138
+ ? "00000000-0000-4000-a000-000000000002"
139
+ : PATCH_PROBE_MISMATCH_ID;
140
+ try {
141
+ await backend.rawQuery({
142
+ path,
143
+ options: {
144
+ method: "PATCH",
145
+ body: JSON.stringify({ data: { type, id: probeId } }),
146
+ },
147
+ });
148
+ throw new Error(
149
+ "PATCH probe unexpectedly succeeded (2xx). The probe must fail after " +
150
+ "core's working-copy guard so no revision is written."
151
+ );
152
+ } catch (err) {
153
+ if (isWorkingCopyPatchError(err)) {
154
+ throw new PatchBlockedError(err instanceof Error ? err : new Error(String(err)));
155
+ }
156
+ if (isProbePassedWithoutSave(err)) {
157
+ return { probed: true, writable: true };
158
+ }
159
+ throw err;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * `backend.updateEntity` with the core working-copy 400 rewritten.
165
+ * @param {object} backend
166
+ * @param {object} input updateEntity argument.
167
+ * @returns {Promise<*>}
168
+ * @throws {PatchBlockedError|*}
169
+ */
170
+ export async function updateEntityGuarded(backend, input) {
171
+ try {
172
+ return await backend.updateEntity(input);
173
+ } catch (err) {
174
+ throw rewriteWorkingCopyPatchError(err);
175
+ }
176
+ }
@@ -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
+ }