drupal-mcp-connector 2.7.4 → 2.9.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/{.claude → .agents}/commands/drupal-audit-config-best-practices.md +3 -4
- package/{.claude → .agents}/commands/drupal-audit-site-health.md +3 -4
- package/{.claude → .agents}/commands/drupal-block-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-bulk-create.md +3 -4
- package/{.claude → .agents}/commands/drupal-bulk-update.md +3 -4
- package/{.claude → .agents}/commands/drupal-config-get.md +3 -4
- package/{.claude → .agents}/commands/drupal-config-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-config-set.md +3 -4
- package/{.claude → .agents}/commands/drupal-content-by-moderation-state.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-block.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-menu-link.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-node.md +6 -7
- package/{.claude → .agents}/commands/drupal-create-paragraph.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-redirect.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-translation.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-delete-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-delete-node.md +3 -4
- package/{.claude → .agents}/commands/drupal-delete-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-describe-fields.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-cache-rebuild.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-config-export.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-config-import.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-config-status.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-cron.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-module-disable.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-module-enable.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-module-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-security-updates.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-sql-query.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-status.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-updatedb.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-user-create.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-user-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-watchdog.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-create.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-delete.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-get.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-update.md +6 -7
- package/{.claude → .agents}/commands/drupal-find-orphaned-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-entity-schema.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-node.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-paragraph.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-revision.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-taxonomy-terms.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-user-by-name.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-governance-status.md +3 -4
- package/{.claude → .agents}/commands/drupal-graphql-introspect.md +3 -4
- package/{.claude → .agents}/commands/drupal-graphql.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-blocks.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-content-types.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-entity-types.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-media-types.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-menu-links.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-moderation-states.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-nodes.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-revisions.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-roles.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-sites.md +2 -3
- package/{.claude → .agents}/commands/drupal-list-translations.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-users.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-vocabularies.md +3 -4
- package/{.claude → .agents}/commands/drupal-mcp-whoami.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-404-log.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-accessibility-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-alias-coverage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-broken-embeds.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-broken-links.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-cache-config.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-config-drift.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-content-by-author.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-content-summary.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-duplicate-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-field-completeness.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-menu-integrity.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-missing-field.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-module-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-orphan-pages.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-orphaned-references.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-permission-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-pii-exposure.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-readability.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-recently-published.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-redirect-health.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-revision-hotspots.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-scheduled-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-seo-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-seo-meta-coverage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-stale-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-status-report.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-taxonomy-usage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-text-format-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-translation-coverage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-unpublished.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-user-activity.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-workflow-bottlenecks.md +3 -4
- package/{.claude → .agents}/commands/drupal-resolve-reference.md +3 -4
- package/{.claude → .agents}/commands/drupal-revert-revision.md +3 -4
- package/{.claude → .agents}/commands/drupal-schedule-publish.md +3 -4
- package/{.claude → .agents}/commands/drupal-search-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-search.md +3 -4
- package/{.claude → .agents}/commands/drupal-security-info.md +3 -4
- package/{.claude → .agents}/commands/drupal-set-moderation-state.md +3 -4
- package/{.claude → .agents}/commands/drupal-site-info.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-menu-link.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-node.md +9 -10
- package/{.claude → .agents}/commands/drupal-update-paragraph.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-redirect.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-upload-file-and-create-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-upload-file.md +3 -4
- package/CHANGELOG.md +82 -0
- package/README.md +23 -12
- package/config/config.example.json +1 -1
- package/package.json +4 -2
- package/scripts/generate-commands.js +42 -21
- package/scripts/install-commands.js +157 -0
- package/src/lib/backends/backend-interface.js +20 -1
- package/src/lib/backends/jsonapi.js +47 -4
- package/src/lib/body-summary.js +60 -0
- package/src/lib/contracts/approval.js +72 -0
- package/src/lib/contracts/decisions.js +139 -0
- package/src/lib/contracts/drupal.js +525 -0
- package/src/lib/contracts/evaluator.js +28 -0
- package/src/lib/contracts/evidence-sink.js +67 -0
- package/src/lib/contracts/fixtures.js +117 -0
- package/src/lib/contracts/index.js +46 -0
- package/src/lib/contracts/relay.js +83 -0
- package/src/lib/contracts/system-of-record.js +61 -0
- package/src/lib/contracts/types.js +242 -0
- package/src/lib/contracts/version.js +52 -0
- package/src/lib/data-flow.js +437 -0
- package/src/lib/dispatch.js +36 -15
- package/src/lib/drupal-fetch.js +52 -6
- package/src/lib/field-definition.js +285 -0
- package/src/lib/patch-preflight.js +157 -66
- package/src/lib/security.js +14 -0
- package/src/lib/server-tools.js +9 -0
- package/src/lib/write-revision.js +82 -2
- package/src/tools/bulk.js +4 -3
- package/src/tools/entities.js +11 -7
- package/src/tools/index.js +3 -2
- package/src/tools/nodes.js +55 -43
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
Built by **Jeremy Michael Cerda** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
|
|
11
11
|
|
|
12
|
-
**If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to
|
|
12
|
+
**If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to the current release (2.6.1 first fixed this), or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
@@ -49,7 +49,9 @@ Each site declares which backend(s) it exposes via the `api` key:
|
|
|
49
49
|
- **Writes go through JSON:API.** Use a JSON:API-enabled site as the write plane; keep GraphQL as a read plane where that suits your architecture.
|
|
50
50
|
- **`defaultTextFormat` sets the body text format** used by the `body` convenience
|
|
51
51
|
parameter on node writes, e.g. `{ "baseUrl": "…", "defaultTextFormat": "basic_html" }`.
|
|
52
|
-
Individual calls can override it with `format`.
|
|
52
|
+
Individual calls can override it with `format`. Field API `allowed_formats` wins
|
|
53
|
+
when it can be resolved: a single allowed format is the default, and a format
|
|
54
|
+
outside the list is refused. Without a resolved list, the connector falls
|
|
53
55
|
back to `full_html`, which many governed sites deliberately do not define — and which
|
|
54
56
|
is the most permissive core format, so setting this per site is the safer posture. A
|
|
55
57
|
text format is Drupal's HTML-filtering boundary, so it is worth choosing deliberately
|
|
@@ -119,20 +121,27 @@ governed call to that tool. These are protocol-native, so they work everywhere t
|
|
|
119
121
|
prompts capability is supported — the client renders them per its own convention
|
|
120
122
|
(e.g. Claude Code shows `/mcp__drupal__drupal-create-node`).
|
|
121
123
|
|
|
122
|
-
####
|
|
123
|
-
|
|
124
|
-
connector
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
#### Filesystem slash commands (`/drupal-*`)
|
|
125
|
+
MCP prompts (above) are the protocol-native slash surface. For the literal bare
|
|
126
|
+
`/drupal-<tool>` form, the connector ships generated stubs under
|
|
127
|
+
**`.agents/commands/`** — a harness-agnostic tree, not a vendor rule directory.
|
|
128
|
+
Clients that scan `.agents/commands/` (including Grok Build when this repo is
|
|
129
|
+
the project) pick them up automatically.
|
|
130
|
+
|
|
131
|
+
Clients that only scan a vendor home path (Claude Code `~/.claude/commands`,
|
|
132
|
+
Grok `~/.grok/commands`) install from that tree into the **operator home**, so
|
|
133
|
+
no consuming project has to commit `.claude/` or `.grok/`:
|
|
127
134
|
|
|
128
135
|
```bash
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
#
|
|
136
|
+
npm run generate:commands # refresh .agents/commands/ (from a clone)
|
|
137
|
+
npm run install:commands # copies into ~/.claude/commands and ~/.grok/commands
|
|
138
|
+
# npm run install:commands -- --clients claude
|
|
139
|
+
# npm run install:commands -- --home /path/to/fake-home # tests / CI
|
|
132
140
|
```
|
|
133
141
|
|
|
134
|
-
|
|
135
|
-
|
|
142
|
+
From an npm install, the same files ship at
|
|
143
|
+
`node_modules/drupal-mcp-connector/.agents/commands/`. Do not copy them into an
|
|
144
|
+
application repo's `.claude/` or `.grok/` directory.
|
|
136
145
|
|
|
137
146
|
### Security Model
|
|
138
147
|
|
|
@@ -161,6 +170,7 @@ Additional connector-side gates (2.1+ / 2.2+):
|
|
|
161
170
|
- **Uploads** only from `MCP_UPLOAD_ROOT` (or the process cwd); sensitive paths (`.env*`, `.ssh`, connector `config.json`) are refused.
|
|
162
171
|
- **HTTPS:** non-loopback binds require an inbound OAuth resource server (`auth.issuer` + `auth.audience`), not `MCP_AUTH_TOKEN`. `MCP_AUTH_TOKEN` remains valid on loopback. `MCP_ALLOW_UNAUTHENTICATED=1` is only for a trusted proxy. Non-loopback TLS defaults to 120 req/min rate limiting. Inbound JWT scopes (and optional `auth.grants`) filter which tools, resources, prompts, and sites a principal can discover or invoke.
|
|
163
172
|
- **GraphQL is off by default.** `drupal_graphql` / introspect require `security.allowGraphql` (true only on the `development` preset). Raw GraphQL results still bypass entity allowlists and field redaction — prefer JSON:API entity tools when connector policy must hold. Mutations also need `allowGraphqlMutations`.
|
|
173
|
+
- **Northbound data-flow budgets (2.8+):** row, byte, page, request, and chained-action counters keyed by inbound principal + authoritative target (same finite defaults as MCP Sentinel), so pagination, retries, batching, or a new chain id cannot reset them. Governed reads carry the declared-ceiling / declared-destination wire contract; tune with `security.declaredCeiling` and `security.readBudgets`.
|
|
164
174
|
|
|
165
175
|
Full detail: **[docs/security.md](docs/security.md)** and **[docs/security-hardening.md](docs/security-hardening.md)**.
|
|
166
176
|
|
|
@@ -298,6 +308,7 @@ an operator channel: keep the agent's credentials off it, and pin
|
|
|
298
308
|
| [Threat Model](docs/threat-model.md) | Trust boundaries, threats & mitigations, residual risks, and the security-pass results |
|
|
299
309
|
| [Deployment](docs/deployment.md) | Run the HTTPS transport in production: Docker, systemd, launchd, reverse proxy, pre-exposure checklist |
|
|
300
310
|
| [Integration Contract](docs/integration-contract.md) | The connector ↔ Drupal-governance contract (identity, OAuth scopes, compatibility) |
|
|
311
|
+
| [Adapter Contracts](docs/adapter-contracts.md) | Provider-neutral evaluator / relay / approval / evidence / SoR contracts and the Drupal conformance kit |
|
|
301
312
|
| [Versioning & Stability](docs/versioning.md) | Semver policy: the stable surface, deprecation process, MCP protocol + Node support |
|
|
302
313
|
| [Whitepaper](docs/whitepaper.md) | Vision, personas, and use cases |
|
|
303
314
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
|
|
9
9
|
"_security_options": {
|
|
10
|
-
"_comment": "apiTokenEnv / oauth.clientSecretEnv: read credentials from the environment, never from this file. requireSecureAuth: reject anon/basic, require HTTPS+Bearer — set it on every remote site. requireGovernance: deny every governed path unless the source governance contract (GET /drupal-mcp/readiness, mcp_sentinel) verifies — no ungoverned JSON:API/GraphQL fallback. Env overrides: MCP_CLIENT_ID overrides or disables the outbound identity header; MCP_BIND_HOST restricts the listen interface (with TLS). Network-facing HTTPS /mcp requires the top-level auth block (issuer + audience), not MCP_AUTH_TOKEN. MCP_AUTH_TOKEN remains valid only on loopback. See docs/security-hardening.md."
|
|
10
|
+
"_comment": "apiTokenEnv / oauth.clientSecretEnv: read credentials from the environment, never from this file. requireSecureAuth: reject anon/basic, require HTTPS+Bearer — set it on every remote site. requireGovernance: deny every governed path unless the source governance contract (GET /drupal-mcp/readiness, mcp_sentinel) verifies — no ungoverned JSON:API/GraphQL fallback. Env overrides: MCP_CLIENT_ID overrides or disables the outbound identity header; MCP_BIND_HOST restricts the listen interface (with TLS). Network-facing HTTPS /mcp requires the top-level auth block (issuer + audience), not MCP_AUTH_TOKEN. MCP_AUTH_TOKEN remains valid only on loopback. Optional security.declaredCeiling (narrow-only X-MCP-Declared-Ceiling) and security.readBudgets (same finite classes as mcp_sentinel) bind northbound data-flow to the inbound principal and target. See docs/security.md and docs/security-hardening.md."
|
|
11
11
|
},
|
|
12
12
|
|
|
13
13
|
"_governance_tiers": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.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",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"src/",
|
|
13
13
|
"scripts/generate-commands.js",
|
|
14
|
-
"
|
|
14
|
+
"scripts/install-commands.js",
|
|
15
|
+
".agents/commands/",
|
|
15
16
|
"config/config.example.json",
|
|
16
17
|
"README.md",
|
|
17
18
|
"CHANGELOG.md",
|
|
@@ -50,6 +51,7 @@
|
|
|
50
51
|
"start:https": "MCP_TRANSPORT=https node src/index.js",
|
|
51
52
|
"start:dev": "MCP_TRANSPORT=https MCP_ALLOW_HTTP=1 MCP_PORT=3443 node src/index.js",
|
|
52
53
|
"generate:commands": "node scripts/generate-commands.js",
|
|
54
|
+
"install:commands": "node scripts/install-commands.js",
|
|
53
55
|
"lint": "eslint src/",
|
|
54
56
|
"lint:fix": "eslint src/ --fix",
|
|
55
57
|
"test": "vitest run",
|
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* generate-commands.js — generate
|
|
3
|
+
* generate-commands.js — generate harness-agnostic slash-command stubs for every tool.
|
|
4
4
|
*
|
|
5
|
-
* Writes one `.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* tool's parameters before making a single call.
|
|
5
|
+
* Writes one `.agents/commands/drupal-<tool>.md` per Drupal tool. The files use
|
|
6
|
+
* protocol tool names (`drupal_list_nodes`) so any MCP client can consume them;
|
|
7
|
+
* they are not a vendor rule tree. Clients that scan `.agents/commands/` (Grok
|
|
8
|
+
* Build, when this repo is the project) pick them up as `/drupal-<tool>`.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* instructions for any agent live only in AGENTS.md.
|
|
10
|
+
* Clients that only scan a vendor home directory (Claude Code `~/.claude/commands`,
|
|
11
|
+
* Grok `~/.grok/commands`) get the same files via `npm run install:commands`.
|
|
14
12
|
*
|
|
15
13
|
* Driven from the same tool definitions as the server (src/tools/index.js), so the
|
|
16
14
|
* command set never drifts from the tools. Run: `npm run generate:commands`.
|
|
17
15
|
*
|
|
18
|
-
* Exports `renderCommandMarkdown`, `
|
|
19
|
-
* file-writing side effect runs only
|
|
16
|
+
* Exports `renderCommandMarkdown`, `renderClaudeCommandMarkdown`, `commandFileName`,
|
|
17
|
+
* `COMMANDS_DIR`, and `generate` for tests; the file-writing side effect runs only
|
|
18
|
+
* when executed directly.
|
|
20
19
|
*/
|
|
21
20
|
|
|
22
21
|
import { mkdirSync, readdirSync, rmSync, writeFileSync, realpathSync } from "fs";
|
|
@@ -26,7 +25,8 @@ import { allDefinitions } from "../src/tools/index.js";
|
|
|
26
25
|
import { paramList, toolNameToPromptName } from "../src/lib/tool-prompts.js";
|
|
27
26
|
import { isDestructiveTool } from "../src/lib/operations.js";
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+
/** Canonical, harness-agnostic command tree shipped in the repo and the npm package. */
|
|
29
|
+
export const COMMANDS_DIR = new URL("../.agents/commands/", import.meta.url);
|
|
30
30
|
|
|
31
31
|
/** Map a tool definition to its command filename: `drupal_create_node` → `drupal-create-node.md`. */
|
|
32
32
|
export function commandFileName(def) {
|
|
@@ -49,19 +49,26 @@ function argumentHint(params) {
|
|
|
49
49
|
* Render the markdown for one tool's slash command.
|
|
50
50
|
*
|
|
51
51
|
* @param {object} def - The tool definition ({name, description, inputSchema}).
|
|
52
|
+
* @param {object} [options]
|
|
53
|
+
* @param {string} [options.allowedTools] - Optional Claude Code `allowed-tools` value.
|
|
54
|
+
* @param {string} [options.argumentsPhrase="the arguments supplied with this command"]
|
|
55
|
+
* Phrase used in the parse-arguments instruction. Claude Code install rewrites
|
|
56
|
+
* this to `` `$ARGUMENTS` `` because that client substitutes the placeholder.
|
|
52
57
|
* @returns {string} File contents (ends with a trailing newline).
|
|
53
58
|
*/
|
|
54
|
-
export function renderCommandMarkdown(def) {
|
|
59
|
+
export function renderCommandMarkdown(def, options = {}) {
|
|
55
60
|
const params = paramList(def.inputSchema);
|
|
56
61
|
const required = params.filter((p) => p.required);
|
|
57
62
|
const optional = params.filter((p) => !p.required);
|
|
58
63
|
const line = (p) => `- \`${p.name}\` (${p.hint})${p.description ? `: ${p.description}` : ""}`;
|
|
64
|
+
const argumentsPhrase = options.argumentsPhrase ?? "the arguments supplied with this command";
|
|
59
65
|
|
|
60
66
|
const frontmatter = ["---", `description: ${yamlString(def.description)}`];
|
|
61
67
|
if (params.length) frontmatter.push(`argument-hint: ${yamlString(argumentHint(params))}`);
|
|
62
|
-
frontmatter.push(`allowed-tools:
|
|
68
|
+
if (options.allowedTools) frontmatter.push(`allowed-tools: ${options.allowedTools}`);
|
|
69
|
+
frontmatter.push("---");
|
|
63
70
|
|
|
64
|
-
const body = [`Call the
|
|
71
|
+
const body = [`Call the MCP tool \`${def.name}\`.`, "", def.description];
|
|
65
72
|
|
|
66
73
|
if (isDestructiveTool(def.name)) {
|
|
67
74
|
body.push("", "> ⚠ **Destructive** — this permanently changes or deletes data. Confirm with the user before calling.");
|
|
@@ -69,9 +76,9 @@ export function renderCommandMarkdown(def) {
|
|
|
69
76
|
|
|
70
77
|
body.push("");
|
|
71
78
|
if (params.length === 0) {
|
|
72
|
-
body.push("This tool takes no arguments — call it directly
|
|
79
|
+
body.push("This tool takes no arguments — call it directly.");
|
|
73
80
|
} else {
|
|
74
|
-
body.push(
|
|
81
|
+
body.push(`Parse ${argumentsPhrase} into this tool's parameters:`, "");
|
|
75
82
|
if (required.length) {
|
|
76
83
|
body.push("**Required:**");
|
|
77
84
|
required.forEach((p) => body.push(line(p)));
|
|
@@ -83,15 +90,29 @@ export function renderCommandMarkdown(def) {
|
|
|
83
90
|
body.push("");
|
|
84
91
|
}
|
|
85
92
|
body.push(
|
|
86
|
-
"If a required parameter is missing
|
|
87
|
-
"
|
|
88
|
-
"
|
|
93
|
+
"If a required parameter is missing, ask before calling — do not invent values. " +
|
|
94
|
+
"Coerce each value to its JSON type (booleans → true/false, numbers → numeric, " +
|
|
95
|
+
"object/array → parse JSON), then make the single tool call and summarize the result."
|
|
89
96
|
);
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
return `${frontmatter.join("\n")}\n\n${body.join("\n")}\n`;
|
|
93
100
|
}
|
|
94
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Claude Code adapter: same stub plus `allowed-tools` scoped to that client's
|
|
104
|
+
* MCP tool name, and `$ARGUMENTS` so typed `/drupal-*` args are not dropped.
|
|
105
|
+
*
|
|
106
|
+
* @param {object} def - The tool definition.
|
|
107
|
+
* @returns {string} File contents.
|
|
108
|
+
*/
|
|
109
|
+
export function renderClaudeCommandMarkdown(def) {
|
|
110
|
+
return renderCommandMarkdown(def, {
|
|
111
|
+
allowedTools: `mcp__drupal__${def.name}`,
|
|
112
|
+
argumentsPhrase: "`$ARGUMENTS`",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
95
116
|
/**
|
|
96
117
|
* Write one command file per tool, pruning stale `drupal-*.md` first so removed
|
|
97
118
|
* tools leave no orphans.
|
|
@@ -118,5 +139,5 @@ const invokedDirectly =
|
|
|
118
139
|
process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
|
|
119
140
|
if (invokedDirectly) {
|
|
120
141
|
const written = generate();
|
|
121
|
-
console.error(`[generate-commands] wrote ${written.length} command files to .
|
|
142
|
+
console.error(`[generate-commands] wrote ${written.length} command files to .agents/commands/`);
|
|
122
143
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* install-commands.js — copy generated `/drupal-*` stubs into an operator's
|
|
4
|
+
* client home directories.
|
|
5
|
+
*
|
|
6
|
+
* The canonical files live in `.agents/commands/` (in-repo, harness-agnostic).
|
|
7
|
+
* Claude Code and Grok only auto-load project commands from vendor paths
|
|
8
|
+
* (`.claude/commands/`, `.grok/commands/`). This script writes the *user-level*
|
|
9
|
+
* copies so `/drupal-*` works in every project without committing a vendor
|
|
10
|
+
* folder to this repo or to a consuming application.
|
|
11
|
+
*
|
|
12
|
+
* Default targets: `~/.claude/commands` (Claude adapter) and `~/.grok/commands`
|
|
13
|
+
* (canonical files). Pass `--clients` to subset. Never writes into a project
|
|
14
|
+
* tree.
|
|
15
|
+
*
|
|
16
|
+
* Run: `npm run install:commands -- [--home DIR] [--clients claude,grok,agents]`
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdirSync, readdirSync, rmSync, writeFileSync, realpathSync } from "fs";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
import { join, resolve } from "path";
|
|
22
|
+
import { pathToFileURL } from "url";
|
|
23
|
+
|
|
24
|
+
import { allDefinitions } from "../src/tools/index.js";
|
|
25
|
+
import {
|
|
26
|
+
commandFileName,
|
|
27
|
+
renderCommandMarkdown,
|
|
28
|
+
renderClaudeCommandMarkdown,
|
|
29
|
+
} from "./generate-commands.js";
|
|
30
|
+
|
|
31
|
+
/** Whitelisted install targets. `rel` is under `--home` (default: os.homedir()). */
|
|
32
|
+
export const CLIENTS = {
|
|
33
|
+
claude: {
|
|
34
|
+
rel: ".claude/commands",
|
|
35
|
+
render: renderClaudeCommandMarkdown,
|
|
36
|
+
},
|
|
37
|
+
grok: {
|
|
38
|
+
rel: ".grok/commands",
|
|
39
|
+
render: renderCommandMarkdown,
|
|
40
|
+
},
|
|
41
|
+
agents: {
|
|
42
|
+
rel: ".agents/commands",
|
|
43
|
+
render: renderCommandMarkdown,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const DEFAULT_CLIENTS = ["claude", "grok"];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse CLI flags. Unknown flags throw.
|
|
51
|
+
*
|
|
52
|
+
* @param {string[]} argv - Arguments after the script name.
|
|
53
|
+
* @returns {{home?: string, clients: string[], help?: boolean}}
|
|
54
|
+
*/
|
|
55
|
+
export function parseArgs(argv) {
|
|
56
|
+
const out = { clients: [...DEFAULT_CLIENTS] };
|
|
57
|
+
for (let i = 0; i < argv.length; i++) {
|
|
58
|
+
const a = argv[i];
|
|
59
|
+
if (a === "--help" || a === "-h") {
|
|
60
|
+
out.help = true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (a === "--home") {
|
|
64
|
+
out.home = argv[++i];
|
|
65
|
+
if (!out.home) throw new Error("--home requires a directory");
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (a.startsWith("--home=")) {
|
|
69
|
+
out.home = a.slice("--home=".length);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (a === "--clients") {
|
|
73
|
+
const raw = argv[++i];
|
|
74
|
+
if (!raw) throw new Error("--clients requires a comma-separated list");
|
|
75
|
+
out.clients = splitClients(raw);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (a.startsWith("--clients=")) {
|
|
79
|
+
out.clients = splitClients(a.slice("--clients=".length));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`Unknown argument: ${a}`);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function splitClients(raw) {
|
|
88
|
+
const names = String(raw).split(",").map((s) => s.trim()).filter(Boolean);
|
|
89
|
+
if (!names.length) throw new Error("--clients requires at least one client");
|
|
90
|
+
return names;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Write one `drupal-*.md` per tool into each requested client directory,
|
|
95
|
+
* pruning stale stubs first. Unknown client names fail closed.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} [options]
|
|
98
|
+
* @param {string} [options.home] - Install root (default: os.homedir()).
|
|
99
|
+
* @param {string[]} [options.clients] - Subset of CLIENTS keys.
|
|
100
|
+
* @param {Array<object>} [options.definitions]
|
|
101
|
+
* @returns {Array<{client: string, dir: string, written: string[]}>}
|
|
102
|
+
*/
|
|
103
|
+
export function install(options = {}) {
|
|
104
|
+
const home = resolve(options.home || homedir());
|
|
105
|
+
const names = options.clients || DEFAULT_CLIENTS;
|
|
106
|
+
const definitions = options.definitions || allDefinitions;
|
|
107
|
+
|
|
108
|
+
const results = [];
|
|
109
|
+
for (const name of names) {
|
|
110
|
+
const client = CLIENTS[name];
|
|
111
|
+
if (!client) {
|
|
112
|
+
throw new Error(`Unknown client "${name}". Allowed: ${Object.keys(CLIENTS).join(", ")}`);
|
|
113
|
+
}
|
|
114
|
+
const dir = join(home, client.rel);
|
|
115
|
+
mkdirSync(dir, { recursive: true });
|
|
116
|
+
for (const f of readdirSync(dir)) {
|
|
117
|
+
if (/^drupal-.*\.md$/.test(f)) rmSync(join(dir, f));
|
|
118
|
+
}
|
|
119
|
+
const written = [];
|
|
120
|
+
for (const def of definitions) {
|
|
121
|
+
const file = commandFileName(def);
|
|
122
|
+
writeFileSync(join(dir, file), client.render(def));
|
|
123
|
+
written.push(file);
|
|
124
|
+
}
|
|
125
|
+
results.push({ client: name, dir, written });
|
|
126
|
+
}
|
|
127
|
+
return results;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const HELP = `Usage: node scripts/install-commands.js [--home DIR] [--clients claude,grok,agents]
|
|
131
|
+
|
|
132
|
+
Copy generated /drupal-* command stubs into operator home directories.
|
|
133
|
+
Does not write into a project tree.
|
|
134
|
+
|
|
135
|
+
--home DIR Install root (default: the current user's home)
|
|
136
|
+
--clients LIST Comma-separated subset of: claude, grok, agents
|
|
137
|
+
(default: claude,grok)
|
|
138
|
+
`;
|
|
139
|
+
|
|
140
|
+
const invokedDirectly =
|
|
141
|
+
process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
|
|
142
|
+
if (invokedDirectly) {
|
|
143
|
+
try {
|
|
144
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
145
|
+
if (opts.help) {
|
|
146
|
+
console.error(HELP);
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
const results = install(opts);
|
|
150
|
+
for (const r of results) {
|
|
151
|
+
console.error(`[install-commands] wrote ${r.written.length} files to ${r.dir} (${r.client})`);
|
|
152
|
+
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.error(`[install-commands] ${err.message}`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -93,7 +93,10 @@ export class Backend {
|
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
95
|
* Update an entity.
|
|
96
|
-
* @param {{entityType: string, bundle: string, id: string, attributes?: object, relationships?: object}} _input
|
|
96
|
+
* @param {{entityType: string, bundle: string, id: string, attributes?: object, relationships?: object, resourceVersion?: string}} _input
|
|
97
|
+
* `resourceVersion` is a JSON:API revision selector (`rel:working-copy`,
|
|
98
|
+
* `rel:latest-version`, `id:<vid>`). Backends that cannot address revisions
|
|
99
|
+
* ignore it.
|
|
97
100
|
* @returns {Promise<import("../canonical.js").CanonicalEntity>}
|
|
98
101
|
*/
|
|
99
102
|
async updateEntity(_input) { return notImplemented("updateEntity"); }
|
|
@@ -139,6 +142,22 @@ export class Backend {
|
|
|
139
142
|
*/
|
|
140
143
|
async getEntitySchema(_entityType, _bundle) { return notImplemented("getEntitySchema"); }
|
|
141
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Resolve Field API metadata for one field (internal introspection).
|
|
147
|
+
*
|
|
148
|
+
* `getEntitySchema` is sampling-only and does NOT expose `allowed_formats`.
|
|
149
|
+
* The JSON:API adapter reads `field_config` via an internal fetch — not via
|
|
150
|
+
* agent entity tools (`field_config` is on the agent deny list). Returns
|
|
151
|
+
* null when the definition cannot be resolved. Callers must not invent
|
|
152
|
+
* `allowed_formats` from `defaultTextFormat` or `full_html`.
|
|
153
|
+
*
|
|
154
|
+
* Default: unknown. GraphQL is read-only for writes; write sites use JSON:API.
|
|
155
|
+
*
|
|
156
|
+
* @param {{entityType: string, bundle: string, fieldName: string}} _ref
|
|
157
|
+
* @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
|
|
158
|
+
*/
|
|
159
|
+
async getFieldDefinition(_ref) { return null; }
|
|
160
|
+
|
|
142
161
|
/**
|
|
143
162
|
* List user roles.
|
|
144
163
|
* @returns {Promise<Array<{id: string, machineName: string, label: string, weight: number}>>}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { drupalFetch, drupalUploadFile } from "../drupal-fetch.js";
|
|
11
11
|
import { validateUuid, validateMachineName } from "../validate.js";
|
|
12
|
+
import { parseFieldConfigObject } from "../field-definition.js";
|
|
12
13
|
import { Backend } from "./backend-interface.js";
|
|
13
14
|
import {
|
|
14
15
|
makeCanonicalEntity,
|
|
@@ -208,8 +209,10 @@ export class JsonApiBackend extends Backend {
|
|
|
208
209
|
if (BASE_ATTRIBUTE_FIELDS.includes(k)) return false;
|
|
209
210
|
if (INTERNAL_ATTR_RE.test(k)) {
|
|
210
211
|
// Paragraph ERR attach needs the current revision id (#192).
|
|
212
|
+
// Node / revisionable writes need the working vs live vid (#166).
|
|
211
213
|
// Other drupal_internal__* attributes stay stripped.
|
|
212
|
-
return
|
|
214
|
+
return k === "drupal_internal__vid"
|
|
215
|
+
|| (entityType === "paragraph" && k === "drupal_internal__revision_id");
|
|
213
216
|
}
|
|
214
217
|
return true;
|
|
215
218
|
})
|
|
@@ -339,17 +342,24 @@ export class JsonApiBackend extends Backend {
|
|
|
339
342
|
/**
|
|
340
343
|
* Update an entity via JSON:API PATCH. Retries without `status` on moderated
|
|
341
344
|
* bundles — see writeWithModerationFallback.
|
|
342
|
-
* @param {{entityType: string, bundle: string, id: string, attributes?: object, relationships?: object}} input
|
|
345
|
+
* @param {{entityType: string, bundle: string, id: string, attributes?: object, relationships?: object, resourceVersion?: string}} input
|
|
346
|
+
* `resourceVersion` is a JSON:API revision selector (e.g. `rel:working-copy`).
|
|
347
|
+
* When set, it is appended so a forward revision is PATCHed instead of the
|
|
348
|
+
* canonical default (#166 / Drupal #2795279).
|
|
343
349
|
* @returns {Promise<import("../canonical.js").CanonicalEntity>} The updated entity.
|
|
344
350
|
*/
|
|
345
|
-
async updateEntity({ entityType, bundle, id, attributes = {}, relationships }) {
|
|
351
|
+
async updateEntity({ entityType, bundle, id, attributes = {}, relationships, resourceVersion }) {
|
|
346
352
|
validateUuid(id);
|
|
347
353
|
const buildPayload = (attrs) => {
|
|
348
354
|
const payload = { data: { type: `${entityType}--${bundle}`, id, attributes: attrs } };
|
|
349
355
|
if (relationships) payload.data.relationships = relationships;
|
|
350
356
|
return payload;
|
|
351
357
|
};
|
|
352
|
-
|
|
358
|
+
let path = `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
|
|
359
|
+
if (resourceVersion) {
|
|
360
|
+
path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
|
|
361
|
+
}
|
|
362
|
+
const data = await this.writeWithModerationFallback(path, "PATCH", buildPayload, attributes);
|
|
353
363
|
return this.toCanonical(data.data);
|
|
354
364
|
}
|
|
355
365
|
|
|
@@ -512,6 +522,39 @@ export class JsonApiBackend extends Backend {
|
|
|
512
522
|
});
|
|
513
523
|
}
|
|
514
524
|
|
|
525
|
+
/**
|
|
526
|
+
* Read Field API metadata from JSON:API `field_config` (chain step 1).
|
|
527
|
+
*
|
|
528
|
+
* This is an internal introspection call, not an agent entity-tool read:
|
|
529
|
+
* `field_config` is on the connector deny list. Do not invent
|
|
530
|
+
* `allowed_formats` from `this.site.defaultTextFormat` or `full_html`.
|
|
531
|
+
* Empty / unreadable `field_config` returns null so the caller can try
|
|
532
|
+
* Drush `config:get field.field.{entityType}.{bundle}.{field}` and, if
|
|
533
|
+
* that also fails, keep the historical default chain only while the list
|
|
534
|
+
* is unknown.
|
|
535
|
+
*
|
|
536
|
+
* @param {{entityType: string, bundle: string, fieldName: string}} ref
|
|
537
|
+
* @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
|
|
538
|
+
*/
|
|
539
|
+
async getFieldDefinition({ entityType, bundle, fieldName }) {
|
|
540
|
+
validateMachineName(entityType, "entityType");
|
|
541
|
+
validateMachineName(bundle, "bundle");
|
|
542
|
+
validateMachineName(fieldName, "fieldName");
|
|
543
|
+
const params = new URLSearchParams();
|
|
544
|
+
params.set("filter[entity_type]", entityType);
|
|
545
|
+
params.set("filter[bundle]", bundle);
|
|
546
|
+
params.set("filter[field_name]", fieldName);
|
|
547
|
+
params.set("page[limit]", "1");
|
|
548
|
+
let data;
|
|
549
|
+
try {
|
|
550
|
+
data = await drupalFetch(this.site, `/jsonapi/field_config/field_config?${params}`);
|
|
551
|
+
} catch {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
const row = Array.isArray(data?.data) ? data.data[0] : data?.data;
|
|
555
|
+
return parseFieldConfigObject(row?.attributes, fieldName);
|
|
556
|
+
}
|
|
557
|
+
|
|
515
558
|
/**
|
|
516
559
|
* Describe a bundle's fields by sampling one entity and inferring attribute
|
|
517
560
|
* types from its values. JSON:API has no schema endpoint, so an empty bundle
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide whether the `summary` argument may be written onto `body`.
|
|
3
|
+
*
|
|
4
|
+
* Core `text_with_summary` stores a `summary` property. `text_long` and
|
|
5
|
+
* sampled `text_formatted` do not — Drupal drops an unknown property, so a
|
|
6
|
+
* silent write would lose the teaser. Fail closed when the sampled schema
|
|
7
|
+
* cannot confirm the property exists (#163).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Warning attached when `summary` is written onto a text_with_summary body. */
|
|
11
|
+
export const SUMMARY_DEPRECATED_WARNING = {
|
|
12
|
+
code: "summary_parameter_deprecated",
|
|
13
|
+
message:
|
|
14
|
+
"The `summary` argument writes body.summary on core text_with_summary. " +
|
|
15
|
+
"Prefer setting the site's dedicated deck/summary field via `fields`.",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} backend Backend with `getEntitySchema`.
|
|
20
|
+
* @param {string} bundle Node bundle machine name.
|
|
21
|
+
* @param {string|undefined} summary Caller-supplied summary; undefined = omitted.
|
|
22
|
+
* @returns {Promise<{deprecated: boolean}>} Whether a deprecation notice applies.
|
|
23
|
+
* @throws {Error} When `summary` was supplied and the body field has no
|
|
24
|
+
* summary property, or the schema cannot be determined.
|
|
25
|
+
*/
|
|
26
|
+
export async function assertBodySummaryWritable(backend, bundle, summary) {
|
|
27
|
+
if (summary === undefined) return { deprecated: false };
|
|
28
|
+
|
|
29
|
+
let schema;
|
|
30
|
+
try {
|
|
31
|
+
schema = await backend.getEntitySchema("node", bundle);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Cannot write \`summary\`: the body field schema for node.${bundle} could not be determined (${detail}). Set the site's dedicated deck field via \`fields\` instead.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const bodyType = schema?.attributes?.body;
|
|
40
|
+
if (bodyType === "text_with_summary") {
|
|
41
|
+
return { deprecated: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sampled = bodyType ? ` (sampled as ${bodyType})` : "";
|
|
45
|
+
throw new Error(
|
|
46
|
+
`This site's body field has no summary property${sampled}; set the site's deck field via \`fields\`. The \`summary\` argument is only valid for core text_with_summary body fields.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Attach the `summary` deprecation notice to a write (or dryRun) result.
|
|
52
|
+
*
|
|
53
|
+
* @param {object|null|undefined} result Tool response.
|
|
54
|
+
* @returns {object|null|undefined} Result with `_warnings` when result is an object.
|
|
55
|
+
*/
|
|
56
|
+
export function attachSummaryDeprecation(result) {
|
|
57
|
+
if (!result || typeof result !== "object") return result;
|
|
58
|
+
const existing = Array.isArray(result._warnings) ? result._warnings : [];
|
|
59
|
+
return { ...result, _warnings: [...existing, SUMMARY_DEPRECATED_WARNING] };
|
|
60
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval-interface contract (#181).
|
|
3
|
+
*
|
|
4
|
+
* An approval binds to an action-manifest digest and a single actor. It is
|
|
5
|
+
* one-use. Replay, digest mismatch, or actor mismatch invalidate it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { ContractError, REASON } from "./decisions.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} ApprovalInterface
|
|
13
|
+
* @property {(manifest: object, actor?: string) => {approvalId: string, digest: string}} issue
|
|
14
|
+
* @property {(approvalId: string, digest: string, actor?: string) => {approvalId: string, digest: string}} consume
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* In-process one-use approval ledger.
|
|
19
|
+
* @returns {ApprovalInterface & {size: () => number}}
|
|
20
|
+
*/
|
|
21
|
+
export function createMemoryApproval() {
|
|
22
|
+
const store = new Map();
|
|
23
|
+
|
|
24
|
+
return Object.freeze({
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} manifest
|
|
27
|
+
* @param {string} [actor]
|
|
28
|
+
* @returns {{approvalId: string, digest: string}}
|
|
29
|
+
*/
|
|
30
|
+
issue(manifest, actor) {
|
|
31
|
+
if (!manifest?.digest) {
|
|
32
|
+
throw new ContractError("Approval requires a manifest digest.", REASON.APPROVAL_REQUIRED);
|
|
33
|
+
}
|
|
34
|
+
const approvalId = randomUUID();
|
|
35
|
+
store.set(approvalId, {
|
|
36
|
+
digest: manifest.digest,
|
|
37
|
+
actor: actor ?? null,
|
|
38
|
+
used: false,
|
|
39
|
+
});
|
|
40
|
+
return { approvalId, digest: manifest.digest };
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} approvalId
|
|
45
|
+
* @param {string} digest
|
|
46
|
+
* @param {string} [actor]
|
|
47
|
+
* @returns {{approvalId: string, digest: string}}
|
|
48
|
+
*/
|
|
49
|
+
consume(approvalId, digest, actor) {
|
|
50
|
+
if (!approvalId) {
|
|
51
|
+
throw new ContractError("Approval required.", REASON.APPROVAL_REQUIRED);
|
|
52
|
+
}
|
|
53
|
+
const entry = store.get(approvalId);
|
|
54
|
+
if (!entry || entry.used) {
|
|
55
|
+
throw new ContractError("Approval already used or unknown.", REASON.REPLAY);
|
|
56
|
+
}
|
|
57
|
+
if (entry.digest !== digest) {
|
|
58
|
+
throw new ContractError("Approval digest mismatch.", REASON.REPLAY);
|
|
59
|
+
}
|
|
60
|
+
if (entry.actor && entry.actor !== actor) {
|
|
61
|
+
throw new ContractError("Approval actor mismatch.", REASON.REPLAY);
|
|
62
|
+
}
|
|
63
|
+
store.set(approvalId, { ...entry, used: true });
|
|
64
|
+
return { approvalId, digest };
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/** @returns {number} */
|
|
68
|
+
size() {
|
|
69
|
+
return store.size;
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|