drupal-mcp-connector 2.15.2 → 2.16.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.
- package/.agents/commands/drupal-codegen-diff.md +17 -0
- package/.agents/commands/drupal-codegen-generate.md +17 -0
- package/.agents/commands/drupal-codegen-inspect.md +17 -0
- package/.agents/commands/drupal-content-by-moderation-state.md +4 -3
- package/.agents/commands/drupal-describe-fields.md +2 -2
- package/.agents/commands/drupal-get-node.md +4 -3
- package/.agents/commands/drupal-get-taxonomy-term.md +4 -3
- package/.agents/commands/drupal-list-translations.md +2 -2
- package/.agents/commands/drupal-report-translation-coverage.md +4 -5
- package/.agents/commands/drupal-report-workflow-bottlenecks.md +2 -1
- package/.agents/commands/drupal-set-moderation-state.md +4 -3
- package/.agents/commands/drupal-update-menu-link.md +2 -1
- package/.agents/commands/drupal-update-taxonomy-term.md +4 -3
- package/CHANGELOG.md +38 -0
- package/README.md +3 -3
- package/bin/drupal-mcp-agent.js +2 -2
- package/package.json +2 -2
- package/scripts/generate-commands.js +2 -2
- package/src/index.js +2 -2
- package/src/lib/backends/jsonapi.js +85 -9
- package/src/lib/config.js +18 -1
- package/src/lib/err-relationships.js +22 -0
- package/src/lib/mcp-server.js +1 -1
- package/src/lib/server-tools.js +2 -2
- package/src/lib/translation-rows.js +59 -0
- package/src/tools/codegen.js +150 -0
- package/src/tools/fields.js +12 -4
- package/src/tools/index.js +2 -1
- package/src/tools/moderation.js +82 -13
- package/src/tools/nodes.js +56 -6
- package/src/tools/reports-content.js +117 -28
- package/src/tools/structure.js +6 -3
- package/src/tools/taxonomy.js +15 -6
- package/src/tools/translations.js +4 -8
package/src/lib/server-tools.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import fetch from "node-fetch";
|
|
26
|
-
import { authHeadersAsync, clientHeaders, CLIENT_VERSION } from "./config.js";
|
|
26
|
+
import { authHeadersAsync, clientHeaders, CLIENT_NAME, CLIENT_VERSION } from "./config.js";
|
|
27
27
|
import { consumeBudgetIfEnforced, northboundHeaders, sourceBudgetDenial } from "./data-flow.js";
|
|
28
28
|
import { clearToken } from "./oauth.js";
|
|
29
29
|
|
|
@@ -164,7 +164,7 @@ async function initializeSession(site, endpoint) {
|
|
|
164
164
|
params: {
|
|
165
165
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
166
166
|
capabilities: {},
|
|
167
|
-
clientInfo: { name:
|
|
167
|
+
clientInfo: { name: CLIENT_NAME, version: CLIENT_VERSION },
|
|
168
168
|
},
|
|
169
169
|
};
|
|
170
170
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize Sentinel translation-inventory rows for tools and reports.
|
|
3
|
+
*
|
|
4
|
+
* Extra keys (`outdated`, `source`) are passed through only when present so
|
|
5
|
+
* older Sentinel versions do not grow invented `outdated: false`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} row Inventory translation row.
|
|
10
|
+
* @returns {object}
|
|
11
|
+
*/
|
|
12
|
+
export function mapTranslationRow(row) {
|
|
13
|
+
if (!row || typeof row !== "object") return row;
|
|
14
|
+
const out = {
|
|
15
|
+
langcode: row.langcode,
|
|
16
|
+
default: Boolean(row.default),
|
|
17
|
+
status: row.status,
|
|
18
|
+
title: row.title,
|
|
19
|
+
moderation_state: row.moderation_state ?? null,
|
|
20
|
+
};
|
|
21
|
+
if (Object.prototype.hasOwnProperty.call(row, "outdated")) {
|
|
22
|
+
out.outdated = Boolean(row.outdated);
|
|
23
|
+
}
|
|
24
|
+
if (typeof row.source === "string" && row.source) {
|
|
25
|
+
out.source = row.source;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Editorial current row per langcode: working copy wins over live.
|
|
32
|
+
* @param {?object} inventory
|
|
33
|
+
* @returns {object[]}
|
|
34
|
+
*/
|
|
35
|
+
export function inventoryTranslationRows(inventory) {
|
|
36
|
+
const byLang = new Map();
|
|
37
|
+
for (const row of inventory?.live?.translations ?? []) {
|
|
38
|
+
if (row?.langcode) byLang.set(row.langcode, { ...mapTranslationRow(row), revision: "live" });
|
|
39
|
+
}
|
|
40
|
+
for (const row of inventory?.working?.translations ?? []) {
|
|
41
|
+
if (row?.langcode) byLang.set(row.langcode, { ...mapTranslationRow(row), revision: "working" });
|
|
42
|
+
}
|
|
43
|
+
return [...byLang.values()];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether inventory has a row matching langcode and/or moderation state.
|
|
48
|
+
* @param {?object} inventory
|
|
49
|
+
* @param {{langcode?: string, state?: string}} match
|
|
50
|
+
* @returns {?object}
|
|
51
|
+
*/
|
|
52
|
+
export function inventoryRowMatching(inventory, { langcode, state } = {}) {
|
|
53
|
+
const wantedState = state === undefined || state === null ? null : String(state).toLowerCase();
|
|
54
|
+
return inventoryTranslationRows(inventory).find((row) => {
|
|
55
|
+
if (langcode && row.langcode !== langcode) return false;
|
|
56
|
+
if (wantedState && String(row.moderation_state || "").toLowerCase() !== wantedState) return false;
|
|
57
|
+
return true;
|
|
58
|
+
}) ?? null;
|
|
59
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool group: GraphQL Compose Codegen (TypeScript / Next.js scaffolds).
|
|
3
|
+
*
|
|
4
|
+
* Thin Drush wrappers around `drupal/graphql_compose_codegen`. The generator
|
|
5
|
+
* lives on the Drupal site; this connector does not reimplement it and does
|
|
6
|
+
* not run GraphQL Code Generator against the SDL.
|
|
7
|
+
*
|
|
8
|
+
* All three tools are reads. `drupal_codegen_generate` always passes
|
|
9
|
+
* `--dry-run` and never `--output-dir`, so artefacts come back as text for
|
|
10
|
+
* the agent to write locally. Disk writes stay a local DDEV/script concern.
|
|
11
|
+
*
|
|
12
|
+
* Capability: missing module or unknown Drush command fails loud. If
|
|
13
|
+
* `drushSsh.allowedCommands` is set, the `graphql-compose-codegen:*`
|
|
14
|
+
* subcommand must be on that list (same exact-match rule as other Drush tools).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getSiteConfig } from "../lib/config.js";
|
|
18
|
+
import { sshDrush } from "./drush.js";
|
|
19
|
+
import { SecurityError } from "../lib/security.js";
|
|
20
|
+
import { validateMachineName } from "../lib/validate.js";
|
|
21
|
+
|
|
22
|
+
const INSPECT = "graphql-compose-codegen:inspect";
|
|
23
|
+
const DIFF = "graphql-compose-codegen:diff";
|
|
24
|
+
const GENERATE = "graphql-compose-codegen:generate";
|
|
25
|
+
|
|
26
|
+
const GENERATE_TIMEOUT_MS = 60000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse an optional list of Drupal machine names (array or comma string).
|
|
30
|
+
*
|
|
31
|
+
* @param {string[]|string|undefined} raw Caller value.
|
|
32
|
+
* @param {string} fieldName Error label.
|
|
33
|
+
* @returns {string[]} Validated names, possibly empty.
|
|
34
|
+
*/
|
|
35
|
+
function parseMachineNameList(raw, fieldName) {
|
|
36
|
+
if (raw === undefined || raw === null || raw === "") return [];
|
|
37
|
+
const parts = Array.isArray(raw)
|
|
38
|
+
? raw
|
|
39
|
+
: String(raw).split(",");
|
|
40
|
+
return parts
|
|
41
|
+
.map((v) => String(v).trim())
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.map((v) => validateMachineName(v, fieldName));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether a Drush failure looks like the codegen module is absent.
|
|
48
|
+
*
|
|
49
|
+
* @param {Error} err Bridge error.
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
function looksLikeMissingCodegen(err) {
|
|
53
|
+
const msg = String(err?.message || err || "");
|
|
54
|
+
return /graphql-compose-codegen|gqcc:|no commands defined in the ["']graphql-compose-codegen|command .* does not exist|could not find command/i.test(msg);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run a codegen Drush subcommand and return stdout.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} args Tool args.
|
|
61
|
+
* @param {string} [args.site] Site name.
|
|
62
|
+
* @param {string[]|string} [args.bundles] Bundle ids.
|
|
63
|
+
* @param {string[]|string} [args.skipFields] Extra field names to exclude.
|
|
64
|
+
* @param {string} subcommand Drush subcommand (first arg).
|
|
65
|
+
* @param {string[]} extra Extra flags (e.g. --dry-run).
|
|
66
|
+
* @param {number} timeoutMs SSH timeout.
|
|
67
|
+
* @returns {Promise<{output: string, command: string, wroteFiles: false}>}
|
|
68
|
+
*/
|
|
69
|
+
async function runGqcc(
|
|
70
|
+
{ site: siteName, bundles, skipFields },
|
|
71
|
+
subcommand,
|
|
72
|
+
extra = [],
|
|
73
|
+
timeoutMs = 30000,
|
|
74
|
+
) {
|
|
75
|
+
const site = getSiteConfig(siteName);
|
|
76
|
+
const names = parseMachineNameList(bundles, "bundles");
|
|
77
|
+
const skip = parseMachineNameList(skipFields, "skipFields");
|
|
78
|
+
const args = [subcommand];
|
|
79
|
+
if (names.length) args.push(`--bundles=${names.join(",")}`);
|
|
80
|
+
if (skip.length) args.push(`--skip-fields=${skip.join(",")}`);
|
|
81
|
+
args.push(...extra);
|
|
82
|
+
try {
|
|
83
|
+
const output = await sshDrush(site, args, timeoutMs);
|
|
84
|
+
return { output, command: args.join(" "), wroteFiles: false };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (err instanceof SecurityError) throw err;
|
|
87
|
+
if (looksLikeMissingCodegen(err)) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
"graphql_compose_codegen is not available on this site " +
|
|
90
|
+
`(Drush command "${subcommand}" failed). Install and enable ` +
|
|
91
|
+
"drupal/graphql_compose_codegen, and if drushSsh.allowedCommands is " +
|
|
92
|
+
`set, add "${subcommand}" to that list. ${err.message}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function inspect(args) {
|
|
100
|
+
return runGqcc(args, INSPECT);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function diff(args) {
|
|
104
|
+
return runGqcc(args, DIFF);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function generate(args) {
|
|
108
|
+
return runGqcc(args, GENERATE, ["--dry-run"], GENERATE_TIMEOUT_MS);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const LIST_PROPS = {
|
|
112
|
+
site: { type: "string" },
|
|
113
|
+
bundles: {
|
|
114
|
+
type: "array",
|
|
115
|
+
items: { type: "string", pattern: "^[a-z][a-z0-9_]*$" },
|
|
116
|
+
description: "Node/paragraph bundle ids. Omit for every bundle graphql_compose exposes.",
|
|
117
|
+
},
|
|
118
|
+
skipFields: {
|
|
119
|
+
type: "array",
|
|
120
|
+
items: { type: "string", pattern: "^[a-z][a-z0-9_]*$" },
|
|
121
|
+
description: "Extra field machine names to exclude from the scaffold.",
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const definitions = [
|
|
126
|
+
{
|
|
127
|
+
name: "drupal_codegen_inspect",
|
|
128
|
+
description:
|
|
129
|
+
"List node and paragraph bundles and extra fields from graphql_compose_codegen (`drush graphql-compose-codegen:inspect`). Requires the module and drushSsh. Missing command fails loud. If allowedCommands is set, include graphql-compose-codegen:inspect. Does not write files.",
|
|
130
|
+
inputSchema: { type: "object", properties: LIST_PROPS },
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "drupal_codegen_diff",
|
|
134
|
+
description:
|
|
135
|
+
"Compare the live graphql_compose schema to the last gqcc:generate snapshot (`drush graphql-compose-codegen:diff`). Requires the module and drushSsh. Missing command fails loud. If allowedCommands is set, include graphql-compose-codegen:diff.",
|
|
136
|
+
inputSchema: { type: "object", properties: LIST_PROPS },
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "drupal_codegen_generate",
|
|
140
|
+
description:
|
|
141
|
+
"Return TypeScript/GraphQL scaffold artefacts from graphql_compose_codegen as text (`drush graphql-compose-codegen:generate --dry-run`). Never writes on the Drupal host (no --output-dir). Copy artefacts locally. Requires the module and drushSsh. Missing command fails loud. If allowedCommands is set, include graphql-compose-codegen:generate.",
|
|
142
|
+
inputSchema: { type: "object", properties: LIST_PROPS },
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
export const handlers = {
|
|
147
|
+
drupal_codegen_inspect: inspect,
|
|
148
|
+
drupal_codegen_diff: diff,
|
|
149
|
+
drupal_codegen_generate: generate,
|
|
150
|
+
};
|
package/src/tools/fields.js
CHANGED
|
@@ -96,11 +96,18 @@ async function describeFields({ site: siteName, type, entityType: entityTypeArg,
|
|
|
96
96
|
|
|
97
97
|
const backend = await resolveBackend(site);
|
|
98
98
|
const schema = await backend.getEntitySchema(entityType, resolvedBundle);
|
|
99
|
+
const translatable = typeof backend.listFieldTranslatability === "function"
|
|
100
|
+
? await backend.listFieldTranslatability(entityType, resolvedBundle).catch(() => ({}))
|
|
101
|
+
: {};
|
|
102
|
+
const translatableMap = translatable && typeof translatable === "object" ? new Map(Object.entries(translatable)) : new Map();
|
|
99
103
|
|
|
100
104
|
const fields = [
|
|
101
105
|
...Object.entries(schema.attributes ?? {}).map(([name, t]) => attributeField(name, t)),
|
|
102
106
|
...Object.keys(schema.relationships ?? {}).map((name) => relationshipField(name)),
|
|
103
|
-
].sort((a, b) => a.name.localeCompare(b.name))
|
|
107
|
+
].sort((a, b) => a.name.localeCompare(b.name)).map((field) => {
|
|
108
|
+
if (!translatableMap.has(field.name)) return field;
|
|
109
|
+
return { ...field, translatable: Boolean(translatableMap.get(field.name)) };
|
|
110
|
+
});
|
|
104
111
|
|
|
105
112
|
const sampledEmpty = fields.length === 0;
|
|
106
113
|
|
|
@@ -125,11 +132,12 @@ export const definitions = [
|
|
|
125
132
|
name: "drupal_describe_fields",
|
|
126
133
|
description:
|
|
127
134
|
"Introspect the fields of a Drupal entity type + bundle: returns a per-field " +
|
|
128
|
-
"list of { name, type, kind, cardinality?, approximate }. Read-only. Built on " +
|
|
135
|
+
"list of { name, type, kind, cardinality?, translatable?, approximate }. Read-only. Built on " +
|
|
129
136
|
"schema SAMPLING (an existing entity), so results are approximate — only " +
|
|
130
137
|
"populated fields are visible and required/cardinality/allowedValues are " +
|
|
131
|
-
"inferred from value shape.
|
|
132
|
-
"
|
|
138
|
+
"inferred from value shape. When JSON:API field_config is readable, translatable " +
|
|
139
|
+
"is copied from Field API; omitted means unknown, not false. Authoritative field " +
|
|
140
|
+
"metadata comes from the Drush bridge (Field API). Use this before creating/updating entities to learn field names.",
|
|
133
141
|
inputSchema: {
|
|
134
142
|
type: "object",
|
|
135
143
|
required: ["site"],
|
package/src/tools/index.js
CHANGED
|
@@ -38,11 +38,12 @@ import * as reportsConfig from "./reports-config.js";
|
|
|
38
38
|
import * as reportsContent from "./reports-content.js";
|
|
39
39
|
import * as auditComposite from "./audit-composite.js";
|
|
40
40
|
import * as config from "./config.js";
|
|
41
|
+
import * as codegen from "./codegen.js";
|
|
41
42
|
import { SITE_PARAM } from "../lib/site-target.js";
|
|
42
43
|
|
|
43
44
|
export const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
|
|
44
45
|
revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra,
|
|
45
|
-
reportsLinks, reportsConfig, reportsContent, auditComposite, config];
|
|
46
|
+
reportsLinks, reportsConfig, reportsContent, auditComposite, config, codegen];
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
49
|
* Stamp the shared `site` description onto every tool that accepts one so
|
package/src/tools/moderation.js
CHANGED
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
redactCanonicalEntity,
|
|
22
22
|
} from "../lib/security.js";
|
|
23
23
|
import { collectEntities } from "../lib/reports-support.js";
|
|
24
|
+
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
25
|
+
import { assertDraftLangcode, readTranslationInventory } from "../lib/draft-write.js";
|
|
26
|
+
import { inventoryRowMatching } from "../lib/translation-rows.js";
|
|
24
27
|
|
|
25
28
|
/** Cap for the client-side scan when JSON:API cannot filter the field. */
|
|
26
29
|
const SAMPLE_CAP = 500;
|
|
@@ -54,7 +57,7 @@ function moderationStateOf(entity) {
|
|
|
54
57
|
* @returns {Promise<object>} The updated, redacted node.
|
|
55
58
|
* @throws {SecurityError} If writing node/type is not permitted.
|
|
56
59
|
*/
|
|
57
|
-
async function setModerationState({ site: siteName, type, id, state }) {
|
|
60
|
+
async function setModerationState({ site: siteName, type, id, state, langcode }) {
|
|
58
61
|
if (!state) throw new Error("A moderation 'state' is required (e.g. 'draft', 'published').");
|
|
59
62
|
const site = getSiteConfig(siteName);
|
|
60
63
|
const sec = resolveSecurityConfig(site);
|
|
@@ -62,6 +65,23 @@ async function setModerationState({ site: siteName, type, id, state }) {
|
|
|
62
65
|
const attributes = { moderation_state: state };
|
|
63
66
|
assertPublishAllowed(sec, attributes);
|
|
64
67
|
const backend = await resolveBackend(site);
|
|
68
|
+
if (langcode) {
|
|
69
|
+
const targetLang = assertDraftLangcode(langcode);
|
|
70
|
+
let existing = null;
|
|
71
|
+
try {
|
|
72
|
+
existing = (await backend.getEntity({ entityType: "node", bundle: type, id })) ?? null;
|
|
73
|
+
} catch {
|
|
74
|
+
existing = null;
|
|
75
|
+
}
|
|
76
|
+
const patchTarget = await prepareGuardedPatch(backend, {
|
|
77
|
+
entityType: "node", bundle: type, id, existing, attributes, langcode: targetLang,
|
|
78
|
+
});
|
|
79
|
+
const patched = await updateEntityGuarded(backend, {
|
|
80
|
+
entityType: "node", bundle: type, id, attributes, langcode: targetLang,
|
|
81
|
+
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
82
|
+
});
|
|
83
|
+
return redactCanonicalEntity(patched, sec, "node");
|
|
84
|
+
}
|
|
65
85
|
const entity = await backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
|
|
66
86
|
return redactCanonicalEntity(entity, sec, "node");
|
|
67
87
|
}
|
|
@@ -76,12 +96,59 @@ async function setModerationState({ site: siteName, type, id, state }) {
|
|
|
76
96
|
*
|
|
77
97
|
* @param {object} args - { site?, type, state, limit?, offset? }.
|
|
78
98
|
*/
|
|
79
|
-
async function contentByModerationState({ site: siteName, type, state, limit = 20, offset = 0 }) {
|
|
99
|
+
async function contentByModerationState({ site: siteName, type, state, limit = 20, offset = 0, langcode }) {
|
|
80
100
|
const site = getSiteConfig(siteName);
|
|
81
101
|
const sec = resolveSecurityConfig(site);
|
|
82
102
|
assertReadAllowed(sec, "node", type);
|
|
83
103
|
const backend = await resolveBackend(site);
|
|
84
104
|
const sort = [{ field: "changed", dir: "desc" }];
|
|
105
|
+
const targetLang = langcode ? assertDraftLangcode(langcode) : null;
|
|
106
|
+
if (targetLang) {
|
|
107
|
+
if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
|
|
108
|
+
return {
|
|
109
|
+
type, state, langcode: targetLang, unavailable: true,
|
|
110
|
+
reason: "A langcode filter requires Sentinel's translation inventory.",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const scanned = await collectEntities(
|
|
114
|
+
backend,
|
|
115
|
+
{ entityType: "node", bundle: type, sort },
|
|
116
|
+
SAMPLE_CAP,
|
|
117
|
+
);
|
|
118
|
+
const matches = [];
|
|
119
|
+
for (const entity of scanned) {
|
|
120
|
+
try {
|
|
121
|
+
const inventory = await readTranslationInventory(backend, {
|
|
122
|
+
entityType: "node", bundle: type, id: entity.id,
|
|
123
|
+
});
|
|
124
|
+
const row = inventoryRowMatching(inventory, { langcode: targetLang, state });
|
|
125
|
+
if (row) {
|
|
126
|
+
matches.push({
|
|
127
|
+
...entity,
|
|
128
|
+
langcode: targetLang,
|
|
129
|
+
fields: { ...entity.fields, moderation_state: row.moderation_state },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (/does not provide Sentinel's governed draft-translation endpoint/.test(String(error?.message))) {
|
|
134
|
+
return {
|
|
135
|
+
type, state, langcode: targetLang, unavailable: true,
|
|
136
|
+
reason: "A langcode filter requires Sentinel's translation inventory.",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const page = matches.slice(offset, offset + limit);
|
|
143
|
+
return {
|
|
144
|
+
type, state, langcode: targetLang, source: "inventory",
|
|
145
|
+
approximate: scanned.length >= SAMPLE_CAP,
|
|
146
|
+
scanned: scanned.length,
|
|
147
|
+
total: matches.length,
|
|
148
|
+
offset, nextOffset: offset + page.length,
|
|
149
|
+
nodes: page.map((e) => redactCanonicalEntity(e, sec, "node")),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
85
152
|
const canFilter = typeof backend.capabilities === "function"
|
|
86
153
|
? Boolean(backend.capabilities()?.filter)
|
|
87
154
|
: true;
|
|
@@ -167,28 +234,30 @@ async function listModerationStates({ site: siteName, type, sample = 50 }) {
|
|
|
167
234
|
export const definitions = [
|
|
168
235
|
{
|
|
169
236
|
name: "drupal_set_moderation_state",
|
|
170
|
-
description: "Transition a content node to a moderation state (content_moderation), e.g. 'draft', 'needs_review', 'published', 'archived'. Governed write.",
|
|
237
|
+
description: "Transition a content node to a moderation state (content_moderation), e.g. 'draft', 'needs_review', 'published', 'archived'. Governed write. Pass langcode to change one translation via Sentinel; omit it for the default-language / shared-state write. If moderation_state is not translatable, a langcode write is refused.",
|
|
171
238
|
inputSchema: {
|
|
172
239
|
type: "object", required: ["type", "id", "state"],
|
|
173
240
|
properties: {
|
|
174
|
-
site:
|
|
175
|
-
type:
|
|
176
|
-
id:
|
|
177
|
-
state:
|
|
241
|
+
site: { type: "string" },
|
|
242
|
+
type: { type: "string", description: "Content type machine name" },
|
|
243
|
+
id: { type: "string", description: "Node UUID" },
|
|
244
|
+
state: { type: "string", description: "Target moderation state machine name" },
|
|
245
|
+
langcode: { type: "string", description: "Target translation (e.g. 'es'). Omit for the default language. Requires Sentinel." },
|
|
178
246
|
},
|
|
179
247
|
},
|
|
180
248
|
},
|
|
181
249
|
{
|
|
182
250
|
name: "drupal_content_by_moderation_state",
|
|
183
|
-
description: "List nodes of a content type currently in a given moderation state (e.g. what is in 'draft' or 'needs_review'). Stock JSON:API cannot filter the computed moderation_state field; when the site rejects that filter the tool samples recent nodes client-side and marks the result approximate, instead of returning Drupal's 500.",
|
|
251
|
+
description: "List nodes of a content type currently in a given moderation state (e.g. what is in 'draft' or 'needs_review'). Pass langcode to match that translation via Sentinel inventory (the editorial work queue). Omit langcode for default-language JSON:API / sampled behavior. Stock JSON:API cannot filter the computed moderation_state field; when the site rejects that filter the tool samples recent nodes client-side and marks the result approximate, instead of returning Drupal's 500.",
|
|
184
252
|
inputSchema: {
|
|
185
253
|
type: "object", required: ["type", "state"],
|
|
186
254
|
properties: {
|
|
187
|
-
site:
|
|
188
|
-
type:
|
|
189
|
-
state:
|
|
190
|
-
|
|
191
|
-
|
|
255
|
+
site: { type: "string" },
|
|
256
|
+
type: { type: "string", description: "Content type machine name" },
|
|
257
|
+
state: { type: "string", description: "Moderation state machine name" },
|
|
258
|
+
langcode: { type: "string", description: "Match this translation (e.g. 'es'). Requires Sentinel. Omit for default-language listing." },
|
|
259
|
+
limit: { type: "number", default: 20 },
|
|
260
|
+
offset: { type: "number", default: 0 },
|
|
192
261
|
},
|
|
193
262
|
},
|
|
194
263
|
},
|
package/src/tools/nodes.js
CHANGED
|
@@ -15,10 +15,11 @@ import {
|
|
|
15
15
|
} from "../lib/security.js";
|
|
16
16
|
import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
|
|
17
17
|
import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA, omitLiveComputedMetatag } from "../lib/entity-response.js";
|
|
18
|
-
import { resolveErrRelationships, relationshipsWereSent } from "../lib/err-relationships.js";
|
|
18
|
+
import { resolveErrRelationships, relationshipsWereSent, paragraphPinsFromEntity } from "../lib/err-relationships.js";
|
|
19
19
|
import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
|
|
20
20
|
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
21
21
|
import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/draft-write.js";
|
|
22
|
+
import { paragraphResourceVersion } from "./paragraphs.js";
|
|
22
23
|
import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
|
|
23
24
|
import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
|
|
24
25
|
import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
|
|
@@ -239,7 +240,51 @@ function pageOf({ limit = 20, offset = 0 }) {
|
|
|
239
240
|
* @param {object} args - { site?, type, id }.
|
|
240
241
|
* @returns {Promise<object|null>} The redacted node, or null if not found.
|
|
241
242
|
*/
|
|
242
|
-
|
|
243
|
+
/**
|
|
244
|
+
* Load pinned paragraph translations (or default-language pins) onto a host.
|
|
245
|
+
* Per-component failures are recorded; they do not fail the host read.
|
|
246
|
+
* @param {object} backend
|
|
247
|
+
* @param {object} sec
|
|
248
|
+
* @param {object} entity
|
|
249
|
+
* @param {?string} langcode
|
|
250
|
+
* @returns {Promise<object>}
|
|
251
|
+
*/
|
|
252
|
+
async function withComponents(backend, sec, entity, langcode) {
|
|
253
|
+
const pins = paragraphPinsFromEntity(entity);
|
|
254
|
+
const components = [];
|
|
255
|
+
for (const pin of pins) {
|
|
256
|
+
try {
|
|
257
|
+
let para;
|
|
258
|
+
if (langcode && pin.revisionId) {
|
|
259
|
+
para = await readDraftTranslation(backend, {
|
|
260
|
+
entityType: "paragraph",
|
|
261
|
+
bundle: pin.paragraphType,
|
|
262
|
+
id: pin.id,
|
|
263
|
+
langcode,
|
|
264
|
+
draftRevision: { revisionId: pin.revisionId },
|
|
265
|
+
});
|
|
266
|
+
} else if (pin.revisionId) {
|
|
267
|
+
para = await backend.getEntity({
|
|
268
|
+
entityType: "paragraph",
|
|
269
|
+
bundle: pin.paragraphType,
|
|
270
|
+
id: pin.id,
|
|
271
|
+
resourceVersion: paragraphResourceVersion(pin.revisionId),
|
|
272
|
+
...(langcode ? { langcode } : {}),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
components.push({
|
|
276
|
+
...pin,
|
|
277
|
+
entity: para ? redactCanonicalEntity(para, sec, "paragraph") : null,
|
|
278
|
+
...(para ? {} : { note: "missing revision pin" }),
|
|
279
|
+
});
|
|
280
|
+
} catch (error) {
|
|
281
|
+
components.push({ ...pin, entity: null, error: String(error.message || error) });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { ...entity, components };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function getNode({ site: siteName, type, id, langcode, resourceVersion, includeComponents = false }) {
|
|
243
288
|
const site = getSiteConfig(siteName);
|
|
244
289
|
const sec = resolveSecurityConfig(site);
|
|
245
290
|
assertReadAllowed(sec, "node", type);
|
|
@@ -253,7 +298,8 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion })
|
|
|
253
298
|
entityType: "node", bundle: type, id, langcode: targetLang,
|
|
254
299
|
draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
|
|
255
300
|
});
|
|
256
|
-
|
|
301
|
+
const redacted = omitLiveComputedMetatag(redactCanonicalEntity(entity, sec, "node"));
|
|
302
|
+
return includeComponents ? withComponents(backend, sec, redacted, targetLang) : redacted;
|
|
257
303
|
}
|
|
258
304
|
const liveHas = (inventory.live?.translations ?? []).some((row) => row.langcode === targetLang);
|
|
259
305
|
if (!liveHas) {
|
|
@@ -261,7 +307,7 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion })
|
|
|
261
307
|
}
|
|
262
308
|
if (targetLang !== inventory.defaultLangcode) {
|
|
263
309
|
const liveRow = (inventory.live.translations ?? []).find((row) => row.langcode === targetLang);
|
|
264
|
-
|
|
310
|
+
const stub = {
|
|
265
311
|
id, entityType: "node", bundle: type, langcode: targetLang,
|
|
266
312
|
title: liveRow?.title ?? null,
|
|
267
313
|
status: liveRow?.status ?? null,
|
|
@@ -269,10 +315,13 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion })
|
|
|
269
315
|
_revisions: { live: inventory.live.vid, working: inventory.working?.vid ?? null },
|
|
270
316
|
note: "Published non-default translations are listed on the live revision; full field reads of an unpublished working translation use langcode against the working draft.",
|
|
271
317
|
};
|
|
318
|
+
return includeComponents ? { ...stub, components: [] } : stub;
|
|
272
319
|
}
|
|
273
320
|
}
|
|
274
321
|
const entity = await backend.getEntity({ entityType: "node", bundle: type, id, resourceVersion });
|
|
275
|
-
|
|
322
|
+
if (!entity) return null;
|
|
323
|
+
const redacted = redactCanonicalEntity(entity, sec, "node");
|
|
324
|
+
return includeComponents ? withComponents(backend, sec, redacted, langcode || null) : redacted;
|
|
276
325
|
}
|
|
277
326
|
|
|
278
327
|
/**
|
|
@@ -520,7 +569,7 @@ async function deleteNode({ site: siteName, type, id, dryRun = false }) {
|
|
|
520
569
|
export const definitions = [
|
|
521
570
|
{
|
|
522
571
|
name: "drupal_get_node",
|
|
523
|
-
description: "Fetch a single Drupal content node by UUID and content type. Returns title, body, status, path alias, and all attributes. Pass langcode to read a working translation draft via Sentinel (distinct from published English).",
|
|
572
|
+
description: "Fetch a single Drupal content node by UUID and content type. Returns title, body, status, path alias, and all attributes. Pass langcode to read a working translation draft via Sentinel (distinct from published English). Pass includeComponents true to attach pinned paragraph translations under `components` (default false).",
|
|
524
573
|
inputSchema: {
|
|
525
574
|
type: "object", required: ["type", "id"],
|
|
526
575
|
properties: {
|
|
@@ -528,6 +577,7 @@ export const definitions = [
|
|
|
528
577
|
type: { type: "string", description: "Content type machine name, e.g. 'article'" },
|
|
529
578
|
id: { type: "string", description: "Node UUID" },
|
|
530
579
|
langcode: { type: "string", description: "Target language (e.g. 'es') to read the unpublished working translation instead of the default language." },
|
|
580
|
+
includeComponents: { type: "boolean", default: false, description: "If true, include pinned paragraph translations under `components` (empty array when the host has no ERR fields). Default false so existing callers are unchanged." },
|
|
531
581
|
},
|
|
532
582
|
},
|
|
533
583
|
},
|