drupal-mcp-connector 2.8.0 → 2.10.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 +72 -0
- package/README.md +2 -1
- package/bin/drupal-mcp-agent.js +191 -0
- package/bin/drupal-mcp-edge.js +150 -0
- package/package.json +4 -2
- 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/http-auth.js +4 -2
- package/src/lib/relay/agent.js +164 -0
- package/src/lib/relay/edge.js +470 -0
- package/src/lib/relay/frames.js +198 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay frame codec (#232).
|
|
3
|
+
*
|
|
4
|
+
* Length-prefixed JSON frames for the edge/agent channel, promoted from the
|
|
5
|
+
* DEV-293 lab harness (`lab/outbound-relay/harness.js`). Mechanism only — no
|
|
6
|
+
* authentication, entitlement, or header policy lives here.
|
|
7
|
+
*
|
|
8
|
+
* Wire format: 4-byte big-endian payload length, then UTF-8 JSON. A frame
|
|
9
|
+
* must be an object whose `type` is one of `FRAME_TYPES`. Anything else —
|
|
10
|
+
* oversize, malformed JSON, unknown type — tears the channel down rather
|
|
11
|
+
* than passing garbage through.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Hard bound on a single frame's JSON payload. */
|
|
15
|
+
export const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
/** The only frame types that may cross the channel. */
|
|
18
|
+
export const FRAME_TYPES = Object.freeze([
|
|
19
|
+
"hello",
|
|
20
|
+
"hello-ok",
|
|
21
|
+
"denied",
|
|
22
|
+
"mcp-request",
|
|
23
|
+
"mcp-response",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const FRAME_TYPE_SET = new Set(FRAME_TYPES);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* RFC 9110 hop-by-hop and transport-scoped headers. They describe one TCP
|
|
30
|
+
* connection and are meaningless (or harmful) across the tunnel.
|
|
31
|
+
*/
|
|
32
|
+
export const HOP_BY_HOP = new Set([
|
|
33
|
+
"connection",
|
|
34
|
+
"keep-alive",
|
|
35
|
+
"proxy-authenticate",
|
|
36
|
+
"proxy-authorization",
|
|
37
|
+
"te",
|
|
38
|
+
"trailers",
|
|
39
|
+
"transfer-encoding",
|
|
40
|
+
"upgrade",
|
|
41
|
+
"content-length",
|
|
42
|
+
"host",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Attach the length-prefixed frame reader to a socket.
|
|
47
|
+
*
|
|
48
|
+
* Oversize declarations, malformed JSON, non-object payloads, and unknown
|
|
49
|
+
* frame types destroy the socket; buffered bytes are dropped and no further
|
|
50
|
+
* frames are delivered.
|
|
51
|
+
*
|
|
52
|
+
* @param {import("node:stream").Duplex} socket
|
|
53
|
+
* @param {(frame: object) => void} onFrame
|
|
54
|
+
*/
|
|
55
|
+
export function attachFramer(socket, onFrame) {
|
|
56
|
+
let buffer = Buffer.alloc(0);
|
|
57
|
+
let dead = false;
|
|
58
|
+
|
|
59
|
+
function teardown() {
|
|
60
|
+
dead = true;
|
|
61
|
+
buffer = Buffer.alloc(0);
|
|
62
|
+
socket.destroy();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
socket.on("data", (chunk) => {
|
|
66
|
+
if (dead) return;
|
|
67
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
68
|
+
while (buffer.length >= 4) {
|
|
69
|
+
const size = buffer.readUInt32BE(0);
|
|
70
|
+
if (size > MAX_FRAME_BYTES) {
|
|
71
|
+
teardown();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (buffer.length < 4 + size) break;
|
|
75
|
+
const json = buffer.subarray(4, 4 + size).toString("utf8");
|
|
76
|
+
buffer = buffer.subarray(4 + size);
|
|
77
|
+
let frame;
|
|
78
|
+
try {
|
|
79
|
+
frame = JSON.parse(json);
|
|
80
|
+
} catch {
|
|
81
|
+
teardown();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!frame || typeof frame !== "object" || Array.isArray(frame)
|
|
85
|
+
|| !FRAME_TYPE_SET.has(frame.type)) {
|
|
86
|
+
teardown();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
onFrame(frame);
|
|
90
|
+
if (dead) return;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Write one frame. Refuses unknown types and oversize payloads by throwing;
|
|
97
|
+
* returns false without writing when the socket is no longer writable.
|
|
98
|
+
*
|
|
99
|
+
* @param {import("node:stream").Duplex} socket
|
|
100
|
+
* @param {object} frame
|
|
101
|
+
* @returns {boolean} True when the frame was written.
|
|
102
|
+
*/
|
|
103
|
+
export function writeFrame(socket, frame) {
|
|
104
|
+
if (!frame || typeof frame !== "object" || !FRAME_TYPE_SET.has(frame.type)) {
|
|
105
|
+
throw new RangeError("Refusing to write an unknown frame type.");
|
|
106
|
+
}
|
|
107
|
+
const payload = Buffer.from(JSON.stringify(frame), "utf8");
|
|
108
|
+
if (payload.length > MAX_FRAME_BYTES) {
|
|
109
|
+
throw new RangeError("Refusing to write a frame beyond the size bound.");
|
|
110
|
+
}
|
|
111
|
+
if (!socket.writable) return false;
|
|
112
|
+
const header = Buffer.alloc(4);
|
|
113
|
+
header.writeUInt32BE(payload.length, 0);
|
|
114
|
+
socket.write(Buffer.concat([header, payload]));
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Correlate mcp-request ids with their mcp-response frames.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} [options]
|
|
122
|
+
* @param {number} [options.timeoutMs] Per-request wait bound.
|
|
123
|
+
* @returns {{size: number, track: (id: string) => Promise<object>, settle: (frame: {id?: string}) => boolean, rejectAll: (error: Error) => void}}
|
|
124
|
+
*/
|
|
125
|
+
export function createRequestBroker({ timeoutMs = 10_000 } = {}) {
|
|
126
|
+
const pending = new Map();
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
get size() {
|
|
130
|
+
return pending.size;
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {string} id
|
|
135
|
+
* @returns {Promise<object>} Resolves with the matching response frame.
|
|
136
|
+
*/
|
|
137
|
+
track(id) {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
pending.delete(id);
|
|
141
|
+
reject(new Error(`Relay fan-down timeout for request ${id}.`));
|
|
142
|
+
}, timeoutMs);
|
|
143
|
+
timer.unref?.();
|
|
144
|
+
pending.set(id, {
|
|
145
|
+
resolve(frame) {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
resolve(frame);
|
|
148
|
+
},
|
|
149
|
+
reject(error) {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
reject(error);
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {{id?: string}} frame
|
|
159
|
+
* @returns {boolean} True when a tracked request was resolved.
|
|
160
|
+
*/
|
|
161
|
+
settle(frame) {
|
|
162
|
+
if (typeof frame?.id !== "string") return false;
|
|
163
|
+
const waiter = pending.get(frame.id);
|
|
164
|
+
if (!waiter) return false;
|
|
165
|
+
pending.delete(frame.id);
|
|
166
|
+
waiter.resolve(frame);
|
|
167
|
+
return true;
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {Error} error
|
|
172
|
+
*/
|
|
173
|
+
rejectAll(error) {
|
|
174
|
+
for (const [id, waiter] of pending) {
|
|
175
|
+
pending.delete(id);
|
|
176
|
+
waiter.reject(error);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Headers that may cross the tunnel at the transport level: hop-by-hop names
|
|
184
|
+
* are dropped, list values joined. Credential policy (what the edge strips
|
|
185
|
+
* beyond this) lives in edge.js, not here.
|
|
186
|
+
*
|
|
187
|
+
* @param {Record<string, string|string[]|null|undefined>} [headers]
|
|
188
|
+
* @returns {Record<string, string>}
|
|
189
|
+
*/
|
|
190
|
+
export function forwardHeaders(headers = {}) {
|
|
191
|
+
const entries = [];
|
|
192
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
193
|
+
if (HOP_BY_HOP.has(String(name).toLowerCase())) continue;
|
|
194
|
+
if (value === null || value === undefined) continue;
|
|
195
|
+
entries.push([name, Array.isArray(value) ? value.join(", ") : String(value)]);
|
|
196
|
+
}
|
|
197
|
+
return Object.fromEntries(entries);
|
|
198
|
+
}
|