drupal-mcp-connector 2.17.0 → 2.18.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,241 @@
1
+ /** Module-owned schemas and behavior, exposed only by explicit local tool policy. */
2
+ import { createHash } from "node:crypto";
3
+ import Ajv from "ajv/dist/2020.js";
4
+ import addFormats from "ajv-formats";
5
+ import { listServerTools, callServerTool, toolResultData } from "./server-tools.js";
6
+ import { listResolvableSiteConfigs, securityMiddleware } from "./dispatch.js";
7
+ import { getRequestIdentity, principalHasScope, resolveGrantedSites } from "./principal.js";
8
+ import { resolveSecurityConfig, SecurityError } from "./security.js";
9
+ import { assertSourceGovernance, GovernanceError } from "./governance.js";
10
+ import { DataFlowBudgetError } from "./data-flow.js";
11
+ import { toolError } from "./errors.js";
12
+ import { withResolvedTarget } from "./site-target.js";
13
+
14
+ const PREFIX = "drupal_module_";
15
+ const OPERATIONS = new Set(["read", "write", "delete"]);
16
+ const CAPABILITIES = new Map([
17
+ ["publish", "allowPublish"], ["configRead", "allowConfigRead"],
18
+ ["configWrite", "allowConfigWrite"], ["graphql", "allowGraphql"],
19
+ ]);
20
+ const MAX_BYTES = 262144;
21
+ const MAX_TOOLS = 256;
22
+
23
+ function bounded(value, limit = MAX_BYTES) {
24
+ if (Buffer.byteLength(JSON.stringify(value) ?? "") > limit) {
25
+ throw new SecurityError("Module tool payload exceeds the protocol ceiling.");
26
+ }
27
+ }
28
+
29
+ function entries(sites) {
30
+ const result = new Map();
31
+ for (const site of sites) {
32
+ const config = site.serverTools?.modules;
33
+ if (!config) continue;
34
+ if (!/^[a-z][a-z0-9_]{0,23}$/.test(config.namespace ?? "")) {
35
+ throw new SecurityError("Module tools require a stable namespace.");
36
+ }
37
+ for (const [alias, policy] of Object.entries(config.tools ?? {})) {
38
+ if (!/^[a-z][a-z0-9_]{0,47}$/.test(alias) || !policy ||
39
+ !/^[A-Za-z0-9_.-]{1,128}$/.test(policy.name ?? "") ||
40
+ !/^[a-z][a-z0-9_:-]{0,63}$/.test(policy.scope ?? "") ||
41
+ !OPERATIONS.has(policy.operation) || !Array.isArray(policy.capabilities) ||
42
+ policy.capabilities.some((cap) => !CAPABILITIES.has(cap) && cap !== "rawSql")) {
43
+ throw new SecurityError("Invalid module tool policy.");
44
+ }
45
+ const name = `${PREFIX}${policy.operation}_${config.namespace}__${alias}`;
46
+ if (result.has(name) || result.size >= MAX_TOOLS) {
47
+ throw new SecurityError("Duplicate module namespace or excessive tool policy entries.");
48
+ }
49
+ result.set(name, { name, alias, site, policy });
50
+ }
51
+ }
52
+ return result;
53
+ }
54
+
55
+ function allowed(entry, identity, sites, grants) {
56
+ const { site, policy } = entry;
57
+ if (identity && (!principalHasScope(identity, policy.scope) ||
58
+ !resolveGrantedSites(identity, sites, grants).some((s) => s._name === site._name))) return false;
59
+ const sec = resolveSecurityConfig(site);
60
+ if (policy.operation !== "read" && sec.readOnly) return false;
61
+ if (policy.operation === "delete" && !sec.allowDestructive) return false;
62
+ return policy.capabilities.every((cap) => cap === "rawSql"
63
+ ? site.drushSsh?.rawSql === "governed"
64
+ : Boolean(new Map(Object.entries(sec)).get(CAPABILITIES.get(cap))));
65
+ }
66
+
67
+ function validator(schema) {
68
+ if (!schema || schema.type !== "object") throw new Error("Object schema required.");
69
+ bounded(schema, 65536);
70
+ // Providers may put constraints beside nullable oneOf types. strictTypes
71
+ // rejects that valid JSON Schema shape; runtime type checks still apply.
72
+ const ajv = new Ajv({ strict: true, strictTypes: false, allErrors: false, ownProperties: true });
73
+ addFormats(ajv);
74
+ const check = ajv.compile(schema);
75
+ if (check.$async) throw new Error("Async schemas are unavailable.");
76
+ return check;
77
+ }
78
+
79
+ async function catalog(site, list) {
80
+ // Extensions always require source governance, including development sites.
81
+ await assertSourceGovernance({ ...site, requireGovernance: true });
82
+ const found = new Map();
83
+ const seen = new Set();
84
+ let cursor;
85
+ let bytes = 0;
86
+ for (let page = 0; page < 16; page++) {
87
+ const result = await list(site, cursor);
88
+ bounded(result);
89
+ bytes += Buffer.byteLength(JSON.stringify(result));
90
+ if (bytes > MAX_BYTES || !Array.isArray(result?.tools)) throw new Error("Invalid module catalog.");
91
+ for (const tool of result.tools) {
92
+ if (typeof tool?.name !== "string" || found.has(tool.name) || found.size >= MAX_TOOLS) {
93
+ throw new Error("Invalid or duplicate module tool.");
94
+ }
95
+ found.set(tool.name, tool);
96
+ }
97
+ if (result.nextCursor === undefined || result.nextCursor === null) return found;
98
+ if (typeof result.nextCursor !== "string" || seen.has(result.nextCursor)) throw new Error("Invalid catalog cursor.");
99
+ cursor = result.nextCursor;
100
+ seen.add(cursor);
101
+ }
102
+ throw new Error("Module catalog page limit exceeded.");
103
+ }
104
+
105
+ function describe(entry, remote) {
106
+ const input = validator(remote.inputSchema);
107
+ const output = remote.outputSchema ? validator(remote.outputSchema) : null;
108
+ const revision = createHash("sha256").update(JSON.stringify({
109
+ input: remote.inputSchema, output: remote.outputSchema ?? null, policy: entry.policy,
110
+ })).digest("hex");
111
+ return { input, output, revision, definition: {
112
+ name: entry.name,
113
+ description: `${String(remote.description ?? remote.name).slice(0, 4096)} [${entry.site._name}]`,
114
+ inputSchema: {
115
+ type: "object", additionalProperties: false, required: ["catalogRevision", "arguments"],
116
+ properties: {
117
+ catalogRevision: { type: "string", const: revision },
118
+ arguments: { ...remote.inputSchema, $id: remote.inputSchema.$id ?? `urn:module:input:${revision}` },
119
+ },
120
+ },
121
+ annotations: {
122
+ readOnlyHint: entry.policy.operation === "read",
123
+ destructiveHint: entry.policy.operation !== "read",
124
+ idempotentHint: false, openWorldHint: true,
125
+ },
126
+ ...(remote.outputSchema ? { outputSchema: {
127
+ type: "object", required: ["result", "_target"],
128
+ properties: {
129
+ result: { ...remote.outputSchema, $id: remote.outputSchema.$id ?? `urn:module:output:${revision}` },
130
+ _target: { type: "object" },
131
+ },
132
+ } } : {}),
133
+ } };
134
+ }
135
+
136
+ /** Reserved module names never fall back to built-in handlers. */
137
+ export function isModuleTool(name) {
138
+ return typeof name === "string" && name.startsWith(PREFIX);
139
+ }
140
+
141
+ /** Resolves a local binding without granting access or contacting its provider. */
142
+ export function resolveModuleBinding(site, binding, required) {
143
+ const bindings = new Map(Object.entries(site.serverTools?.bindings ?? {}));
144
+ const alias = bindings.get(binding);
145
+ const entry = [...entries([site]).values()].find((item) => item.alias === alias);
146
+ if (!entry || entry.policy.operation !== required.operation ||
147
+ entry.policy.scope !== required.scope ||
148
+ !required.capabilities.every((cap) => entry.policy.capabilities.includes(cap))) {
149
+ throw new SecurityError("Module binding is missing or does not satisfy the operation contract.");
150
+ }
151
+ return entry;
152
+ }
153
+
154
+ /**
155
+ * Build a registry without cross-request catalog or authorization caches.
156
+ * Transport injection lets unrelated fixture providers prove generic dispatch.
157
+ */
158
+ export function createModuleToolRegistry({ list = listServerTools, call = callServerTool } = {}) {
159
+ const registry = {
160
+ /** Invoke a locally bound compatibility operation through normal discovery. */
161
+ async callBinding(site, binding, args, required, context = {}) {
162
+ const entry = resolveModuleBinding(site, binding, required);
163
+ const ctx = { ...context, sites: [site] };
164
+ const definition = (await registry.list(ctx)).find((item) => item.name === entry.name);
165
+ if (!definition) throw new SecurityError("Bound module tool is unavailable for this caller.");
166
+ const result = await registry.call(entry.name, {
167
+ arguments: args,
168
+ catalogRevision: definition.inputSchema.properties.catalogRevision.const,
169
+ }, ctx);
170
+ if (result.isError) throw new SecurityError("Bound module tool refused the request; no fallback was attempted.");
171
+ // Compatibility callers consume the original Tool API result, not the
172
+ // module registry's result/target envelope. Keep the established shape.
173
+ const data = result.structuredContent.result;
174
+ return { content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data };
175
+ },
176
+ async list(context = {}) {
177
+ const sites = context.sites ?? listResolvableSiteConfigs();
178
+ const identity = context.identity === undefined ? getRequestIdentity() : context.identity;
179
+ const enabled = [...entries(sites).values()].filter((entry) => allowed(entry, identity, sites, context.grants));
180
+ const catalogs = new Map();
181
+ const definitions = [];
182
+ for (const entry of enabled) {
183
+ if (!catalogs.has(entry.site._name)) {
184
+ try {
185
+ const found = await securityMiddleware(entry.name, { site: entry.site._name },
186
+ () => catalog(entry.site, list), { ...context, sites, identity, moduleTool: entry.policy });
187
+ catalogs.set(entry.site._name, found);
188
+ }
189
+ catch {
190
+ // A failed catalog fetch invalidates this provider for the request.
191
+ catalogs.set(entry.site._name, new Map());
192
+ }
193
+ }
194
+ try {
195
+ const remote = catalogs.get(entry.site._name).get(entry.policy.name);
196
+ if (remote) definitions.push(describe(entry, remote).definition);
197
+ } catch {
198
+ // A malformed schema disables only that action, not its siblings.
199
+ }
200
+ }
201
+ return definitions.sort((a, b) => a.name.localeCompare(b.name));
202
+ },
203
+ async call(name, args, context = {}) {
204
+ try {
205
+ const sites = context.sites ?? listResolvableSiteConfigs();
206
+ const identity = context.identity === undefined ? getRequestIdentity() : context.identity;
207
+ const entry = entries(sites).get(name);
208
+ if (!entry || !allowed(entry, identity, sites, context.grants)) throw new SecurityError("Module tool is not enabled for this caller.");
209
+ bounded(args);
210
+ if (!args || Object.keys(args).some((key) => !["arguments", "catalogRevision"].includes(key))) {
211
+ throw new SecurityError("Invalid module tool arguments.");
212
+ }
213
+ const invokeContext = { ...context, sites, identity, moduleTool: entry.policy };
214
+ return await securityMiddleware(name, { site: entry.site._name }, async () => {
215
+ const remote = (await catalog(entry.site, list)).get(entry.policy.name);
216
+ if (!remote) throw new SecurityError("Module tool is no longer available.");
217
+ const spec = describe(entry, remote);
218
+ if (args.catalogRevision !== spec.revision || !spec.input(args.arguments)) {
219
+ throw new SecurityError("Module tool schema changed or arguments are invalid. Refresh tools/list.");
220
+ }
221
+ const result = await call(entry.site, entry.policy.name, args.arguments, {
222
+ maxBytes: MAX_BYTES, preserveErrors: true, retryRejected: entry.policy.operation === "read",
223
+ });
224
+ bounded(result);
225
+ if (!Array.isArray(result?.content) || result.content.some((item) => item.type !== "text")) {
226
+ throw new Error("Unsupported module result content.");
227
+ }
228
+ const data = toolResultData(result);
229
+ const failed = result.isError === true || data?.success === false;
230
+ if (!failed && spec.output && !spec.output(data)) throw new Error("Invalid module output schema.");
231
+ const structuredContent = withResolvedTarget({ result: data }, invokeContext.resolvedTarget);
232
+ return { content: [{ type: "text", text: JSON.stringify(structuredContent) }], structuredContent, isError: failed };
233
+ }, invokeContext);
234
+ } catch (error) {
235
+ const safe = error instanceof SecurityError || error instanceof GovernanceError || error instanceof DataFlowBudgetError;
236
+ return toolError(safe ? error : new Error("Module tool unavailable or returned an invalid response. No fallback was attempted."));
237
+ }
238
+ },
239
+ };
240
+ return registry;
241
+ }
@@ -1,48 +1,5 @@
1
1
  /** Sentinel discovery for translation-only node revisions (#297). */
