drupal-mcp-connector 1.3.1 → 1.4.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/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.0] - 2026-06-29
11
+
12
+ ### Added
13
+ - **Redirect tools** (`drupal_create_redirect`, `drupal_update_redirect`) for the
14
+ contrib Redirect module. `drupal_create_redirect` produces a redirect that serves
15
+ its 301 (or chosen code) immediately: the source path's leading slash is stripped
16
+ to the module's stored, slash-less form (the classic "redirect saved but never
17
+ fires" cause), the destination is normalized to a Drupal link-field URI (a bare
18
+ path is wrapped as `internal:`, while `entity:node/ID` and absolute URLs pass
19
+ through), and `status_code` defaults to 301 with 302 (and 303/307/308) accepted.
20
+ `drupal_update_redirect` repoints an existing redirect's source/target or changes
21
+ its status code via a partial update — the path to activate/fix a redirect that
22
+ isn't firing. Both are governed by the per-site security policy (redirect writes /
23
+ `administer redirects`). Resolves the gap where connector-created redirects were
24
+ inactive and could not be enabled (DEV-111).
25
+
26
+ ## [1.3.2] - 2026-06-27
27
+
28
+ ### Fixed
29
+ - `drupal_config_set` now forwards the configuration map under the `data` key the
30
+ server-side tool requires, instead of `value`. The governed tool
31
+ (`tool_api.mcp_sentinel_config_set`) declares its inputs as `name` plus `data` (a
32
+ map of top-level keys to new values, applied as a partial update). Previously the
33
+ connector sent `{ name, value }`, so every `config_set` was rejected with
34
+ `-32602 Invalid parameters … Missing required properties: \`data\``. The public
35
+ tool surface is unchanged — callers still pass `value` (a map); it is translated to
36
+ `data` at the call site. `config_get` / `config_list` were unaffected.
37
+
10
38
  ## [1.3.1] - 2026-06-27
11
39
 
12
40
  ### Fixed
package/README.md CHANGED
@@ -73,6 +73,7 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
73
73
  | **Translations** | List + create entity translations |
74
74
  | **Paragraphs** | Create/get Paragraph components for embedding in host fields |
75
75
  | **Structure** | Menu links + custom blocks (list/create) |
76
+ | **Redirects** | Create active URL redirects (301/302) + update/repoint existing redirects (Redirect module) |
76
77
  | **Search** | Best-effort content search (title match; Search API/Solr-ready) |
77
78
  | **Reports (extra)** | Orphaned references, unpublished content, missing-field audits |
78
79
  | **Config & Governance** | Governed config get/list/set via the server-tool bridge; `drupal_mcp_whoami` tier/capability report |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -65,6 +65,7 @@ import * as bulk from "./tools/bulk.js";
65
65
  import * as translations from "./tools/translations.js";
66
66
  import * as paragraphs from "./tools/paragraphs.js";
67
67
  import * as structure from "./tools/structure.js";
68
+ import * as redirects from "./tools/redirects.js";
68
69
  import * as search from "./tools/search.js";
69
70
  import * as reportsExtra from "./tools/reports-extra.js";
70
71
  import * as config from "./tools/config.js";
@@ -74,7 +75,7 @@ import * as config from "./tools/config.js";
74
75
  // ---------------------------------------------------------------------------
75
76
 
76
77
  const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
77
- revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, search, reportsExtra, config];
78
+ revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra, config];
78
79
 
79
80
  // Flatten every module's tool definitions into one ListTools payload, and merge
80
81
  // their handler maps into a single closed dispatch table keyed by tool name.
