drupal-mcp-connector 2.19.0 → 2.20.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-config-set.md +2 -2
- package/.agents/commands/drupal-create-node.md +2 -2
- package/.agents/commands/drupal-create-translation.md +1 -1
- package/.agents/commands/drupal-delete-node.md +1 -1
- package/.agents/commands/drupal-describe-fields.md +2 -2
- package/.agents/commands/drupal-drush-config-import.md +2 -2
- package/.agents/commands/drupal-drush-module-disable.md +2 -2
- package/.agents/commands/drupal-drush-module-list.md +1 -1
- package/.agents/commands/drupal-drush-user-list.md +1 -1
- package/.agents/commands/drupal-drush-watchdog.md +1 -1
- package/.agents/commands/drupal-entity-create.md +2 -2
- package/.agents/commands/drupal-entity-delete.md +1 -1
- package/.agents/commands/drupal-entity-update.md +4 -4
- package/.agents/commands/drupal-report-field-completeness.md +2 -2
- package/.agents/commands/drupal-report-missing-field.md +2 -2
- package/.agents/commands/drupal-report-seo-meta-coverage.md +2 -2
- package/.agents/commands/drupal-report-status-report.md +1 -1
- package/.agents/commands/drupal-update-node.md +4 -4
- package/CHANGELOG.md +254 -0
- package/README.md +10 -3
- package/bin/drupal-mcp-verify.js +4 -3
- package/config/config.example.json +52 -2
- package/package.json +1 -1
- package/scripts/generate-commands.js +40 -5
- package/scripts/install-commands.js +148 -11
- package/src/index.js +9 -12
- package/src/lib/backends/graphql-schema.js +9 -1
- package/src/lib/backends/graphql.js +4 -2
- package/src/lib/backends/index.js +9 -3
- package/src/lib/backends/jsonapi.js +2 -0
- package/src/lib/dispatch.js +6 -6
- package/src/lib/drupal-fetch.js +97 -26
- package/src/lib/dry-run-checks.js +78 -0
- package/src/lib/error-body.js +448 -0
- package/src/lib/error-status.js +38 -0
- package/src/lib/errors.js +0 -11
- package/src/lib/evidence.js +0 -6
- package/src/lib/governance.js +2 -8
- package/src/lib/link-checker.js +3 -3
- package/src/lib/mcp-server.js +7 -1
- package/src/lib/metatag-audit.js +2 -1
- package/src/lib/module-tools.js +23 -2
- package/src/lib/operations.js +2 -2
- package/src/lib/patch-preflight.js +23 -5
- package/src/lib/policy-enforcement.js +4 -4
- package/src/lib/principal.js +3 -3
- package/src/lib/relay/edge.js +2 -2
- package/src/lib/reports-support.js +75 -0
- package/src/lib/security.js +234 -18
- package/src/lib/sentinel-draft.js +3 -2
- package/src/lib/server-tools.js +188 -27
- package/src/lib/tool-prompts.js +139 -8
- package/src/lib/usage.js +0 -9
- package/src/lib/verify.js +164 -61
- package/src/tools/config.js +70 -5
- package/src/tools/drush.js +191 -17
- package/src/tools/entities.js +18 -6
- package/src/tools/fields.js +40 -4
- package/src/tools/graphql.js +10 -5
- package/src/tools/nodes.js +16 -6
- package/src/tools/paragraphs.js +1 -1
- package/src/tools/reports-config.js +3 -3
- package/src/tools/reports-content.js +34 -26
- package/src/tools/reports-extra.js +46 -38
- package/src/tools/reports.js +50 -25
- package/src/tools/scheduler.js +1 -1
- package/src/tools/structure.js +12 -2
- package/src/tools/translations.js +5 -1
- package/src/lib/draft-write.js +0 -19
- package/src/lib/node-draft-inventory.js +0 -5
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
|
|
|
68
68
|
|
|
69
69
|
## Features
|
|
70
70
|
|
|
71
|
-
###
|
|
71
|
+
### 123 Tools Across 27 Modules
|
|
72
72
|
|
|
73
73
|
| Module | Tools |
|
|
74
74
|
|--------|-------|
|
|
@@ -77,6 +77,7 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
|
|
|
77
77
|
| **Users** | List, get, create, update, block/unblock, role management (PII-gated) |
|
|
78
78
|
| **Media** | List types, CRUD, file upload, orphaned-media detection |
|
|
79
79
|
| **GraphQL** | Execute a query, schema introspection (mutation-gated) |
|
|
80
|
+
| **Codegen** | Optional graphql_compose_codegen inspect/diff/generate (generate is `--dry-run` text) |
|
|
80
81
|
| **Entities** | Generic CRUD for *any* Drupal entity type (paragraphs, commerce, webforms, …) |
|
|
81
82
|
| **Site** | Site info, content-type discovery, configured-site listing |
|
|
82
83
|
| **Reports** | Content summary, stale content, field completeness, SEO/accessibility audits, taxonomy usage, user activity, revision hotspots (10 read-only reports) |
|
|
@@ -99,7 +100,7 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
|
|
|
99
100
|
| **Audit (composite)** | `drupal_audit_site_health` — scored content/links/config dashboard with a roll-up grade |
|
|
100
101
|
| **Config & Governance** | Governed config get/list/set via the server-tool bridge; `drupal_mcp_whoami` tier/capability report |
|
|
101
102
|
|
|
102
|
-
**Preview writes with `dryRun`.** The node and entity create/update/delete tools accept an optional `dryRun: true` flag that
|
|
103
|
+
**Preview writes with `dryRun`.** The node and entity create/update/delete tools accept an optional `dryRun: true` flag that runs the connector's checks and returns a preview of the payload, without committing anything to Drupal. The result's `checks` block says what was checked. Field access and entity validation are checked only when Sentinel's non-saving draft endpoint ran; see [docs/tools-reference.md](docs/tools-reference.md#preview-writes-with-dryrun).
|
|
103
104
|
|
|
104
105
|
### MCP Resources
|
|
105
106
|
Browsable, always-fresh context the client can read without calling a tool:
|
|
@@ -144,8 +145,14 @@ npm run install:commands # copies into ~/.claude/commands, ~/.grok/commands,
|
|
|
144
145
|
# npm run install:commands -- --clients claude
|
|
145
146
|
# npm run install:commands -- --clients codex
|
|
146
147
|
# npm run install:commands -- --home /path/to/fake-home # tests / CI
|
|
148
|
+
# npm run install:commands -- --modules # also write stubs for module-owned tools
|
|
147
149
|
```
|
|
148
150
|
|
|
151
|
+
Module-owned tools ([docs/module-tools.md](docs/module-tools.md)) get a prompt
|
|
152
|
+
whenever they are visible to the caller. Their bare `/drupal-<namespace>-<alias>`
|
|
153
|
+
stubs are written only with `--modules`, which discovers them from the sources
|
|
154
|
+
in `config/config.json`.
|
|
155
|
+
|
|
149
156
|
From an npm install, the same files ship at
|
|
150
157
|
`node_modules/drupal-mcp-connector/.agents/commands/`. Do not copy them into an
|
|
151
158
|
application repo's `.claude/` or `.grok/` directory.
|
|
@@ -308,7 +315,7 @@ an operator channel: keep the agent's credentials off it, and pin
|
|
|
308
315
|
| [OAuth client_credentials](docs/oauth-client-credentials.md) | Production OAuth deploy: scope→role mapping, JSON:API writes, config persistence, secret handling, troubleshooting |
|
|
309
316
|
| [Architecture](docs/architecture.md) | Backend abstraction, canonical model, and how to extend it |
|
|
310
317
|
| [GraphQL Setup](docs/graphql-local-setup.md) | GraphQL Compose backend + local TLS notes |
|
|
311
|
-
| [Tools Reference](docs/tools-reference.md) | Full reference for all
|
|
318
|
+
| [Tools Reference](docs/tools-reference.md) | Full reference for all 123 tools |
|
|
312
319
|
| [Security Guide](docs/security.md) | Presets, entity access control, field redaction |
|
|
313
320
|
| [Security Hardening](docs/security-hardening.md) | Optional transport, identity, and secrets controls |
|
|
314
321
|
| [Verification](docs/verification.md) | Prove a secure install: static + live checks, negative probes, evidence artefact, managed residuals |
|
package/bin/drupal-mcp-verify.js
CHANGED
|
@@ -21,7 +21,7 @@ import process from "node:process";
|
|
|
21
21
|
import fetch from "node-fetch";
|
|
22
22
|
import { verifyStatic, verifyLive } from "../src/lib/verify.js";
|
|
23
23
|
import { loadConfig, getSiteConfig, resolveOauth } from "../src/lib/config.js";
|
|
24
|
-
import { callServerTool } from "../src/lib/server-tools.js";
|
|
24
|
+
import { callServerTool, listServerTools } from "../src/lib/server-tools.js";
|
|
25
25
|
|
|
26
26
|
/** Parses `--flag`, `--key value` and `--key=value`. */
|
|
27
27
|
function parseArgs(argv) {
|
|
@@ -118,12 +118,13 @@ async function main() {
|
|
|
118
118
|
? resolveOauth({ ...(config.sites?.[siteName ?? config.defaultSite] ?? {}), _name: siteName ?? config.defaultSite })
|
|
119
119
|
: getSiteConfig(siteName);
|
|
120
120
|
// The real bridge client, so the governed-tool probes exercise the real
|
|
121
|
-
// contract (MCP session,
|
|
122
|
-
// hand-rolled JSON-RPC body the server would reject as malformed.
|
|
121
|
+
// contract (MCP session, advertised tool name, tool-level refusal) rather
|
|
122
|
+
// than a hand-rolled JSON-RPC body the server would reject as malformed.
|
|
123
123
|
evidence.push(
|
|
124
124
|
await verifyLive(site, {
|
|
125
125
|
transport: fetch,
|
|
126
126
|
callTool: callServerTool,
|
|
127
|
+
listTools: listServerTools,
|
|
127
128
|
contentTarget: args["content-target"] ? String(args["content-target"]) : null,
|
|
128
129
|
contentTargetType: args["content-target-type"] ? String(args["content-target-type"]) : null,
|
|
129
130
|
}),
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
},
|
|
110
110
|
"serverTools": { "url": "/mcp" },
|
|
111
111
|
"drushSsh": {
|
|
112
|
-
"_comment": "Development only. SSH target of the local web container. Whitelisted to config export/status — every other drupal_drush_* tool is blocked here. rawSql is omitted, so drupal_drush_sql_query is refused: raw SQL reads underneath Drupal's entity API and is only available
|
|
112
|
+
"_comment": "Development only. SSH target of the local web container. Whitelisted to config export/status — every other drupal_drush_* tool is blocked here. rawSql is omitted, so drupal_drush_sql_query is refused: raw SQL reads underneath Drupal's entity API and is only available through mcp_sentinel's governed SQL tool. To enable it, set rawSql:\"governed\" here and set allow_raw_sql on the site's policy profile. Then choose one path. With serverTools.bindings.sqlQuery mapped to a module tool (see _server_tools), the query goes to mcp_sentinel over the governed MCP endpoint and uses no SSH host, key or connection. Without a bindings object it runs over this SSH bridge, so also add \"mcp-sentinel:sql-query\" to allowedCommands.",
|
|
113
113
|
"host": "<web-container-ssh-host>",
|
|
114
114
|
"user": "<web-container-ssh-user>",
|
|
115
115
|
"keyPath": "~/.ssh/id_ed25519",
|
|
@@ -154,13 +154,63 @@
|
|
|
154
154
|
},
|
|
155
155
|
|
|
156
156
|
"_server_tools": {
|
|
157
|
-
"_comment": "serverTools.url is the JSON-RPC endpoint of the Drupal-side governed MCP tools (mcp_server_tool_bridge / mcp_sentinel), resolved against baseUrl. Required for drupal_config_get/list/set. The config-inspection audits will also use it (governed path) when present, but fall back to the drush bridge, so it is optional for the audits. Authenticated with the same OAuth bearer."
|
|
157
|
+
"_comment": "serverTools.url is the JSON-RPC endpoint of the Drupal-side governed MCP tools (mcp_server_tool_bridge / mcp_sentinel), resolved against baseUrl. Required for drupal_config_get/list/set. The config-inspection audits will also use it (governed path) when present, but fall back to the drush bridge, so it is optional for the audits. Authenticated with the same OAuth bearer.",
|
|
158
|
+
"_modules_comment": "Optional. serverTools.modules exposes Drupal module-owned tools through the generic registry. It is opt-in: a tool installed on the source, or listed in its catalog, is not exposed until it is named here. namespace is lowercase letters, digits and underscores, up to 24 characters. An alias follows the same rule, up to 48 characters. The namespace and alias together must be unique across configured sites. Each entry under tools is an alias with four required keys. name is the exact wire name from the source's tools/list; copy it, because bridge versions join the parts differently and the connector does not rewrite it. scope is the inbound scope a caller needs. operation is read, write or delete; it sets the public tool name (drupal_module_<operation>_<namespace>__<alias>) and the connector gate, whatever the source's own annotation says. capabilities lists extra connector gates (publish, configRead, configWrite, graphql, rawSql) and may be empty. These keys only narrow access; Drupal stays authoritative. To get slash-command stubs for the approved module tools, run `npm run install:commands -- --modules` from the directory that holds config/config.json. See docs/module-tools.md.",
|
|
159
|
+
"_bindings_comment": "Optional. serverTools.bindings keeps a built-in command's public name while its implementation lives in a Drupal module. Each binding names an alias under serverTools.modules.tools on the same site. configGet, configList, configSet serve drupal_config_get/list/set and the config reports; codegenInspect, codegenDiff, codegenPreview serve drupal_codegen_inspect/diff/generate; sqlQuery serves drupal_drush_sql_query. Required alias policy: config and codegen aliases use scope mcp_config; get, list and the three codegen aliases are operation read with capability configRead; configSet is operation write with capability configWrite; sqlQuery is scope mcp_admin, operation read, capability rawSql, and still needs drushSsh.rawSql: \"governed\" on the site. Once a bindings object exists, every one of these commands resolves only through its binding: a missing or mismatched mapping refuses the command, with no fallback to another tool or to SSH. Configure the full set for the commands the site uses. Without a bindings object these commands keep their built-in transport. See docs/module-tools.md.",
|
|
160
|
+
"example": {
|
|
161
|
+
"serverTools": {
|
|
162
|
+
"url": "/mcp",
|
|
163
|
+
"modules": {
|
|
164
|
+
"namespace": "example_site",
|
|
165
|
+
"tools": {
|
|
166
|
+
"list_activities": {
|
|
167
|
+
"name": "tool_api__example_list_activities",
|
|
168
|
+
"scope": "example_read",
|
|
169
|
+
"operation": "read",
|
|
170
|
+
"capabilities": []
|
|
171
|
+
},
|
|
172
|
+
"record_activity": {
|
|
173
|
+
"name": "tool_api__example_record_activity",
|
|
174
|
+
"scope": "example_write",
|
|
175
|
+
"operation": "write",
|
|
176
|
+
"capabilities": []
|
|
177
|
+
},
|
|
178
|
+
"get_config": { "name": "tool_api__mcp_sentinel_config_get", "scope": "mcp_config", "operation": "read", "capabilities": ["configRead"] },
|
|
179
|
+
"list_config": { "name": "tool_api__mcp_sentinel_config_list", "scope": "mcp_config", "operation": "read", "capabilities": ["configRead"] },
|
|
180
|
+
"set_config": { "name": "tool_api__mcp_sentinel_config_set", "scope": "mcp_config", "operation": "write", "capabilities": ["configWrite"] },
|
|
181
|
+
"codegen_inspect": { "name": "tool_api__graphql_compose_codegen_inspect", "scope": "mcp_config", "operation": "read", "capabilities": ["configRead"] },
|
|
182
|
+
"codegen_diff": { "name": "tool_api__graphql_compose_codegen_diff", "scope": "mcp_config", "operation": "read", "capabilities": ["configRead"] },
|
|
183
|
+
"codegen_preview": { "name": "tool_api__graphql_compose_codegen_preview", "scope": "mcp_config", "operation": "read", "capabilities": ["configRead"] },
|
|
184
|
+
"sql_query": { "name": "tool_api__mcp_sentinel_sql_query", "scope": "mcp_admin", "operation": "read", "capabilities": ["rawSql"] }
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
"bindings": {
|
|
188
|
+
"configGet": "get_config",
|
|
189
|
+
"configList": "list_config",
|
|
190
|
+
"configSet": "set_config",
|
|
191
|
+
"codegenInspect": "codegen_inspect",
|
|
192
|
+
"codegenDiff": "codegen_diff",
|
|
193
|
+
"codegenPreview": "codegen_preview",
|
|
194
|
+
"sqlQuery": "sql_query"
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
158
198
|
},
|
|
159
199
|
|
|
160
200
|
"_audit_tools": {
|
|
161
201
|
"_comment": "The audit tool groups (drupal_report_* links/config/content, drupal_audit_site_health) are read-only. Privileged audits (404 log, config drift/best-practices, module/permission/status) are self-sufficient via the connector's own drush bridge (drushSsh) — no companion module required; the config-inspection audits additionally prefer the governed config server-tool when serverTools is configured. They report 'unavailable' when no source is configured for the site. drupal_report_broken_links does no outbound HTTP unless called with checkLive:true; see each site's optional 'audit' block for live-check limits."
|
|
162
202
|
},
|
|
163
203
|
|
|
204
|
+
"_protected_modules": {
|
|
205
|
+
"_comment": "drupal_drush_module_disable refuses governance, integrity, secrets, auth and API modules on every preset (mcp_sentinel, audit_chain, field_guard, file_gate, key, encrypt, simple_oauth, consumers, jsonapi, serialization, mcp_server, mcp_server_tool_bridge, tool, content_moderation, workflows). Two optional per-site keys under security change the list. protectedModules adds modules and cannot shrink the default. allowProtectedModuleUninstall is the explicit opt-out and removes only the modules it names. A malformed value in either key blocks every uninstall on that site. Neither key opens readOnly, allowDestructive or drushSsh.allowedCommands. An uninstall that would cascade to dependents is refused. See docs/security.md#protected-modules.",
|
|
206
|
+
"example": { "preset": "development", "protectedModules": ["my_audit_module"], "allowProtectedModuleUninstall": [] }
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
"_core_extension": {
|
|
210
|
+
"_comment": "core.extension lists installed modules and themes, so changing it can uninstall a protected module. On every preset drupal_config_set refuses a write to core.extension, and drupal_drush_config_import first runs `drush config:status` and imports nothing when core.extension differs or the status cannot be read. If drushSsh.allowedCommands is set it must list config:status as well as config:import. One optional per-site key under security opens both: allowCoreExtensionChange, true or false, default false. Any other value keeps both refused. With it set, drupal_config_set still refuses a value that removes or alters an installed protected module, and drupal_drush_config_import runs unchecked because the connector cannot read the sync directory. The key opens no other gate. A config import run outside the connector is out of its reach; add core.extension to denied_config_types on the MCP Sentinel policy profile for the source-side control. See docs/security.md#changes-to-coreextension.",
|
|
211
|
+
"example": { "preset": "config-editor", "allowCoreExtensionChange": false }
|
|
212
|
+
},
|
|
213
|
+
|
|
164
214
|
"_mcp_client_registration": {
|
|
165
215
|
"_comment": "Add under your MCP client's mcpServers config (stdio transport)",
|
|
166
216
|
"drupal": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.20.0",
|
|
4
4
|
"description": "Drupal MCP Connector — multi-site MCP server for Drupal with JSON:API and GraphQL, governed writes, draft translations, content tools, audit reports, and an SSH Drush bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* Driven from the same tool definitions as the server (src/tools/index.js), so the
|
|
16
16
|
* command set never drifts from the tools. Run: `npm run generate:commands`.
|
|
17
17
|
*
|
|
18
|
+
* Module-owned tools are discovered at runtime and have no committed stub. The
|
|
19
|
+
* installer writes them on request (`install:commands -- --modules`), using
|
|
20
|
+
* `moduleCommandFileName` and the `MODULE_STUB_MARKER` exported here.
|
|
21
|
+
*
|
|
18
22
|
* Exports `renderCommandMarkdown`, `renderClaudeCommandMarkdown`,
|
|
19
23
|
* `renderCodexSkillMarkdown`, `renderCodexToolsReference`, `CODEX_SKILL_NAME`,
|
|
20
24
|
* `commandFileName`, `COMMANDS_DIR`, and `generate` for tests; the file-writing
|
|
@@ -25,7 +29,9 @@ import { mkdirSync, readdirSync, rmSync, writeFileSync, realpathSync } from "fs"
|
|
|
25
29
|
import { pathToFileURL } from "url";
|
|
26
30
|
|
|
27
31
|
import { allDefinitions } from "../src/tools/index.js";
|
|
28
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
isModuleDefinition, moduleCallNotes, toolDescription, toolNameToPromptName, toolParams,
|
|
34
|
+
} from "../src/lib/tool-prompts.js";
|
|
29
35
|
import { isDestructiveTool } from "../src/lib/operations.js";
|
|
30
36
|
|
|
31
37
|
/** Canonical, harness-agnostic command tree shipped in the repo and the npm package. */
|
|
@@ -36,6 +42,31 @@ export function commandFileName(def) {
|
|
|
36
42
|
return `${toolNameToPromptName(def.name)}.md`;
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
/** Trailing marker that tells the installer a stub belongs to a module-owned tool. */
|
|
46
|
+
export const MODULE_STUB_MARKER = "<!-- drupal-mcp-connector:module-tool -->";
|
|
47
|
+
|
|
48
|
+
const MODULE_TOOL_NAME = /^drupal_module_(?:read|write|delete)_([a-z][a-z0-9_]*?)__([a-z][a-z0-9_]*)$/;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Command filename for a module-owned tool: `drupal-<namespace>-<alias>.md`. The
|
|
52
|
+
* operation is left out so the command reads as the module's action; the stub
|
|
53
|
+
* body still names the full tool.
|
|
54
|
+
*
|
|
55
|
+
* Either part may contain `__`, so a tool name cannot always be split back.
|
|
56
|
+
* Pass the configured parts when they are known; the name is parsed only as a
|
|
57
|
+
* fallback, at the first separator.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} def - A tool definition.
|
|
60
|
+
* @param {{namespace: string, alias: string}} [parts] - From local config.
|
|
61
|
+
* @returns {?string} The filename, or null when the name is not a module tool.
|
|
62
|
+
*/
|
|
63
|
+
export function moduleCommandFileName(def, parts) {
|
|
64
|
+
const match = MODULE_TOOL_NAME.exec(def?.name ?? "");
|
|
65
|
+
if (!match) return null;
|
|
66
|
+
const [namespace, alias] = parts ? [parts.namespace, parts.alias] : [match[1], match[2]];
|
|
67
|
+
return `drupal-${namespace}-${alias}.md`.replace(/_/g, "-");
|
|
68
|
+
}
|
|
69
|
+
|
|
39
70
|
/** Collapse to a single-line, double-quoted YAML scalar. */
|
|
40
71
|
function yamlString(value) {
|
|
41
72
|
const clean = String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\s+/g, " ").trim();
|
|
@@ -60,18 +91,20 @@ function argumentHint(params) {
|
|
|
60
91
|
* @returns {string} File contents (ends with a trailing newline).
|
|
61
92
|
*/
|
|
62
93
|
export function renderCommandMarkdown(def, options = {}) {
|
|
63
|
-
const params =
|
|
94
|
+
const params = toolParams(def);
|
|
64
95
|
const required = params.filter((p) => p.required);
|
|
65
96
|
const optional = params.filter((p) => !p.required);
|
|
66
97
|
const line = (p) => `- \`${p.name}\` (${p.hint})${p.description ? `: ${p.description}` : ""}`;
|
|
98
|
+
const isModule = isModuleDefinition(def);
|
|
67
99
|
const argumentsPhrase = options.argumentsPhrase ?? "the arguments supplied with this command";
|
|
68
100
|
|
|
69
|
-
const
|
|
101
|
+
const description = toolDescription(def);
|
|
102
|
+
const frontmatter = ["---", `description: ${yamlString(description)}`];
|
|
70
103
|
if (params.length) frontmatter.push(`argument-hint: ${yamlString(argumentHint(params))}`);
|
|
71
104
|
if (options.allowedTools) frontmatter.push(`allowed-tools: ${options.allowedTools}`);
|
|
72
105
|
frontmatter.push("---");
|
|
73
106
|
|
|
74
|
-
const body = [`Call the MCP tool \`${def.name}\`.`, "",
|
|
107
|
+
const body = [`Call the MCP tool \`${def.name}\`.`, "", description];
|
|
75
108
|
|
|
76
109
|
if (isDestructiveTool(def.name)) {
|
|
77
110
|
body.push("", "> ⚠ **Destructive** — this permanently changes or deletes data. Confirm with the user before calling.");
|
|
@@ -79,7 +112,7 @@ export function renderCommandMarkdown(def, options = {}) {
|
|
|
79
112
|
|
|
80
113
|
body.push("");
|
|
81
114
|
if (params.length === 0) {
|
|
82
|
-
body.push("This tool takes no arguments — call it directly.");
|
|
115
|
+
body.push(isModule ? "This tool takes no parameters." : "This tool takes no arguments — call it directly.");
|
|
83
116
|
} else {
|
|
84
117
|
body.push(`Parse ${argumentsPhrase} into this tool's parameters:`, "");
|
|
85
118
|
if (required.length) {
|
|
@@ -99,6 +132,8 @@ export function renderCommandMarkdown(def, options = {}) {
|
|
|
99
132
|
);
|
|
100
133
|
}
|
|
101
134
|
|
|
135
|
+
if (isModule) body.push("", ...moduleCallNotes(def, params.length > 0), "", MODULE_STUB_MARKER);
|
|
136
|
+
|
|
102
137
|
return `${frontmatter.join("\n")}\n\n${body.join("\n")}\n`;
|
|
103
138
|
}
|
|
104
139
|
|
|
@@ -14,10 +14,15 @@
|
|
|
14
14
|
* Pass `--clients` to subset. Never writes into a project tree. Codex custom
|
|
15
15
|
* prompts (`~/.codex/prompts`) are not written — they are deprecated.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
17
|
+
* Module-owned tools (docs/module-tools.md) are written only with `--modules`.
|
|
18
|
+
* That run discovers the tools the local config approves, from the configured
|
|
19
|
+
* sources, and writes `drupal-<namespace>-<alias>.md` stubs. Run it from the
|
|
20
|
+
* directory that holds `config/config.json`.
|
|
21
|
+
*
|
|
22
|
+
* Run: `npm run install:commands -- [--home DIR] [--clients claude,grok,codex,agents] [--modules]`
|
|
18
23
|
*/
|
|
19
24
|
|
|
20
|
-
import { mkdirSync, readdirSync, rmSync, writeFileSync, realpathSync } from "fs";
|
|
25
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, realpathSync } from "fs";
|
|
21
26
|
import { homedir } from "os";
|
|
22
27
|
import { join, resolve } from "path";
|
|
23
28
|
import { pathToFileURL } from "url";
|
|
@@ -25,6 +30,8 @@ import { pathToFileURL } from "url";
|
|
|
25
30
|
import { allDefinitions } from "../src/tools/index.js";
|
|
26
31
|
import {
|
|
27
32
|
commandFileName,
|
|
33
|
+
moduleCommandFileName,
|
|
34
|
+
MODULE_STUB_MARKER,
|
|
28
35
|
renderCommandMarkdown,
|
|
29
36
|
renderClaudeCommandMarkdown,
|
|
30
37
|
renderCodexSkillMarkdown,
|
|
@@ -63,7 +70,7 @@ const DEFAULT_CLIENTS = ["claude", "grok", "codex"];
|
|
|
63
70
|
* Parse CLI flags. Unknown flags throw.
|
|
64
71
|
*
|
|
65
72
|
* @param {string[]} argv - Arguments after the script name.
|
|
66
|
-
* @returns {{home?: string, clients: string[], help?: boolean}}
|
|
73
|
+
* @returns {{home?: string, clients: string[], modules?: boolean, help?: boolean}}
|
|
67
74
|
*/
|
|
68
75
|
export function parseArgs(argv) {
|
|
69
76
|
const out = { clients: [...DEFAULT_CLIENTS] };
|
|
@@ -73,6 +80,10 @@ export function parseArgs(argv) {
|
|
|
73
80
|
out.help = true;
|
|
74
81
|
continue;
|
|
75
82
|
}
|
|
83
|
+
if (a === "--modules") {
|
|
84
|
+
out.modules = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
76
87
|
if (a === "--home") {
|
|
77
88
|
out.home = argv[++i];
|
|
78
89
|
if (!out.home) throw new Error("--home requires a directory");
|
|
@@ -103,6 +114,55 @@ function splitClients(raw) {
|
|
|
103
114
|
return names;
|
|
104
115
|
}
|
|
105
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Decide which module tools get a stub. A name that matches a built-in command,
|
|
119
|
+
* or that two module tools share, is refused so no command is shadowed.
|
|
120
|
+
*
|
|
121
|
+
* @param {Array<object>} moduleDefinitions - Definitions from live discovery.
|
|
122
|
+
* @param {Array<object>} [definitions] - Built-in definitions.
|
|
123
|
+
* @param {Map<string, {namespace: string, alias: string}>} [parts] - Configured
|
|
124
|
+
* namespace and alias per tool name, so a `__` inside either is not misread.
|
|
125
|
+
* @returns {{stubs: Array<{file: string, def: object}>, refused: Array<{name: string, file: ?string, reason: string}>}}
|
|
126
|
+
*/
|
|
127
|
+
export function planModuleStubs(moduleDefinitions, definitions = allDefinitions, parts = new Map()) {
|
|
128
|
+
const builtIn = new Set(definitions.map(commandFileName));
|
|
129
|
+
const byFile = new Map();
|
|
130
|
+
const refused = [];
|
|
131
|
+
for (const def of moduleDefinitions) {
|
|
132
|
+
const file = moduleCommandFileName(def, parts.get(def.name));
|
|
133
|
+
if (!file) refused.push({ name: def.name, file, reason: "not a module tool name" });
|
|
134
|
+
else if (builtIn.has(file)) refused.push({ name: def.name, file, reason: "matches a built-in command" });
|
|
135
|
+
else byFile.set(file, [...(byFile.get(file) ?? []), def]);
|
|
136
|
+
}
|
|
137
|
+
const stubs = [];
|
|
138
|
+
for (const [file, defs] of byFile) {
|
|
139
|
+
if (defs.length === 1) stubs.push({ file, def: defs[0] });
|
|
140
|
+
else defs.forEach((def) => refused.push({ name: def.name, file, reason: "shared by more than one module tool" }));
|
|
141
|
+
}
|
|
142
|
+
return { stubs, refused };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Configured tool names that discovery did not return.
|
|
147
|
+
*
|
|
148
|
+
* @param {string[]} configured - Names from local policy.
|
|
149
|
+
* @param {Array<object>} discovered - Definitions from live discovery.
|
|
150
|
+
* @returns {string[]}
|
|
151
|
+
*/
|
|
152
|
+
export function missingModuleTools(configured, discovered) {
|
|
153
|
+
const found = new Set(discovered.map((def) => def.name));
|
|
154
|
+
return configured.filter((name) => !found.has(name));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Whether an installed stub was written for a module-owned tool. */
|
|
158
|
+
function isModuleStub(path) {
|
|
159
|
+
try {
|
|
160
|
+
return readFileSync(path, "utf8").includes(MODULE_STUB_MARKER);
|
|
161
|
+
} catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
106
166
|
/**
|
|
107
167
|
* Write one `drupal-*.md` per tool into each requested client directory,
|
|
108
168
|
* pruning stale stubs first. Unknown client names fail closed.
|
|
@@ -111,12 +171,23 @@ function splitClients(raw) {
|
|
|
111
171
|
* @param {string} [options.home] - Install root (default: os.homedir()).
|
|
112
172
|
* @param {string[]} [options.clients] - Subset of CLIENTS keys.
|
|
113
173
|
* @param {Array<object>} [options.definitions]
|
|
114
|
-
* @
|
|
174
|
+
* @param {Array<object>} [options.moduleDefinitions] - Module tools from live
|
|
175
|
+
* discovery. Omitted: installed module stubs are left as they are.
|
|
176
|
+
* @param {Map<string, {namespace: string, alias: string}>} [options.moduleParts]
|
|
177
|
+
* Configured namespace and alias per module tool name.
|
|
178
|
+
* @param {boolean} [options.pruneModules=true] - Remove module stubs that are
|
|
179
|
+
* not rewritten. Pass false when discovery was incomplete.
|
|
180
|
+
* @returns {Array<{client: string, dir: string, written: string[], moduleWritten: string[], catalogued?: number}>}
|
|
115
181
|
*/
|
|
116
182
|
export function install(options = {}) {
|
|
117
183
|
const home = resolve(options.home || homedir());
|
|
118
184
|
const names = options.clients || DEFAULT_CLIENTS;
|
|
119
185
|
const definitions = options.definitions || allDefinitions;
|
|
186
|
+
const withModules = Array.isArray(options.moduleDefinitions);
|
|
187
|
+
const moduleStubs = withModules
|
|
188
|
+
? planModuleStubs(options.moduleDefinitions, definitions, options.moduleParts).stubs
|
|
189
|
+
: [];
|
|
190
|
+
const pruneModules = withModules && options.pruneModules !== false;
|
|
120
191
|
|
|
121
192
|
const results = [];
|
|
122
193
|
for (const name of names) {
|
|
@@ -128,15 +199,24 @@ export function install(options = {}) {
|
|
|
128
199
|
const dir = join(home, client.rel, client.skillName);
|
|
129
200
|
rmSync(dir, { recursive: true, force: true });
|
|
130
201
|
mkdirSync(join(dir, "references"), { recursive: true });
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
202
|
+
// The Codex skill is one catalog, so module tools join it rather than
|
|
203
|
+
// getting files of their own. It is rebuilt on every run.
|
|
204
|
+
const catalog = [...definitions, ...moduleStubs.map((stub) => stub.def)];
|
|
205
|
+
writeFileSync(join(dir, "SKILL.md"), client.renderSkill(catalog));
|
|
206
|
+
writeFileSync(join(dir, "references", "tools.md"), client.renderReference(catalog));
|
|
207
|
+
results.push({
|
|
208
|
+
client: name, dir, written: ["SKILL.md", "references/tools.md"],
|
|
209
|
+
catalogued: moduleStubs.length, moduleWritten: [],
|
|
210
|
+
});
|
|
134
211
|
continue;
|
|
135
212
|
}
|
|
136
213
|
const dir = join(home, client.rel);
|
|
137
214
|
mkdirSync(dir, { recursive: true });
|
|
138
215
|
for (const f of readdirSync(dir)) {
|
|
139
|
-
if (
|
|
216
|
+
if (!/^drupal-.*\.md$/.test(f)) continue;
|
|
217
|
+
// Module stubs outlive a plain install; only a complete --modules run prunes them.
|
|
218
|
+
if (isModuleStub(join(dir, f)) && !pruneModules) continue;
|
|
219
|
+
rmSync(join(dir, f));
|
|
140
220
|
}
|
|
141
221
|
const written = [];
|
|
142
222
|
for (const def of definitions) {
|
|
@@ -144,12 +224,40 @@ export function install(options = {}) {
|
|
|
144
224
|
writeFileSync(join(dir, file), client.render(def));
|
|
145
225
|
written.push(file);
|
|
146
226
|
}
|
|
147
|
-
|
|
227
|
+
const moduleWritten = [];
|
|
228
|
+
for (const { file, def } of moduleStubs) {
|
|
229
|
+
writeFileSync(join(dir, file), client.render(def));
|
|
230
|
+
moduleWritten.push(file);
|
|
231
|
+
}
|
|
232
|
+
results.push({ client: name, dir, written, moduleWritten });
|
|
148
233
|
}
|
|
149
234
|
return results;
|
|
150
235
|
}
|
|
151
236
|
|
|
152
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Discover the module-owned tools the local config approves, as the local
|
|
239
|
+
* operator (no inbound principal). Sources, credentials and governance checks
|
|
240
|
+
* are the ones the server uses; a source that fails returns no tools.
|
|
241
|
+
*
|
|
242
|
+
* @returns {Promise<{definitions: Array<object>, configured: string[], parts: Map<string, {namespace: string, alias: string}>}>}
|
|
243
|
+
*/
|
|
244
|
+
export async function discoverModuleDefinitions() {
|
|
245
|
+
// Loaded on demand so a plain install needs no site config or network.
|
|
246
|
+
const { loadLocalSecrets } = await import("../src/lib/load-secrets.js");
|
|
247
|
+
const { listResolvableSiteConfigs } = await import("../src/lib/dispatch.js");
|
|
248
|
+
const { createModuleToolRegistry, configuredModuleTools } = await import("../src/lib/module-tools.js");
|
|
249
|
+
loadLocalSecrets();
|
|
250
|
+
const sites = listResolvableSiteConfigs();
|
|
251
|
+
const definitions = await createModuleToolRegistry().list({ sites, identity: null });
|
|
252
|
+
const tools = configuredModuleTools(sites);
|
|
253
|
+
return {
|
|
254
|
+
definitions,
|
|
255
|
+
configured: tools.map((tool) => tool.name),
|
|
256
|
+
parts: new Map(tools.map(({ name, namespace, alias }) => [name, { namespace, alias }])),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const HELP = `Usage: node scripts/install-commands.js [--home DIR] [--clients claude,grok,codex,agents] [--modules]
|
|
153
261
|
|
|
154
262
|
Copy generated /drupal-* command stubs (and the Codex skill) into operator
|
|
155
263
|
home directories. Does not write into a project tree. Does not write
|
|
@@ -158,6 +266,9 @@ deprecated Codex custom prompts (~/.codex/prompts).
|
|
|
158
266
|
--home DIR Install root (default: the current user's home)
|
|
159
267
|
--clients LIST Comma-separated subset of: claude, grok, codex, agents
|
|
160
268
|
(default: claude,grok,codex)
|
|
269
|
+
--modules Also write stubs for module-owned tools. Discovers them from
|
|
270
|
+
the sources in config/config.json (run from that directory).
|
|
271
|
+
Without this flag, installed module stubs are left alone.
|
|
161
272
|
`;
|
|
162
273
|
|
|
163
274
|
const invokedDirectly =
|
|
@@ -169,9 +280,35 @@ if (invokedDirectly) {
|
|
|
169
280
|
console.error(HELP);
|
|
170
281
|
process.exit(0);
|
|
171
282
|
}
|
|
283
|
+
if (opts.modules) {
|
|
284
|
+
const { definitions, configured, parts } = await discoverModuleDefinitions();
|
|
285
|
+
const missing = missingModuleTools(configured, definitions);
|
|
286
|
+
if (configured.length === 0) {
|
|
287
|
+
console.error("[install-commands] --modules: no site configures serverTools.modules; nothing to discover.");
|
|
288
|
+
}
|
|
289
|
+
if (configured.length > 0 && definitions.length === 0) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`--modules: ${configured.length} module tools are configured but no source returned any. ` +
|
|
292
|
+
"Check the source, its credentials and its governance status. Nothing was written."
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
for (const name of missing) {
|
|
296
|
+
console.error(`[install-commands] WARNING: configured but not returned by its source: ${name}`);
|
|
297
|
+
}
|
|
298
|
+
for (const r of planModuleStubs(definitions, allDefinitions, parts).refused) {
|
|
299
|
+
console.error(`[install-commands] WARNING: no stub for ${r.name}: ${r.reason}${r.file ? ` (${r.file})` : ""}`);
|
|
300
|
+
}
|
|
301
|
+
opts.moduleDefinitions = definitions;
|
|
302
|
+
opts.moduleParts = parts;
|
|
303
|
+
// An incomplete listing must not delete stubs for tools that may only be unreachable.
|
|
304
|
+
opts.pruneModules = missing.length === 0;
|
|
305
|
+
}
|
|
172
306
|
const results = install(opts);
|
|
173
307
|
for (const r of results) {
|
|
174
|
-
|
|
308
|
+
const extra = !opts.modules ? ""
|
|
309
|
+
: r.catalogued === undefined ? ` + ${r.moduleWritten.length} module stubs`
|
|
310
|
+
: ` (${r.catalogued} module tools in the catalog)`;
|
|
311
|
+
console.error(`[install-commands] wrote ${r.written.length} files${extra} to ${r.dir} (${r.client})`);
|
|
175
312
|
}
|
|
176
313
|
} catch (err) {
|
|
177
314
|
console.error(`[install-commands] ${err.message}`);
|
package/src/index.js
CHANGED
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
// Tools — aggregated (single source of truth, side-effect-free) and per-tool prompts
|
|
60
60
|
import { allDefinitions, allHandlers, definitionsByName } from "./tools/index.js";
|
|
61
61
|
import { createModuleToolRegistry, isModuleTool } from "./lib/module-tools.js";
|
|
62
|
-
import { buildToolPrompts,
|
|
62
|
+
import { buildToolPrompts, createPromptSurface } from "./lib/tool-prompts.js";
|
|
63
63
|
|
|
64
64
|
// Apply config/secrets.map (or the shipped example table) before any site
|
|
65
65
|
// resolution. MCP clients spawn this file directly; the shell launcher is
|
|
@@ -313,17 +313,14 @@ const buildConnectorServer = createConnectorServerFactory({
|
|
|
313
313
|
},
|
|
314
314
|
read: readResource,
|
|
315
315
|
},
|
|
316
|
-
prompts: {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
? getPromptMessages(name, args)
|
|
325
|
-
: getToolPromptMessages(name, args, definitionsByName),
|
|
326
|
-
},
|
|
316
|
+
prompts: createPromptSurface({
|
|
317
|
+
staticPrompts: ALL_PROMPTS,
|
|
318
|
+
discover: discoverableTools,
|
|
319
|
+
filter: (prompts, tools) => filterPromptsByPrincipal(prompts, getRequestIdentity(), tools),
|
|
320
|
+
workflowNames: WORKFLOW_PROMPT_NAMES,
|
|
321
|
+
workflowMessages: getPromptMessages,
|
|
322
|
+
definitionsByName,
|
|
323
|
+
}),
|
|
327
324
|
});
|
|
328
325
|
|
|
329
326
|
// ---------------------------------------------------------------------------
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { drupalGraphqlFetch } from "../drupal-fetch.js";
|
|
11
|
+
import { describeGraphqlErrors } from "../error-body.js";
|
|
11
12
|
import { graphqlTypeToEntity } from "./graphql-names.js";
|
|
12
13
|
|
|
13
14
|
// Introspection is intentionally nested four `ofType` levels deep: a field type
|
|
@@ -150,9 +151,16 @@ class SchemaMap {
|
|
|
150
151
|
* Build a SchemaMap from a raw introspection response.
|
|
151
152
|
* @param {object} introspection The `{ data: { __schema } }` response.
|
|
152
153
|
* @returns {SchemaMap}
|
|
154
|
+
* @throws {Error} When the response has no schema. The message carries the
|
|
155
|
+
* server's cleaned, bounded errors (see `describeGraphqlErrors`).
|
|
153
156
|
*/
|
|
154
157
|
function buildSchemaMap(introspection) {
|
|
155
|
-
const schema = introspection
|
|
158
|
+
const schema = introspection?.data?.__schema;
|
|
159
|
+
if (!schema) {
|
|
160
|
+
// A 200 with `errors` and no schema (introspection off, access denied).
|
|
161
|
+
const detail = describeGraphqlErrors(introspection?.errors) || "the response has no schema";
|
|
162
|
+
throw new Error(`GraphQL introspection failed: ${detail}`);
|
|
163
|
+
}
|
|
156
164
|
const typesByName = new Map(schema.types.map((t) => [t.name, t]));
|
|
157
165
|
const queryType = typesByName.get(schema.queryType.name);
|
|
158
166
|
const map = new SchemaMap();
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { drupalGraphqlFetch } from "../drupal-fetch.js";
|
|
15
|
+
import { describeGraphqlErrors } from "../error-body.js";
|
|
15
16
|
import { Backend } from "./backend-interface.js";
|
|
16
17
|
import { BackendCapabilityError } from "./errors.js";
|
|
17
18
|
import { loadSchemaMap } from "./graphql-schema.js";
|
|
@@ -129,14 +130,15 @@ export class GraphqlBackend extends Backend {
|
|
|
129
130
|
* them so a backend failure surfaces as an error, not a silently-empty result.
|
|
130
131
|
* @param {string} query GraphQL query document.
|
|
131
132
|
* @returns {Promise<object>} The raw `{ data, errors? }` response.
|
|
132
|
-
* @throws {Error} When the response contains GraphQL errors.
|
|
133
|
+
* @throws {Error} When the response contains GraphQL errors. The messages are
|
|
134
|
+
* cleaned and bounded (see `describeGraphqlErrors`).
|
|
133
135
|
*/
|
|
134
136
|
async _query(query) {
|
|
135
137
|
const json = await drupalGraphqlFetch(this.site, { query });
|
|
136
138
|
if (json?.errors?.length) {
|
|
137
139
|
throw new Error(
|
|
138
140
|
`GraphQL query failed on site "${this.site._name}": ` +
|
|
139
|
-
json.errors
|
|
141
|
+
describeGraphqlErrors(json.errors)
|
|
140
142
|
);
|
|
141
143
|
}
|
|
142
144
|
return json;
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import { drupalFetch } from "../drupal-fetch.js";
|
|
12
12
|
import { drupalGraphqlFetch } from "../drupal-fetch.js";
|
|
13
13
|
import { clearToken } from "../oauth.js";
|
|
14
|
+
import { describeGraphqlErrors } from "../error-body.js";
|
|
15
|
+
import { httpStatusOf } from "../error-status.js";
|
|
14
16
|
import { JsonApiBackend } from "./jsonapi.js";
|
|
15
17
|
import { GraphqlBackend } from "./graphql.js";
|
|
16
18
|
import { BackendResolutionError } from "./errors.js";
|
|
@@ -43,9 +45,11 @@ export function _clearBackendCache() {
|
|
|
43
45
|
*/
|
|
44
46
|
export function isAuthError(err) {
|
|
45
47
|
const msg = String(err?.message || "");
|
|
48
|
+
// The response status, never a bare "401" in the path or the detail (#355).
|
|
49
|
+
const status = httpStatusOf(err);
|
|
46
50
|
return (
|
|
47
|
-
|
|
48
|
-
(
|
|
51
|
+
status === 401 ||
|
|
52
|
+
(status === 403 && /token|oauth|credential|scope/i.test(msg)) ||
|
|
49
53
|
/invalid_client|invalid_grant|unauthorized_client|invalid_token/i.test(msg) ||
|
|
50
54
|
/\bunauthorized\b/i.test(msg)
|
|
51
55
|
);
|
|
@@ -121,7 +125,9 @@ async function probeProtocol(name, site) {
|
|
|
121
125
|
if (name === "graphql") {
|
|
122
126
|
const json = await drupalGraphqlFetch(site, { query: "{ __typename }" });
|
|
123
127
|
if (json && !json.errors) return { ok: true, error: null };
|
|
124
|
-
|
|
128
|
+
// The probe error ends up in the resolution error the client sees, and
|
|
129
|
+
// isAuthError() reads it: cleaned and bounded, keywords intact (#356).
|
|
130
|
+
return { ok: false, error: new Error(describeGraphqlErrors(json?.errors?.[0]) || "GraphQL probe returned errors") };
|
|
125
131
|
}
|
|
126
132
|
return { ok: false, error: new Error(`unknown backend "${name}"`) };
|
|
127
133
|
} catch (error) {
|
|
@@ -804,6 +804,8 @@ export class JsonApiBackend extends Backend {
|
|
|
804
804
|
* Describe a bundle's fields by sampling one entity and inferring attribute
|
|
805
805
|
* types from its values. JSON:API has no schema endpoint, so an empty bundle
|
|
806
806
|
* yields a `note` and empty maps.
|
|
807
|
+
* JSON:API leaves a field the account may not view out of the resource, so
|
|
808
|
+
* such a field is not in the result; an empty field keeps its key (#337).
|
|
807
809
|
* @param {string} entityType
|
|
808
810
|
* @param {string} bundle
|
|
809
811
|
* @returns {Promise<{entityType: string, bundle: string, resourceType?: string, note?: string, attributes: object, relationships: object}>}
|