2
- import { readTranslationInventory } from "./draft-write.js";
3
-
4
- /**
5
- * Read optional inventory, falling back only when the endpoint is unsupported.
6
- * Permission, transport and malformed-response failures are not absence.
7
- * @param {object} backend
8
- * @param {{entityType: string, bundle: string, id: string}} ref
9
- * @returns {Promise<object|null>}
10
- */
11
- export async function readNodeDraftInventory(backend, ref) {
12
- if ((ref.entityType !== "node" && ref.entityType !== "media")
13
- || typeof backend.rawQuery !== "function"
14
- || typeof backend.resourcePath !== "function") return null;
15
- let inventory;
16
- try {
17
- inventory = await readTranslationInventory(backend, ref);
18
- } catch (error) {
19
- if (/does not provide Sentinel's governed draft-translation endpoint/.test(error.message)) return null;
20
- throw error;
21
- }
22
- const validVid = (vid) => /^[1-9]\d*$/.test(String(vid ?? "")) && Number.isSafeInteger(Number(vid));
23
- if (!validVid(inventory.live?.vid)
24
- || (inventory.working && (!validVid(inventory.working.vid)
25
- || !Array.isArray(inventory.working.translations)
26
- || inventory.working.translations.some((row) => !row || typeof row.langcode !== "string"
27
- || typeof row.status !== "boolean")))) {
28
- throw new Error("Sentinel returned an invalid revision inventory. Re-read before updating.");
29
- }
30
- return inventory;
31
- }
32
-
33
- /**
34
- * Ensure inventory discovery does not turn a published language into a draft.
35
- * Sentinel still validates the revision pair and language on every request.
36
- * @param {object} inventory
37
- * @param {string|undefined} langcode
38
- */
39
- export function assertInventoryDraftLanguage(inventory, langcode) {
40
- const rows = inventory.working?.translations ?? [];
41
- if (!langcode && rows.length !== 1) {
42
- throw new Error("This working revision contains translations. Pass an explicit langcode for an existing unpublished language; no draft was created.");
43
- }
44
- const row = langcode ? rows.find((item) => item.langcode === langcode) : rows[0];
45
- if (!row || row.status !== false) {
46
- throw new Error("The requested language is not an existing unpublished working draft. Published languages and other drafts were left unchanged.");
47
- }
48
- }
2
+ export {
3
+ assertInventoryDraftLanguage,
4
+ readNodeDraftInventory,
5
+ } from "./sentinel-draft.js";
@@ -32,6 +32,11 @@ export const DESTRUCTIVE_PREFIXES = ["drupal_delete_", "drupal_drush_module_disa
32
32
  * prefixes are checked first so they take precedence over plain write.
33
33
  */
34
34
  export function inferOperation(toolName) {
35
+ // Only the local module registry generates these names from approved policy.
36
+ // A forged name cannot resolve a handler; unknown reserved names are conservative.
37
+ if (toolName.startsWith("drupal_module_")) {
38
+ return /^drupal_module_(read|write|delete)_/.exec(toolName)?.[1] ?? "delete";
39
+ }
35
40
  if (DESTRUCTIVE_PREFIXES.some((p) => toolName.startsWith(p))) return "delete";
36
41
  if (WRITE_PREFIXES.some((p) => toolName.startsWith(p))) return "write";
37
42
  if (toolName === "drupal_graphql") return "graphql";
@@ -24,8 +24,7 @@
24
24
 
25
25
  import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
26
26
  import { entityRevisionId } from "./write-revision.js";
27
- import { writeDraft } from "./draft-write.js";
28
- import { readNodeDraftInventory, assertInventoryDraftLanguage } from "./node-draft-inventory.js";
27
+ import { writeDraft, readNodeDraftInventory, assertInventoryDraftLanguage } from "./sentinel-draft.js";
29
28
 
30
29
  /** Stable error code for a core working-copy / not-latest-revision block. */
31
30
  export const PATCH_BLOCKED_CODE = "PATCH_BLOCKED";
@@ -61,16 +60,6 @@ export const PATCH_WORKING_COPY_STALE_MESSAGE =
61
60
  "The connector will not retry the canonical URL or discard the draft. " +
62
61
  "Re-read rel:working-copy and retry, or resolve the conflict in Drupal. See connector #166.";
63
62
 
64
- /**
65
- * Operator message for a core working-copy 400.
66
- * A resolvable working copy gets a new draft revision (#166) — this message is only
67
- * for the invisible-row case (#201).
68
- * @returns {string}
69
- */
70
- export function patchBlockedMessage() {
71
- return PATCH_BLOCKED_MESSAGE;
72
- }
73
-
74
63
  /**
75
64
  * Thrown when the core working-copy PATCH guard rejects a canonical write
76
65
  * (or its probe) and no working copy is addressable (#201).
@@ -388,18 +377,21 @@ export async function preflightPatchWritable({
388
377
  /**
389
378
  * Resolve the PATCH target, then run the same probe the real write will use.
390
379
  * Callers inherit #166 targeting by going through this before dryRun or write.
380
+ * `langcode` always resolves Sentinel inventory and draft-preflights, even
381
+ * when the entity is unmoderated (media translations).
391
382
  *
392
383
  * @param {object} backend
393
- * @param {{entityType: string, bundle: string, id: string, existing?: ?object, attributes?: object}} args
384
+ * @param {{entityType: string, bundle: string, id: string, existing?: ?object, attributes?: object, relationships?: object, langcode?: string}} args
394
385
  * @returns {Promise<{resourceVersion: ?string, workingCopy: ?object, liveVid: ?number|string, workingVid: ?number|string}>}
395
386
  */
396
387
  export async function prepareGuardedPatch(backend, {
397
388
  entityType, bundle, id, existing, attributes, relationships, langcode,
398
389
  }) {
399
- const target = shouldPreflightPatch({ existing, attributes })
390
+ const needsPreflight = shouldPreflightPatch({ existing, attributes });
391
+ const target = (needsPreflight || langcode)
400
392
  ? await resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing })
401
393
  : { resourceVersion: undefined, workingCopy: null, liveVid: null, workingVid: null };
402
- if (shouldPreflightPatch({ existing, attributes }) && !target.resourceVersion) {
394
+ if (needsPreflight && !target.resourceVersion) {
403
395
  // Canonical path (no distinct working copy). The id-mismatch probe never
404
396
  // reaches Sentinel's save-time stale-default check; refuse here when the
405
397
  // possiblyPatchBlocked fingerprint is already readable (#273).