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.
@@ -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
+ }