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.
- package/.claude/commands/drupal-bulk-create.md +2 -2
- package/.claude/commands/drupal-create-paragraph.md +2 -2
- package/.claude/commands/drupal-entity-update.md +3 -3
- package/.claude/commands/drupal-get-paragraph.md +2 -2
- package/.claude/commands/drupal-list-revisions.md +2 -2
- package/.claude/commands/drupal-list-sites.md +2 -2
- package/.claude/commands/drupal-update-node.md +4 -4
- package/.claude/commands/drupal-update-paragraph.md +2 -2
- package/CHANGELOG.md +80 -0
- package/README.md +2 -2
- package/config/config.example.json +16 -1
- package/package.json +4 -3
- package/src/index.js +91 -21
- package/src/lib/backends/backend-interface.js +4 -1
- package/src/lib/backends/jsonapi.js +16 -6
- package/src/lib/canonical.js +11 -1
- package/src/lib/config.js +23 -0
- package/src/lib/dispatch.js +36 -10
- package/src/lib/err-relationships.js +286 -0
- package/src/lib/http-auth.js +541 -3
- package/src/lib/http-handler.js +62 -9
- package/src/lib/mcp-server.js +32 -6
- package/src/lib/patch-preflight.js +176 -0
- package/src/lib/principal.js +372 -0
- package/src/lib/verify.js +40 -4
- package/src/lib/write-revision.js +72 -0
- package/src/tools/bulk.js +31 -6
- package/src/tools/config.js +6 -0
- package/src/tools/entities.js +37 -7
- package/src/tools/nodes.js +34 -10
- package/src/tools/paragraphs.js +56 -42
- package/src/tools/revisions.js +42 -7
- package/src/tools/site.js +19 -5
package/src/lib/mcp-server.js
CHANGED
|
@@ -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
|
|
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 () => ({
|
|
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 () => ({
|
|
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
|
|
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
|
+
}
|