@@ -57,6 +57,11 @@ async function configList({ site: siteName, prefix }) {
57
57
  /**
58
58
  * Set a configuration value. Governed and audited server-side; the connector
59
59
  * additionally enforces the config-write cap before dispatching.
60
+ *
61
+ * The public `value` is a map of top-level config keys to their new values; the
62
+ * server-side tool (mcp_sentinel McpConfigSetTool) takes that map under the key
63
+ * `data` and applies a partial `$editable->set($key, $value)` per entry, so we
64
+ * translate `value` → `data` at the call site.
60
65
  * @param {object} args - { site?, name, value }.
61
66
  * @returns {Promise<*>} The server tool's result.
62
67
  * @throws {SecurityError} if the site is read-only or config writes are disabled.
@@ -67,7 +72,7 @@ async function configSet({ site: siteName, name, value }) {
67
72
  assertConfigScope(site, `config:set ${name}`);
68
73
  assertNotReadOnly(sec, `config:set ${name}`);
69
74
  assertConfigWriteAllowed(sec);
70
- return callServerTool(site, SERVER_TOOLS.configSet, { name, value });
75
+ return callServerTool(site, SERVER_TOOLS.configSet, { name, data: value });
71
76
  }
72
77
 
73
78
  // ---------------------------------------------------------------------------
@@ -163,7 +168,7 @@ export const definitions = [
163
168
  properties: {
164
169
  site: { type: "string" },
165
170
  name: { type: "string" },
166
- value: { description: "The configuration value to set (object, array, or scalar)." },
171
+ value: { type: "object", description: "A map of top-level config keys to their new values (e.g. { \"slogan\": \"Information Technology\" }). Other keys in the object are preserved server-side." },
167
172
  },
168
173
  },
169
174
  },
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Tool group: URL redirects (the contrib Redirect module).
3
+ *
4
+ * A `redirect` entity maps an old/source path to a destination and fires an HTTP
5
+ * redirect (301 by default) when the source path is requested. Redirects are a
6
+ * single-bundle content entity (`redirect--redirect`) exposed over JSON:API, so
7
+ * they go through the shared backend like the other structural content tools.
8
+ *
9
+ * Why a dedicated tool (vs. the generic entity tools): the Redirect module's
10
+ * field shape is unforgiving and easy to get wrong, which produces a stored-but-
11
+ * dead redirect:
12
+ * - `redirect_source` stores the source path WITHOUT a leading slash. A source
13
+ * saved as "/old" never matches an incoming request for "old", so the
14
+ * redirect silently never fires. This tool strips the leading slash so a
15
+ * created redirect is live (serves its 301) immediately.
16
+ * - `redirect_redirect` is a Drupal link field — a bare "/new" must be wrapped
17
+ * as the URI "internal:/new". This tool normalizes destinations so callers
18
+ * can pass a plain path, an `entity:node/ID`, or an absolute URL.
19
+ * - `status_code` defaults to 301 and can be set to 302 (or another redirect
20
+ * code) explicitly on create, and changed on an existing redirect via update.
21
+ *
22
+ * Redirect entities have no separate enabled/disabled flag — a redirect with a
23
+ * valid source is active. "Enable an existing redirect" therefore means: correct
24
+ * its fields so it matches and fires, which is exactly what drupal_update_redirect
25
+ * does. Both tools are governed: writes assert create/update permission for the
26
+ * `redirect` entity type against the per-site security policy.
27
+ */
28
+
29
+ import { getSiteConfig } from "../lib/config.js";
30
+ import { resolveBackend } from "../lib/backends/index.js";
31
+ import { resolveSecurityConfig, assertWriteAllowed } from "../lib/security.js";
32
+
33
+ const REDIRECT_TYPE = "redirect";
34
+
35
+ // Redirect status codes the Redirect module supports. 301/302 are the common
36
+ // pair called for in the ticket; the rest are the other valid HTTP redirect
37
+ // codes, accepted so the tool isn't needlessly restrictive. 301 is the default.
38
+ const ALLOWED_STATUS_CODES = [301, 302, 303, 307, 308];
39
+
40
+ // Drupal URI schemes a destination may already carry; anything else that is a
41
+ // path gets wrapped as internal:.
42
+ const URI_SCHEME_RE = /^(https?:|mailto:|tel:|internal:|entity:|route:|base:)/i;
43
+
44
+ /**
45
+ * Normalize a source path to the Redirect module's stored form: trimmed, no
46
+ * leading slash. Storing a leading slash is the classic "redirect saved but
47
+ * never fires" bug, so this is the core of the fix.
48
+ *
49
+ * @param {string} source Raw source path, e.g. "/old-path" or "old-path".
50
+ * @returns {string} The source path without a leading slash.
51
+ */
52
+ function normalizeSource(source) {
53
+ return String(source).trim().replace(/^\/+/, "");
54
+ }
55
+
56
+ /**
57
+ * Normalize a redirect destination into a Drupal link-field URI. Absolute URLs
58
+ * and explicit Drupal URI schemes (entity:, internal:, route:, …) pass through
59
+ * unchanged; a bare path is wrapped as `internal:`.
60
+ *
61
+ * @param {string} target Destination path or URI.
62
+ * @returns {string} A Drupal link-field URI.
63
+ */
64
+ function normalizeTargetUri(target) {
65
+ const t = String(target).trim();
66
+ if (URI_SCHEME_RE.test(t)) return t;
67
+ return t.startsWith("/") ? `internal:${t}` : `internal:/${t}`;
68
+ }
69
+
70
+ /**
71
+ * Validate a requested status code against the supported redirect codes.
72
+ *
73
+ * @param {number} code The HTTP status code.
74
+ * @returns {number} The validated code.
75
+ * @throws {Error} If the code is not a supported redirect status code.
76
+ */
77
+ function assertStatusCode(code) {
78
+ if (!ALLOWED_STATUS_CODES.includes(code)) {
79
+ throw new Error(
80
+ `Unsupported redirect status code ${code}. Use one of: ${ALLOWED_STATUS_CODES.join(", ")} (301 is the default).`,
81
+ );
82
+ }
83
+ return code;
84
+ }
85
+
86
+ /**
87
+ * Create an active URL redirect.
88
+ *
89
+ * @param {object} args - { site?, source, target, statusCode?, language? }.
90
+ * `source` is the old path (a leading slash is fine; it is stripped to the
91
+ * stored form). `target` is the destination — a path ("/new"), an
92
+ * `entity:node/ID`, or an absolute URL. `statusCode` defaults to 301; pass 302
93
+ * for a temporary redirect. `language` defaults to 'und' (all languages).
94
+ * @returns {Promise<object>} The created redirect descriptor from the backend.
95
+ * @throws {Error} If source/target are missing or the status code is unsupported.
96
+ * @throws {SecurityError} If creating redirects is not permitted.
97
+ */
98
+ async function createRedirect({ site: siteName, source, target, statusCode = 301, language = "und" }) {
99
+ if (!source) throw new Error("A redirect 'source' path is required (e.g. '/old-path').");
100
+ if (!target) throw new Error("A redirect 'target' is required (a path, 'entity:node/ID', or an absolute URL).");
101
+ assertStatusCode(statusCode);
102
+ const site = getSiteConfig(siteName);
103
+ const sec = resolveSecurityConfig(site);
104
+ assertWriteAllowed(sec, "create", REDIRECT_TYPE, REDIRECT_TYPE);
105
+ const backend = await resolveBackend(site);
106
+ const attributes = {
107
+ redirect_source: { path: normalizeSource(source), query: null },
108
+ redirect_redirect: { uri: normalizeTargetUri(target) },
109
+ status_code: statusCode,
110
+ language,
111
+ };
112
+ return backend.createEntity({ entityType: REDIRECT_TYPE, bundle: REDIRECT_TYPE, attributes });
113
+ }
114
+
115
+ /**
116
+ * Update an existing redirect: repoint its source/target or change its status
117
+ * code. Only the provided fields are sent (a partial JSON:API PATCH), so an
118
+ * update that changes just the status code leaves source/target untouched.
119
+ *
120
+ * @param {object} args - { site?, id, source?, target?, statusCode? }.
121
+ * @returns {Promise<object>} The updated redirect descriptor from the backend.
122
+ * @throws {Error} If id is missing or the status code is unsupported.
123
+ * @throws {SecurityError} If updating redirects is not permitted.
124
+ */
125
+ async function updateRedirect({ site: siteName, id, source, target, statusCode }) {
126
+ if (!id) throw new Error("A redirect 'id' (UUID) is required to update an existing redirect.");
127
+ const site = getSiteConfig(siteName);
128
+ const sec = resolveSecurityConfig(site);
129
+ assertWriteAllowed(sec, "update", REDIRECT_TYPE, REDIRECT_TYPE);
130
+ const backend = await resolveBackend(site);
131
+ const attributes = {};
132
+ if (source !== undefined) attributes.redirect_source = { path: normalizeSource(source), query: null };
133
+ if (target !== undefined) attributes.redirect_redirect = { uri: normalizeTargetUri(target) };
134
+ if (statusCode !== undefined) attributes.status_code = assertStatusCode(statusCode);
135
+ return backend.updateEntity({ entityType: REDIRECT_TYPE, bundle: REDIRECT_TYPE, id, attributes });
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Tool definitions
140
+ // ---------------------------------------------------------------------------
141
+
142
+ export const definitions = [
143
+ {
144
+ name: "drupal_create_redirect",
145
+ description:
146
+ "Create an active URL redirect (contrib Redirect module). The redirect serves its 301 (or chosen code) immediately: 'source' is the old path (a leading slash is fine — it is normalized to the module's stored, slash-less form so the redirect actually matches and fires), and 'target' is the destination as a path ('/new'), an 'entity:node/ID', or an absolute URL. status_code defaults to 301; pass 302 for a temporary redirect. Governed by the site security policy (needs redirect write / 'administer redirects').",
147
+ inputSchema: {
148
+ type: "object", required: ["source", "target"],
149
+ properties: {
150
+ site: { type: "string", description: "Named site (omit for default)" },
151
+ source: { type: "string", description: "Source/old path to redirect from, e.g. '/old-slug'. Leading slash optional." },
152
+ target: { type: "string", description: "Destination: a path ('/new-slug'), 'entity:node/42', or an absolute 'https://…' URL." },
153
+ statusCode: { type: "number", default: 301, description: "HTTP redirect status code. 301 (permanent, default) or 302 (temporary); 303/307/308 also accepted." },
154
+ language: { type: "string", default: "und", description: "Langcode the redirect applies to. Defaults to 'und' (all languages)." },
155
+ },
156
+ },
157
+ },
158
+ {
159
+ name: "drupal_update_redirect",
160
+ description:
161
+ "Update an existing redirect by UUID: repoint its source or target, or change its status code (e.g. 301↔302). Only the fields you pass are changed (partial update). Use this to activate/fix a redirect that isn't firing (e.g. one created with a stale source). Governed by the site security policy.",
162
+ inputSchema: {
163
+ type: "object", required: ["id"],
164
+ properties: {
165
+ site: { type: "string" },
166
+ id: { type: "string", description: "Redirect entity UUID" },
167
+ source: { type: "string", description: "New source/old path (leading slash optional). Omit to leave unchanged." },
168
+ target: { type: "string", description: "New destination path/URI. Omit to leave unchanged." },
169
+ statusCode: { type: "number", description: "New HTTP redirect status code (301/302/303/307/308). Omit to leave unchanged." },
170
+ },
171
+ },
172
+ },
173
+ ];
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Handler map
177
+ // ---------------------------------------------------------------------------
178
+
179
+ export const handlers = {
180
+ drupal_create_redirect: createRedirect,
181
+ drupal_update_redirect: updateRedirect,
182
+ };