drupal-mcp-connector 0.7.1 → 0.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/CHANGELOG.md +92 -0
- package/README.md +5 -1
- package/package.json +15 -15
- package/src/index.js +37 -38
- package/src/lib/backends/jsonapi.js +60 -14
- package/src/lib/http-handler.js +78 -0
- package/src/lib/rate-limit.js +56 -0
- package/src/tools/nodes.js +33 -12
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,84 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-06-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Docs: an [MCP client setup guide](docs/mcp-clients.md) with copy-paste config
|
|
14
|
+
**and per-platform management commands** for Claude (Code `claude mcp …` /
|
|
15
|
+
Desktop), Grok (Build `grok mcp …` + API Remote MCP Tools), and OpenAI (Codex
|
|
16
|
+
`codex mcp …` + ChatGPT/Responses API), plus generic stdio and remote-HTTP
|
|
17
|
+
patterns and the local-vs-remote reachability/secret tradeoffs.
|
|
18
|
+
- Test coverage for the Streamable-HTTP transport's request routing (bearer-auth
|
|
19
|
+
gate, session open/reuse, `/health`, 404) via an extracted, unit-tested
|
|
20
|
+
`http-handler` module.
|
|
21
|
+
- Regression tests confirming **non-content-moderation Drupal sites are
|
|
22
|
+
unaffected** by the moderation fallback: a plain create sends `status` and
|
|
23
|
+
succeeds on the first request with no retry (the fallback only engages on the
|
|
24
|
+
specific moderated-entity 403).
|
|
25
|
+
- Optional built-in **rate limiting** for the HTTPS transport: set
|
|
26
|
+
`MCP_RATE_LIMIT` (per-IP requests per window) and `MCP_RATE_WINDOW_SEC`
|
|
27
|
+
(default 60). Over-limit `/mcp` requests get `429` + `Retry-After`; the check
|
|
28
|
+
runs before auth (throttling brute force) and never limits `/health`. Off by
|
|
29
|
+
default. (#4)
|
|
30
|
+
- Reference deployment for the HTTPS transport: a `Dockerfile` (+ `.dockerignore`),
|
|
31
|
+
systemd unit, launchd plist + launcher, a Caddy reverse-proxy example, and a
|
|
32
|
+
[Deployment guide](docs/deployment.md) with a pre-exposure checklist.
|
|
33
|
+
- [Versioning & Stability policy](docs/versioning.md) defining the stable public
|
|
34
|
+
surface (tool names/inputs, resource/prompt URIs, config + env vars, transports,
|
|
35
|
+
presets), the deprecation process, MCP-protocol negotiation behavior, and Node
|
|
36
|
+
support — the contract that 1.0 will lock.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
- Refactor: the HTTP transport's request handler is extracted from `index.js`
|
|
40
|
+
into `src/lib/http-handler.js` (no behavior change), making the routing/auth
|
|
41
|
+
path unit-testable and ready for additional middleware (e.g. rate limiting).
|
|
42
|
+
|
|
43
|
+
## [0.8.0] - 2026-06-15
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
- Docs: an OAuth2 `client_credentials` deployment guide
|
|
47
|
+
(`docs/oauth-client-credentials.md`) covering scope→role mapping, JSON:API
|
|
48
|
+
write enablement, config persistence across deploys, and secret handling, plus
|
|
49
|
+
a reusable `examples/launch-with-secret.sh` secret-manager launcher. Linked
|
|
50
|
+
from the README and getting-started.
|
|
51
|
+
- `drupal_create_node` and `drupal_update_node` accept a `moderationState`
|
|
52
|
+
argument (e.g. `"draft"`/`"published"`) for content types under a
|
|
53
|
+
content_moderation workflow. When set, `moderation_state` is sent and `status`
|
|
54
|
+
is omitted (moderated entities own their published state).
|
|
55
|
+
- CI: a `Changelog` workflow blocks any pull request that doesn't update
|
|
56
|
+
`CHANGELOG.md`. Trivial PRs that genuinely need no entry can carry the
|
|
57
|
+
`no-changelog` label to bypass the check.
|
|
58
|
+
- CI: a `dependabot.yml` enabling weekly version updates for npm (dev
|
|
59
|
+
dependencies grouped) and GitHub Actions.
|
|
60
|
+
- CI: a `changelog-autoupdate` workflow (org reusable) that writes a CHANGELOG
|
|
61
|
+
entry on Dependabot PRs and pushes it via a GitHub App token, so the required
|
|
62
|
+
`CHANGELOG updated` check passes without manual edits. No-ops until the
|
|
63
|
+
`CHANGELOG_APP_*` Dependabot secrets are configured.
|
|
64
|
+
- CI: Dependabot patch/minor PRs now auto-merge once checks pass (majors still
|
|
65
|
+
reviewed), via the org reusable workflow.
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
- CI: bumped `actions/checkout` and `actions/setup-node` to `v6` (Node 24
|
|
69
|
+
runtime) ahead of GitHub's June 2026 deprecation of Node 20 actions.
|
|
70
|
+
- CI: added a `concurrency` group so superseded in-progress runs are cancelled,
|
|
71
|
+
matching the sibling repos' CI hygiene.
|
|
72
|
+
- Dependency: bumped `graphql` 16.14.0 → 16.14.1 (patch).
|
|
73
|
+
- Dev dependencies: bumped `eslint` `^9`→`^10`, `eslint-plugin-security` `^3`→`^4`,
|
|
74
|
+
`eslint-plugin-n` `^17`→`^18`, and `globals` `^15`→`^17` (Dependabot
|
|
75
|
+
dev-dependencies group). Lint and the full test suite pass on the new majors.
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
- Create/update no longer fail on content_moderation bundles. The JSON:API
|
|
79
|
+
backend now transparently retries a write without the `status` attribute when
|
|
80
|
+
Drupal rejects it as a moderated entity's published field (HTTP 403), so the
|
|
81
|
+
safe default `status:false` works on moderated types (Drupal applies the
|
|
82
|
+
workflow's default state). Affects all entity create/update paths (nodes,
|
|
83
|
+
entities, media), not just nodes. (#23)
|
|
84
|
+
- Docs: replaced a personal email with the `opensource@wilkesliberty.com` role
|
|
85
|
+
address (README, `package.json`); corrected whitepaper tool counts (Drush
|
|
86
|
+
~10→15, Nodes ~12→6).
|
|
87
|
+
|
|
10
88
|
## [0.7.1] - 2026-06-08
|
|
11
89
|
|
|
12
90
|
### Fixed
|
|
@@ -22,6 +100,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
22
100
|
- Rewrote CONTRIBUTING.md: prerequisites, full dev-script list, a tests section,
|
|
23
101
|
accurate PR/CI gates, and the PR-then-tag release flow for protected `master`.
|
|
24
102
|
|
|
103
|
+
### Changed
|
|
104
|
+
- Restored the column-aligned `package.json` `scripts` formatting that
|
|
105
|
+
`npm version` re-flattened while cutting 0.7.0.
|
|
106
|
+
|
|
25
107
|
## [0.7.0] - 2026-06-08
|
|
26
108
|
|
|
27
109
|
### Added
|
|
@@ -31,6 +113,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
31
113
|
(GitHub Actions OIDC — no token/secret), gated on a tag↔`package.json` version
|
|
32
114
|
match. Provenance is attached automatically. One-time trusted-publisher setup
|
|
33
115
|
on npmjs.com (see CONTRIBUTING.md → Releasing).
|
|
116
|
+
- Branch protection on `master`: merges require a pull request with passing CI
|
|
117
|
+
(lint, unit tests on Node 20/22, Drupal integration, CodeQL) and resolved
|
|
118
|
+
review conversations; force-pushes and branch deletion are blocked.
|
|
119
|
+
|
|
120
|
+
### Fixed
|
|
121
|
+
- CI now runs on the `master` default branch. The workflow had been configured
|
|
122
|
+
for a nonexistent `main` branch, so lint/syntax/unit and integration never
|
|
123
|
+
executed on pushes or PRs.
|
|
34
124
|
|
|
35
125
|
### Removed
|
|
36
126
|
- **BREAKING:** dropped support for Node 18 (`engines.node` is now `>=20.0.0`).
|
|
@@ -123,6 +213,8 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
|
|
|
123
213
|
- User tools gained explicit PII-access assertions.
|
|
124
214
|
- Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
|
|
125
215
|
|
|
216
|
+
[0.9.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.9.0
|
|
217
|
+
[0.8.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.8.0
|
|
126
218
|
[0.7.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.7.1
|
|
127
219
|
[0.7.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.7.0
|
|
128
220
|
[0.6.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.6.1
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
[](https://drupal.org)
|
|
8
8
|
[](https://modelcontextprotocol.io)
|
|
9
9
|
|
|
10
|
-
Built by **Jeremy Michael Cerda** (
|
|
10
|
+
Built by **Jeremy Michael Cerda** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
@@ -170,12 +170,16 @@ Governance keys off the authenticated account's role and OAuth scopes — not re
|
|
|
170
170
|
| Doc | Description |
|
|
171
171
|
|-----|-------------|
|
|
172
172
|
| [Getting Started](docs/getting-started.md) | Full setup: DDEV/Lando, Simple OAuth, multi-site, transports |
|
|
173
|
+
| [MCP Clients](docs/mcp-clients.md) | Wire the connector into Claude (Code/Desktop), Grok (Build/API), and OpenAI (Codex/ChatGPT) — copy-paste config per client |
|
|
174
|
+
| [OAuth client_credentials](docs/oauth-client-credentials.md) | Production OAuth deploy: scope→role mapping, JSON:API writes, config persistence, secret handling, troubleshooting |
|
|
173
175
|
| [Architecture](docs/architecture.md) | Backend abstraction, canonical model, and how to extend it |
|
|
174
176
|
| [GraphQL Setup](docs/graphql-local-setup.md) | GraphQL Compose backend + local TLS notes |
|
|
175
177
|
| [Tools Reference](docs/tools-reference.md) | Full reference for all 66 tools |
|
|
176
178
|
| [Security Guide](docs/security.md) | Presets, entity access control, field redaction |
|
|
177
179
|
| [Security Hardening](docs/security-hardening.md) | Optional transport, identity, and secrets controls |
|
|
180
|
+
| [Deployment](docs/deployment.md) | Run the HTTPS transport in production: Docker, systemd, launchd, reverse proxy, pre-exposure checklist |
|
|
178
181
|
| [Integration Contract](docs/integration-contract.md) | The connector ↔ Drupal-governance contract (identity, OAuth scopes, compatibility) |
|
|
182
|
+
| [Versioning & Stability](docs/versioning.md) | Semver policy: the stable surface, deprecation process, MCP protocol + Node support |
|
|
179
183
|
| [Whitepaper](docs/whitepaper.md) | Vision, personas, and use cases |
|
|
180
184
|
|
|
181
185
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.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",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"url": "https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues"
|
|
36
36
|
},
|
|
37
37
|
"license": "MIT",
|
|
38
|
-
"author": "Jeremy Michael Cerda <
|
|
38
|
+
"author": "Jeremy Michael Cerda <opensource@wilkesliberty.com> (https://wilkesliberty.com)",
|
|
39
39
|
"contributors": [
|
|
40
40
|
"Wilkes & Liberty, LLC <opensource@wilkesliberty.com> (https://wilkesliberty.com)"
|
|
41
41
|
],
|
|
@@ -43,15 +43,15 @@
|
|
|
43
43
|
"node": ">=20.0.0"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
|
-
"start":
|
|
47
|
-
"start:https":
|
|
48
|
-
"start:dev":
|
|
49
|
-
"lint":
|
|
50
|
-
"lint:fix":
|
|
51
|
-
"test":
|
|
52
|
-
"test:watch":
|
|
53
|
-
"audit":
|
|
54
|
-
"check":
|
|
46
|
+
"start": "node src/index.js",
|
|
47
|
+
"start:https": "MCP_TRANSPORT=https node src/index.js",
|
|
48
|
+
"start:dev": "MCP_TRANSPORT=https MCP_ALLOW_HTTP=1 MCP_PORT=3443 node src/index.js",
|
|
49
|
+
"lint": "eslint src/",
|
|
50
|
+
"lint:fix": "eslint src/ --fix",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"test:watch": "vitest",
|
|
53
|
+
"audit": "npm audit --audit-level=high",
|
|
54
|
+
"check": "npm run lint && npm run audit",
|
|
55
55
|
"syntax-check": "for f in src/lib/*.js src/tools/*.js src/index.js; do node --input-type=module --check < $f && echo \"$f ✓\"; done"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
@@ -61,10 +61,10 @@
|
|
|
61
61
|
"ssh2": "^1.16.0"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"eslint": "^
|
|
65
|
-
"eslint-plugin-security": "^
|
|
66
|
-
"eslint-plugin-n": "^
|
|
67
|
-
"globals": "^
|
|
64
|
+
"eslint": "^10.4.1",
|
|
65
|
+
"eslint-plugin-security": "^4.0.0",
|
|
66
|
+
"eslint-plugin-n": "^18.1.0",
|
|
67
|
+
"globals": "^17.6.0",
|
|
68
68
|
"vitest": "^4.1.8"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/index.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* MCP_AUTH_TOKEN Bearer token required on /mcp in https mode (warns if unset)
|
|
19
19
|
* MCP_BIND_HOST Bind address for https mode when TLS is present
|
|
20
20
|
* (default: "0.0.0.0"; ignored without TLS, which forces loopback)
|
|
21
|
+
* MCP_RATE_LIMIT Max /mcp requests per window per client IP (0/unset = off)
|
|
22
|
+
* MCP_RATE_WINDOW_SEC Rate-limit window in seconds (default: 60)
|
|
21
23
|
*/
|
|
22
24
|
|
|
23
25
|
import { createServer as createHttpsServer } from "https";
|
|
@@ -36,6 +38,8 @@ import { CallToolRequestSchema,
|
|
|
36
38
|
|
|
37
39
|
import { getSiteConfig, listSiteNames, getTlsConfig, CLIENT_VERSION } from "./lib/config.js";
|
|
38
40
|
import { makeBearerCheck } from "./lib/http-auth.js";
|
|
41
|
+
import { createMcpRequestHandler } from "./lib/http-handler.js";
|
|
42
|
+
import { createRateLimiter } from "./lib/rate-limit.js";
|
|
39
43
|
import { resolveSecurityConfig, assertNotReadOnly,
|
|
40
44
|
assertDestructiveAllowed, assertGraphqlMutationAllowed,
|
|
41
45
|
SecurityError } from "./lib/security.js";
|
|
@@ -439,47 +443,42 @@ if (transport === "stdio") {
|
|
|
439
443
|
// Map of sessionId → transport for multi-client support
|
|
440
444
|
const sessions = new Map();
|
|
441
445
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
446
|
+
// Create + connect a new Streamable-HTTP transport, registering it in the
|
|
447
|
+
// session map on initialize and pruning it on close.
|
|
448
|
+
async function openSession() {
|
|
449
|
+
const mcpTransport = new StreamableHTTPServerTransport({
|
|
450
|
+
sessionIdGenerator: () => randomUUID(),
|
|
451
|
+
onsessioninitialized: (id) => sessions.set(id, mcpTransport),
|
|
452
|
+
});
|
|
453
|
+
mcpTransport.onclose = () => sessions.delete(mcpTransport.sessionId);
|
|
454
|
+
await server.connect(mcpTransport);
|
|
455
|
+
return mcpTransport;
|
|
456
|
+
}
|
|
449
457
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
await sessions.get(sessionId).handleRequest(req, res);
|
|
473
|
-
|
|
474
|
-
} else if (req.url === "/health") {
|
|
475
|
-
res.writeHead(200, { "Content-Type": "application/json" })
|
|
476
|
-
.end(JSON.stringify({ status: "ok", tools: allDefinitions.length }));
|
|
477
|
-
|
|
478
|
-
} else {
|
|
479
|
-
res.writeHead(404).end("Not found");
|
|
480
|
-
}
|
|
458
|
+
// Optional fixed-window rate limiting on /mcp, keyed by client IP. Off unless
|
|
459
|
+
// MCP_RATE_LIMIT > 0. Counts are per-process; for multi-replica deployments
|
|
460
|
+
// prefer rate limiting at the reverse proxy.
|
|
461
|
+
const rateLimit = Number(process.env.MCP_RATE_LIMIT || 0);
|
|
462
|
+
const rateWindowSec = Number(process.env.MCP_RATE_WINDOW_SEC || 60);
|
|
463
|
+
const rateLimiter = rateLimit > 0
|
|
464
|
+
? createRateLimiter({ limit: rateLimit, windowMs: rateWindowSec * 1000 })
|
|
465
|
+
: null;
|
|
466
|
+
if (rateLimiter) {
|
|
467
|
+
console.error(
|
|
468
|
+
`[drupal-mcp-connector] Rate limiting: ${rateLimit} req / ${rateWindowSec}s per client IP on /mcp.`
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const requestHandler = createMcpRequestHandler({
|
|
473
|
+
checkAuth,
|
|
474
|
+
sessions,
|
|
475
|
+
openSession,
|
|
476
|
+
toolCount: allDefinitions.length,
|
|
477
|
+
rateLimiter,
|
|
481
478
|
});
|
|
482
479
|
|
|
480
|
+
const nodeServer = createNodeServer(requestHandler);
|
|
481
|
+
|
|
483
482
|
const hasTls = Boolean(tlsCfg.certPath && tlsCfg.keyPath);
|
|
484
483
|
// Unauthenticated plain HTTP must never bind beyond loopback. A non-loopback
|
|
485
484
|
// bind is allowed only alongside TLS, via an explicit MCP_BIND_HOST opt-in.
|
|
@@ -19,6 +19,21 @@ import {
|
|
|
19
19
|
// these are dropped from canonical `fields` (the canonical id is the UUID).
|
|
20
20
|
const INTERNAL_ATTR_RE = /^drupal_internal__/;
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Detect the JSON:API error Drupal returns when a write attempts to set the
|
|
24
|
+
* `status` (published) field on a content_moderation-governed entity. Such
|
|
25
|
+
* entities own their published state via `moderation_state`, so a direct
|
|
26
|
+
* `status` write is refused with a 403 ("Cannot edit the published field of
|
|
27
|
+
* moderated entities" / "not allowed to … field (status)"). Used to decide
|
|
28
|
+
* whether to retry the write without `status`.
|
|
29
|
+
* @param {unknown} err
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
export function isModeratedStatusError(err) {
|
|
33
|
+
const msg = String(err?.message || "");
|
|
34
|
+
return /published field of moderated/i.test(msg) || /field \(status\)/i.test(msg);
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
// Canonical filter op -> JSON:API condition operator.
|
|
23
38
|
const OP_MAP = new Map([
|
|
24
39
|
["neq", "<>"], ["gt", ">"], ["gte", ">="], ["lt", "<"], ["lte", "<="],
|
|
@@ -209,32 +224,63 @@ export class JsonApiBackend extends Backend {
|
|
|
209
224
|
}
|
|
210
225
|
|
|
211
226
|
/**
|
|
212
|
-
*
|
|
227
|
+
* Issue a JSON:API write, transparently retrying once without the `status`
|
|
228
|
+
* attribute if the target bundle is under a content_moderation workflow.
|
|
229
|
+
*
|
|
230
|
+
* Moderated entities derive their published state from `moderation_state` and
|
|
231
|
+
* reject a direct `status` write with a 403. This lets create/update "just
|
|
232
|
+
* work" on moderated bundles using the connector's safe default `status:false`:
|
|
233
|
+
* the retry drops `status` and Drupal applies the workflow's default state
|
|
234
|
+
* (typically draft — i.e. still unpublished, preserving the no-auto-publish
|
|
235
|
+
* guarantee). Callers that need a specific state pass `moderation_state`
|
|
236
|
+
* explicitly, in which case `status` is absent and no retry occurs.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} path JSON:API resource path.
|
|
239
|
+
* @param {"POST"|"PATCH"} method HTTP method.
|
|
240
|
+
* @param {(attrs: object) => object} buildPayload Builds the request body from an attribute map.
|
|
241
|
+
* @param {object} attributes Entity attributes (may include `status`).
|
|
242
|
+
* @returns {Promise<object>} The JSON:API response body.
|
|
243
|
+
*/
|
|
244
|
+
async writeWithModerationFallback(path, method, buildPayload, attributes) {
|
|
245
|
+
try {
|
|
246
|
+
return await drupalFetch(this.site, path, { method, body: JSON.stringify(buildPayload(attributes)) });
|
|
247
|
+
} catch (err) {
|
|
248
|
+
if (!isModeratedStatusError(err) || !("status" in attributes)) throw err;
|
|
249
|
+
const withoutStatus = { ...attributes };
|
|
250
|
+
delete withoutStatus.status;
|
|
251
|
+
return drupalFetch(this.site, path, { method, body: JSON.stringify(buildPayload(withoutStatus)) });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Create an entity via JSON:API POST. Retries without `status` on moderated
|
|
257
|
+
* bundles — see writeWithModerationFallback.
|
|
213
258
|
* @param {{entityType: string, bundle: string, attributes?: object, relationships?: object}} input
|
|
214
259
|
* @returns {Promise<import("../canonical.js").CanonicalEntity>} The created entity.
|
|
215
260
|
*/
|
|
216
261
|
async createEntity({ entityType, bundle, attributes = {}, relationships }) {
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
262
|
+
const buildPayload = (attrs) => {
|
|
263
|
+
const payload = { data: { type: `${entityType}--${bundle}`, attributes: attrs } };
|
|
264
|
+
if (relationships) payload.data.relationships = relationships;
|
|
265
|
+
return payload;
|
|
266
|
+
};
|
|
267
|
+
const data = await this.writeWithModerationFallback(this.resourcePath(entityType, bundle), "POST", buildPayload, attributes);
|
|
223
268
|
return this.toCanonical(data.data);
|
|
224
269
|
}
|
|
225
270
|
|
|
226
271
|
/**
|
|
227
|
-
* Update an entity via JSON:API PATCH.
|
|
272
|
+
* Update an entity via JSON:API PATCH. Retries without `status` on moderated
|
|
273
|
+
* bundles — see writeWithModerationFallback.
|
|
228
274
|
* @param {{entityType: string, bundle: string, id: string, attributes?: object, relationships?: object}} input
|
|
229
275
|
* @returns {Promise<import("../canonical.js").CanonicalEntity>} The updated entity.
|
|
230
276
|
*/
|
|
231
277
|
async updateEntity({ entityType, bundle, id, attributes = {}, relationships }) {
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
});
|
|
278
|
+
const buildPayload = (attrs) => {
|
|
279
|
+
const payload = { data: { type: `${entityType}--${bundle}`, id, attributes: attrs } };
|
|
280
|
+
if (relationships) payload.data.relationships = relationships;
|
|
281
|
+
return payload;
|
|
282
|
+
};
|
|
283
|
+
const data = await this.writeWithModerationFallback(`${this.resourcePath(entityType, bundle)}/${id}`, "PATCH", buildPayload, attributes);
|
|
238
284
|
return this.toCanonical(data.data);
|
|
239
285
|
}
|
|
240
286
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP request handler for the Streamable-HTTP MCP transport.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from index.js so the routing/auth/health/404 behavior is unit
|
|
5
|
+
* testable without standing up a real server or the MCP SDK. The entry point
|
|
6
|
+
* wires the concrete dependencies (bearer check, session map, session factory);
|
|
7
|
+
* tests inject stubs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build the `(req, res)` handler for the MCP HTTP endpoint.
|
|
12
|
+
*
|
|
13
|
+
* Routes:
|
|
14
|
+
* - `POST /mcp` — bearer-gated; reuses a session by `Mcp-Session-Id` or opens
|
|
15
|
+
* a new one, then delegates to the transport's `handleRequest`.
|
|
16
|
+
* - `GET /mcp` — bearer-gated; requires a known session, else 400.
|
|
17
|
+
* - `GET /health`— unauthenticated liveness probe (`{status, tools}`).
|
|
18
|
+
* - everything else — 404.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} deps
|
|
21
|
+
* @param {(authHeader: any) => boolean} deps.checkAuth Bearer predicate (see http-auth.js).
|
|
22
|
+
* @param {Map<string, {handleRequest: Function, sessionId?: string}>} deps.sessions Session id → transport.
|
|
23
|
+
* @param {() => Promise<{handleRequest: Function}>} deps.openSession Create+connect a new transport.
|
|
24
|
+
* @param {number} deps.toolCount Tool count reported by /health.
|
|
25
|
+
* @param {?{check: (key: string) => {allowed: boolean, retryAfterSec: number}}} [deps.rateLimiter]
|
|
26
|
+
* Optional rate limiter (see rate-limit.js). Omit/null to disable.
|
|
27
|
+
* @param {(req: import("http").IncomingMessage) => string} [deps.clientKey]
|
|
28
|
+
* Maps a request to a rate-limit key (default: client IP).
|
|
29
|
+
* @returns {(req: import("http").IncomingMessage, res: import("http").ServerResponse) => Promise<void>}
|
|
30
|
+
*/
|
|
31
|
+
export function createMcpRequestHandler({
|
|
32
|
+
checkAuth, sessions, openSession, toolCount,
|
|
33
|
+
rateLimiter = null,
|
|
34
|
+
clientKey = (req) => req.socket?.remoteAddress || "unknown",
|
|
35
|
+
}) {
|
|
36
|
+
return async function handle(req, res) {
|
|
37
|
+
if (req.url === "/mcp" && (req.method === "POST" || req.method === "GET")) {
|
|
38
|
+
// Rate limit BEFORE auth so repeated bad-token attempts are throttled too.
|
|
39
|
+
if (rateLimiter) {
|
|
40
|
+
const verdict = rateLimiter.check(clientKey(req));
|
|
41
|
+
if (!verdict.allowed) {
|
|
42
|
+
res.writeHead(429, { "Retry-After": String(verdict.retryAfterSec) }).end("Too Many Requests");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Auth gate: only the /mcp endpoint requires a token; /health stays open.
|
|
47
|
+
if (!checkAuth(req.headers["authorization"])) {
|
|
48
|
+
res.writeHead(401, { "WWW-Authenticate": "Bearer" }).end("Unauthorized");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (req.method === "POST" && req.url === "/mcp") {
|
|
54
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
55
|
+
const transport = (sessionId && sessions.get(sessionId)) || (await openSession());
|
|
56
|
+
await transport.handleRequest(req, res);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (req.method === "GET" && req.url === "/mcp") {
|
|
61
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
62
|
+
if (!sessionId || !sessions.has(sessionId)) {
|
|
63
|
+
res.writeHead(400).end("Missing or unknown MCP-Session-Id");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
await sessions.get(sessionId).handleRequest(req, res);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (req.url === "/health") {
|
|
71
|
+
res.writeHead(200, { "Content-Type": "application/json" })
|
|
72
|
+
.end(JSON.stringify({ status: "ok", tools: toolCount }));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
res.writeHead(404).end("Not found");
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, dependency-free fixed-window rate limiter for the HTTPS transport.
|
|
3
|
+
*
|
|
4
|
+
* Opt-in: with a falsy/non-positive `limit` it's a no-op (always allows), so the
|
|
5
|
+
* default behavior is unchanged. Counts are kept in-memory per process and keyed
|
|
6
|
+
* by an arbitrary string (the caller chooses, e.g. client IP). Suited to a
|
|
7
|
+
* single-process connector fronting one Drupal; for multi-replica deployments
|
|
8
|
+
* put a shared limiter in the reverse proxy instead.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} RateVerdict
|
|
13
|
+
* @property {boolean} allowed Whether the request may proceed.
|
|
14
|
+
* @property {number} remaining Requests left in the current window (Infinity when disabled).
|
|
15
|
+
* @property {number} retryAfterSec Seconds until the window resets (0 when allowed).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a fixed-window rate limiter.
|
|
20
|
+
* @param {object} opts
|
|
21
|
+
* @param {?number} opts.limit Max requests per window per key. Falsy/<=0 disables limiting.
|
|
22
|
+
* @param {number} [opts.windowMs] Window length in ms (default 60_000).
|
|
23
|
+
* @param {() => number} [opts.now] Clock (injectable for tests; default Date.now).
|
|
24
|
+
* @param {number} [opts.maxKeys] Soft cap on tracked keys before expired ones are pruned (default 10_000).
|
|
25
|
+
* @returns {{ check: (key: string) => RateVerdict, size: () => number }}
|
|
26
|
+
*/
|
|
27
|
+
export function createRateLimiter({ limit, windowMs = 60_000, now = () => Date.now(), maxKeys = 10_000 } = {}) {
|
|
28
|
+
const enabled = typeof limit === "number" && limit > 0;
|
|
29
|
+
/** @type {Map<string, {count: number, resetAt: number}>} */
|
|
30
|
+
const buckets = new Map();
|
|
31
|
+
|
|
32
|
+
function prune(t) {
|
|
33
|
+
for (const [k, b] of buckets) {
|
|
34
|
+
if (t >= b.resetAt) buckets.delete(k);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
check(key) {
|
|
40
|
+
if (!enabled) return { allowed: true, remaining: Infinity, retryAfterSec: 0 };
|
|
41
|
+
const t = now();
|
|
42
|
+
let b = buckets.get(key);
|
|
43
|
+
if (!b || t >= b.resetAt) {
|
|
44
|
+
if (buckets.size >= maxKeys) prune(t);
|
|
45
|
+
b = { count: 0, resetAt: t + windowMs };
|
|
46
|
+
buckets.set(key, b);
|
|
47
|
+
}
|
|
48
|
+
b.count += 1;
|
|
49
|
+
if (b.count > limit) {
|
|
50
|
+
return { allowed: false, remaining: 0, retryAfterSec: Math.ceil((b.resetAt - t) / 1000) };
|
|
51
|
+
}
|
|
52
|
+
return { allowed: true, remaining: limit - b.count, retryAfterSec: 0 };
|
|
53
|
+
},
|
|
54
|
+
size() { return buckets.size; },
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/tools/nodes.js
CHANGED
|
@@ -92,16 +92,30 @@ async function searchContent({ site: siteName, query, type, status, limit = 10 }
|
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
94
|
* Create a node. Caller-supplied `fields` are spread into the attribute map;
|
|
95
|
-
* title/status/body are layered on top so they win over any
|
|
95
|
+
* title/status/moderation_state/body are layered on top so they win over any
|
|
96
|
+
* same-named field.
|
|
96
97
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
98
|
+
* Publish state — two mutually exclusive paths:
|
|
99
|
+
* - `moderationState` (e.g. "draft"/"published"): for content types under a
|
|
100
|
+
* content_moderation workflow. When given, `moderation_state` is sent and
|
|
101
|
+
* `status` is omitted (moderated entities reject a direct `status` write).
|
|
102
|
+
* - `status` (boolean): for non-moderated types. Defaults to false (draft) so
|
|
103
|
+
* content is never auto-published. `moderationState` takes precedence.
|
|
104
|
+
* If a moderated bundle still receives `status` (the safe default), the JSON:API
|
|
105
|
+
* backend transparently retries without it — see jsonapi.js.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} args - { site?, type, title, body?, summary?, status?, moderationState?, fields? }.
|
|
99
108
|
* @returns {Promise<object>} The created node descriptor from the backend.
|
|
100
109
|
*/
|
|
101
|
-
async function createNode({ site: siteName, type, title, body, summary, status
|
|
110
|
+
async function createNode({ site: siteName, type, title, body, summary, status, moderationState, fields = {} }) {
|
|
102
111
|
const site = getSiteConfig(siteName);
|
|
103
112
|
const backend = await resolveBackend(site);
|
|
104
|
-
const attributes = { title,
|
|
113
|
+
const attributes = { title, ...fields };
|
|
114
|
+
if (moderationState !== undefined) {
|
|
115
|
+
attributes.moderation_state = moderationState;
|
|
116
|
+
} else {
|
|
117
|
+
attributes.status = status === undefined ? false : status;
|
|
118
|
+
}
|
|
105
119
|
const bodyAttr = buildBodyAttribute(body, summary);
|
|
106
120
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
107
121
|
return backend.createEntity({ entityType: "node", bundle: type, attributes });
|
|
@@ -111,15 +125,20 @@ async function createNode({ site: siteName, type, title, body, summary, status =
|
|
|
111
125
|
* Update a node. Only supplied attributes are sent, so omitted fields are left
|
|
112
126
|
* untouched (partial update). `fields` is spread first, then known scalars.
|
|
113
127
|
*
|
|
114
|
-
*
|
|
128
|
+
* Publish state mirrors createNode: pass `moderationState` for content_moderation
|
|
129
|
+
* bundles (sends `moderation_state`, omits `status`) or `status` for non-moderated
|
|
130
|
+
* types. `moderationState` takes precedence; both are optional on update.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} args - { site?, type, id, title?, body?, summary?, status?, moderationState?, fields? }.
|
|
115
133
|
* @returns {Promise<object>} The updated node descriptor.
|
|
116
134
|
*/
|
|
117
|
-
async function updateNode({ site: siteName, type, id, title, body, summary, status, fields = {} }) {
|
|
135
|
+
async function updateNode({ site: siteName, type, id, title, body, summary, status, moderationState, fields = {} }) {
|
|
118
136
|
const site = getSiteConfig(siteName);
|
|
119
137
|
const backend = await resolveBackend(site);
|
|
120
138
|
const attributes = { ...fields };
|
|
121
139
|
if (title !== undefined) attributes.title = title;
|
|
122
|
-
if (
|
|
140
|
+
if (moderationState !== undefined) attributes.moderation_state = moderationState;
|
|
141
|
+
else if (status !== undefined) attributes.status = status;
|
|
123
142
|
const bodyAttr = buildBodyAttribute(body, summary);
|
|
124
143
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
125
144
|
return backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
|
|
@@ -188,7 +207,7 @@ export const definitions = [
|
|
|
188
207
|
},
|
|
189
208
|
{
|
|
190
209
|
name: "drupal_create_node",
|
|
191
|
-
description: "Create a new content node. Returns the new node UUID, integer ID, and URL.",
|
|
210
|
+
description: "Create a new content node. Returns the new node UUID, integer ID, and URL. For content types under an editorial (content_moderation) workflow, set moderationState (e.g. 'draft'/'published') instead of status.",
|
|
192
211
|
inputSchema: {
|
|
193
212
|
type: "object", required: ["type", "title"],
|
|
194
213
|
properties: {
|
|
@@ -197,14 +216,15 @@ export const definitions = [
|
|
|
197
216
|
title: { type: "string" },
|
|
198
217
|
body: { type: "string", description: "Body field HTML" },
|
|
199
218
|
summary: { type: "string", description: "Body summary / teaser" },
|
|
200
|
-
status: { type: "boolean", default: false, description: "true to publish immediately" },
|
|
219
|
+
status: { type: "boolean", default: false, description: "Published flag for NON-moderated types. true to publish immediately. Ignored if moderationState is set; on a moderated type it is dropped automatically." },
|
|
220
|
+
moderationState: { type: "string", description: "Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status." },
|
|
201
221
|
fields: { type: "object", description: "Additional field values keyed by Drupal machine name" },
|
|
202
222
|
},
|
|
203
223
|
},
|
|
204
224
|
},
|
|
205
225
|
{
|
|
206
226
|
name: "drupal_update_node",
|
|
207
|
-
description: "Update an existing node. Only include fields you want to change.",
|
|
227
|
+
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status.",
|
|
208
228
|
inputSchema: {
|
|
209
229
|
type: "object", required: ["type", "id"],
|
|
210
230
|
properties: {
|
|
@@ -214,7 +234,8 @@ export const definitions = [
|
|
|
214
234
|
title: { type: "string" },
|
|
215
235
|
body: { type: "string" },
|
|
216
236
|
summary: { type: "string" },
|
|
217
|
-
status: { type: "boolean", description: "true = publish, false = unpublish" },
|
|
237
|
+
status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
|
|
238
|
+
moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status." },
|
|
218
239
|
fields: { type: "object" },
|
|
219
240
|
},
|
|
220
241
|
},
|