@thingd/cli 0.31.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/LICENSE +201 -0
- package/README.md +238 -0
- package/dist/dashboard/public/assets/index-B-Y-3-0l.js +2 -0
- package/dist/dashboard/public/assets/index-B5YhpIl3.js +2 -0
- package/dist/dashboard/public/assets/index-BnFclxvN.css +1 -0
- package/dist/dashboard/public/assets/index-BtA9rnyI.js +2 -0
- package/dist/dashboard/public/assets/index-BzLTzidY.js +2 -0
- package/dist/dashboard/public/assets/index-C6PkDB7y.css +1 -0
- package/dist/dashboard/public/assets/index-D8yUCdOQ.js +2 -0
- package/dist/dashboard/public/assets/index-fQywB2df.js +2 -0
- package/dist/dashboard/public/assets/index-kZdrdi3K.css +1 -0
- package/dist/dashboard/public/assets/index-kgZrboBN.js +4 -0
- package/dist/dashboard/public/favicon.svg +1 -0
- package/dist/dashboard/public/icons.svg +24 -0
- package/dist/dashboard/public/index.html +16 -0
- package/dist/dashboard/server.d.ts +6 -0
- package/dist/dashboard/server.d.ts.map +1 -0
- package/dist/dashboard/server.js +385 -0
- package/dist/data-movement.d.ts +5 -0
- package/dist/data-movement.d.ts.map +1 -0
- package/dist/data-movement.js +257 -0
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +109 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1015 -0
- package/dist/install.d.ts +3 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +311 -0
- package/dist/interactive.d.ts +2 -0
- package/dist/interactive.d.ts.map +1 -0
- package/dist/interactive.js +1592 -0
- package/dist/logo.d.ts +3 -0
- package/dist/logo.d.ts.map +1 -0
- package/dist/logo.js +8 -0
- package/dist/mcp/audit.d.ts +27 -0
- package/dist/mcp/audit.d.ts.map +1 -0
- package/dist/mcp/audit.js +36 -0
- package/dist/mcp/cluster.d.ts +68 -0
- package/dist/mcp/cluster.d.ts.map +1 -0
- package/dist/mcp/cluster.js +303 -0
- package/dist/mcp/config.d.ts +14 -0
- package/dist/mcp/config.d.ts.map +1 -0
- package/dist/mcp/config.js +67 -0
- package/dist/mcp/http.d.ts +25 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/http.js +588 -0
- package/dist/mcp/index.d.ts +5 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +3 -0
- package/dist/mcp/result.d.ts +3 -0
- package/dist/mcp/result.d.ts.map +1 -0
- package/dist/mcp/result.js +10 -0
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +51 -0
- package/dist/mcp/tools.d.ts +10 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +568 -0
- package/dist/mcp-http.d.ts +3 -0
- package/dist/mcp-http.d.ts.map +1 -0
- package/dist/mcp-http.js +42 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +22 -0
- package/dist/paths.d.ts +4 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +14 -0
- package/dist/rest/helpers.d.ts +17 -0
- package/dist/rest/helpers.d.ts.map +1 -0
- package/dist/rest/helpers.js +55 -0
- package/dist/rest/server.d.ts +4 -0
- package/dist/rest/server.d.ts.map +1 -0
- package/dist/rest/server.js +317 -0
- package/package.json +57 -0
package/dist/mcp/http.js
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { PassThrough } from "node:stream";
|
|
3
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
|
+
import { createThingdMcpServer, ThingD, } from "@thingd/node";
|
|
5
|
+
import { findNextLeaderCandidate, forwardMcpRequestToLeader, getClusterStatus, resolveClusterOptions, } from "./cluster.js";
|
|
6
|
+
import { ensureHttpRuntimeIsSafe } from "./config.js";
|
|
7
|
+
export async function startThingdHttpServer(options) {
|
|
8
|
+
const host = options.host ?? "127.0.0.1";
|
|
9
|
+
const port = options.port ?? 8757;
|
|
10
|
+
ensureHttpRuntimeIsSafe({
|
|
11
|
+
host,
|
|
12
|
+
authToken: options.authToken,
|
|
13
|
+
allowUnauthenticated: options.allowUnauthenticated,
|
|
14
|
+
});
|
|
15
|
+
const originalDb = await ThingD.open({
|
|
16
|
+
path: options.path,
|
|
17
|
+
driver: options.driver,
|
|
18
|
+
});
|
|
19
|
+
const cluster = resolveClusterOptions(options.cluster);
|
|
20
|
+
const db = createReplicatingDb(originalDb, cluster.mode);
|
|
21
|
+
const state = {
|
|
22
|
+
db,
|
|
23
|
+
originalDb,
|
|
24
|
+
authToken: options.authToken,
|
|
25
|
+
mcpPath: options.mcpPath ?? "/mcp",
|
|
26
|
+
healthPath: options.healthPath ?? "/healthz",
|
|
27
|
+
driver: options.driver ?? "memory",
|
|
28
|
+
audit: options.audit,
|
|
29
|
+
cluster,
|
|
30
|
+
hardening: options.hardening,
|
|
31
|
+
consecutiveReplicationFailures: 0,
|
|
32
|
+
};
|
|
33
|
+
const server = createServer((request, response) => {
|
|
34
|
+
void handleRequest(state, request, response);
|
|
35
|
+
});
|
|
36
|
+
await listen(server, port, host);
|
|
37
|
+
const address = server.address();
|
|
38
|
+
const resolvedPort = typeof address === "object" && address ? address.port : port;
|
|
39
|
+
const displayHost = host === "0.0.0.0" ? "127.0.0.1" : host;
|
|
40
|
+
const url = `http://${displayHost}:${resolvedPort}`;
|
|
41
|
+
if (cluster.mode === "follower") {
|
|
42
|
+
startReplicationRunner(state);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
server,
|
|
46
|
+
url,
|
|
47
|
+
mcpUrl: `${url}${state.mcpPath}`,
|
|
48
|
+
close: async () => {
|
|
49
|
+
stopReplicationRunner(state);
|
|
50
|
+
await close(server);
|
|
51
|
+
await originalDb.close?.();
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function handleRequest(state, request, response) {
|
|
56
|
+
try {
|
|
57
|
+
setCommonHeaders(response);
|
|
58
|
+
const path = requestPath(request);
|
|
59
|
+
if (request.method === "OPTIONS") {
|
|
60
|
+
response.writeHead(204);
|
|
61
|
+
response.end();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (path === state.healthPath) {
|
|
65
|
+
await handleHealth(state, request, response);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (path === state.cluster.statusPath) {
|
|
69
|
+
await handleClusterStatus(state, request, response);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (path === state.cluster.peersPath) {
|
|
73
|
+
handleClusterPeers(state, request, response);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (path !== state.mcpPath && path !== "/v1/replication/events") {
|
|
77
|
+
writeJson(response, 404, {
|
|
78
|
+
error: "not_found",
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!isAuthorized(state, request)) {
|
|
83
|
+
response.setHeader("WWW-Authenticate", "Bearer");
|
|
84
|
+
writeJson(response, 401, {
|
|
85
|
+
error: "unauthorized",
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (path === "/v1/replication/events") {
|
|
90
|
+
await handleReplicationEvents(state, request, response);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (request.method !== "POST") {
|
|
94
|
+
response.setHeader("Allow", "POST, OPTIONS");
|
|
95
|
+
writeJson(response, 405, {
|
|
96
|
+
jsonrpc: "2.0",
|
|
97
|
+
error: {
|
|
98
|
+
code: -32_000,
|
|
99
|
+
message: "Method not allowed.",
|
|
100
|
+
},
|
|
101
|
+
id: null,
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// Enforce payload size limit.
|
|
106
|
+
// For requests with Content-Length we can reject immediately without draining the body.
|
|
107
|
+
// For chunked transfers we wrap the request in a PassThrough that aborts if the limit is exceeded.
|
|
108
|
+
const maxBytes = state.hardening?.maxPayloadBytes ?? 524_288;
|
|
109
|
+
const contentLength = parseContentLength(request);
|
|
110
|
+
if (contentLength !== null && contentLength > maxBytes) {
|
|
111
|
+
writeJson(response, 413, {
|
|
112
|
+
jsonrpc: "2.0",
|
|
113
|
+
error: {
|
|
114
|
+
code: -32_000,
|
|
115
|
+
message: `Request body exceeds the maximum allowed size of ${maxBytes} bytes.`,
|
|
116
|
+
},
|
|
117
|
+
id: null,
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (state.cluster.mode === "follower") {
|
|
122
|
+
await forwardMcpRequestToLeader(state.cluster, state.mcpPath, request, response);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// For chunked transfers, wrap the request in a PassThrough that enforces the limit
|
|
126
|
+
// without pre-draining the stream (the MCP transport still drives reading).
|
|
127
|
+
const wrappedRequest = contentLength === null ? wrapRequestWithSizeLimit(request, response, maxBytes) : request;
|
|
128
|
+
await handleMcpRequest(state, wrappedRequest, response);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (!response.headersSent) {
|
|
132
|
+
writeJson(response, 500, {
|
|
133
|
+
jsonrpc: "2.0",
|
|
134
|
+
error: {
|
|
135
|
+
code: -32_603,
|
|
136
|
+
message: error instanceof Error ? error.message : "Internal server error",
|
|
137
|
+
},
|
|
138
|
+
id: null,
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
response.end();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function handleHealth(state, request, response) {
|
|
146
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
147
|
+
response.setHeader("Allow", "GET, HEAD, OPTIONS");
|
|
148
|
+
writeJson(response, 405, {
|
|
149
|
+
error: "method_not_allowed",
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const status = await getClusterStatus(state.cluster, state.db);
|
|
154
|
+
writeJson(response, 200, {
|
|
155
|
+
ok: true,
|
|
156
|
+
service: "thingd-mcp",
|
|
157
|
+
driver: state.driver,
|
|
158
|
+
mcpPath: state.mcpPath,
|
|
159
|
+
cluster: status,
|
|
160
|
+
}, request.method === "HEAD");
|
|
161
|
+
}
|
|
162
|
+
async function handleClusterStatus(state, request, response) {
|
|
163
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
164
|
+
response.setHeader("Allow", "GET, HEAD, OPTIONS");
|
|
165
|
+
writeJson(response, 405, {
|
|
166
|
+
error: "method_not_allowed",
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const status = await getClusterStatus(state.cluster, state.db);
|
|
171
|
+
writeJson(response, 200, status, request.method === "HEAD");
|
|
172
|
+
}
|
|
173
|
+
function handleClusterPeers(state, request, response) {
|
|
174
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
175
|
+
response.setHeader("Allow", "GET, HEAD, OPTIONS");
|
|
176
|
+
writeJson(response, 405, {
|
|
177
|
+
error: "method_not_allowed",
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
writeJson(response, 200, {
|
|
182
|
+
peers: state.cluster.peers,
|
|
183
|
+
discovery: state.cluster.discovery,
|
|
184
|
+
}, request.method === "HEAD");
|
|
185
|
+
}
|
|
186
|
+
async function handleMcpRequest(state, request, response) {
|
|
187
|
+
const server = createThingdMcpServer(state.db, {
|
|
188
|
+
audit: state.audit,
|
|
189
|
+
hardening: state.hardening,
|
|
190
|
+
});
|
|
191
|
+
const transport = new StreamableHTTPServerTransport({
|
|
192
|
+
sessionIdGenerator: undefined,
|
|
193
|
+
});
|
|
194
|
+
response.on("close", () => {
|
|
195
|
+
void transport.close();
|
|
196
|
+
void server.close();
|
|
197
|
+
});
|
|
198
|
+
await server.connect(transport);
|
|
199
|
+
await transport.handleRequest(request, response);
|
|
200
|
+
}
|
|
201
|
+
function requestPath(request) {
|
|
202
|
+
return new URL(request.url ?? "/", "http://thingd.local").pathname;
|
|
203
|
+
}
|
|
204
|
+
function isAuthorized(state, request) {
|
|
205
|
+
if (!state.authToken) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
return request.headers.authorization === `Bearer ${state.authToken}`;
|
|
209
|
+
}
|
|
210
|
+
function setCommonHeaders(response) {
|
|
211
|
+
response.setHeader("Access-Control-Allow-Origin", "*");
|
|
212
|
+
response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, MCP-Protocol-Version");
|
|
213
|
+
response.setHeader("Access-Control-Allow-Methods", "POST, GET, HEAD, OPTIONS");
|
|
214
|
+
}
|
|
215
|
+
function writeJson(response, statusCode, body, headersOnly = false) {
|
|
216
|
+
response.writeHead(statusCode, {
|
|
217
|
+
"Content-Type": "application/json",
|
|
218
|
+
});
|
|
219
|
+
if (headersOnly) {
|
|
220
|
+
response.end();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
response.end(JSON.stringify(body));
|
|
224
|
+
}
|
|
225
|
+
function listen(server, port, host) {
|
|
226
|
+
return new Promise((resolve, reject) => {
|
|
227
|
+
const onError = (error) => {
|
|
228
|
+
server.off("listening", onListening);
|
|
229
|
+
reject(error);
|
|
230
|
+
};
|
|
231
|
+
const onListening = () => {
|
|
232
|
+
server.off("error", onError);
|
|
233
|
+
resolve();
|
|
234
|
+
};
|
|
235
|
+
server.once("error", onError);
|
|
236
|
+
server.once("listening", onListening);
|
|
237
|
+
server.listen(port, host);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function close(server) {
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
server.close((error) => {
|
|
243
|
+
if (error) {
|
|
244
|
+
reject(error);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
resolve();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/** Parse Content-Length header. Returns null if absent or invalid. */
|
|
252
|
+
function parseContentLength(request) {
|
|
253
|
+
const header = request.headers["content-length"];
|
|
254
|
+
if (!header) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
const n = Number.parseInt(header, 10);
|
|
258
|
+
return Number.isInteger(n) && n >= 0 ? n : null;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Wrap a request in a PassThrough that enforces maxBytes for chunked transfers.
|
|
262
|
+
* The MCP transport still drives reading — we just count bytes as they flow through
|
|
263
|
+
* and destroy the stream (causing the transport to error) if the limit is exceeded.
|
|
264
|
+
* The response is also aborted with HTTP 413 immediately on overflow.
|
|
265
|
+
*/
|
|
266
|
+
function wrapRequestWithSizeLimit(request, response, maxBytes) {
|
|
267
|
+
const pass = new PassThrough();
|
|
268
|
+
let total = 0;
|
|
269
|
+
let aborted = false;
|
|
270
|
+
Object.defineProperties(pass, {
|
|
271
|
+
method: { get: () => request.method },
|
|
272
|
+
url: { get: () => request.url },
|
|
273
|
+
headers: { get: () => request.headers },
|
|
274
|
+
});
|
|
275
|
+
request.on("data", (chunk) => {
|
|
276
|
+
if (aborted) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
total += chunk.length;
|
|
280
|
+
if (total > maxBytes) {
|
|
281
|
+
aborted = true;
|
|
282
|
+
if (!response.headersSent) {
|
|
283
|
+
writeJson(response, 413, {
|
|
284
|
+
jsonrpc: "2.0",
|
|
285
|
+
error: {
|
|
286
|
+
code: -32_000,
|
|
287
|
+
message: `Request body exceeds the maximum allowed size of ${maxBytes} bytes.`,
|
|
288
|
+
},
|
|
289
|
+
id: null,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
pass.destroy();
|
|
293
|
+
request.destroy();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
pass.push(chunk);
|
|
297
|
+
});
|
|
298
|
+
request.on("end", () => {
|
|
299
|
+
if (!aborted) {
|
|
300
|
+
pass.end();
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
request.on("error", (err) => {
|
|
304
|
+
if (!aborted) {
|
|
305
|
+
pass.destroy(err);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
return pass;
|
|
309
|
+
}
|
|
310
|
+
function createReplicatingDb(originalDb, mode) {
|
|
311
|
+
if (mode !== "leader" && mode !== "single") {
|
|
312
|
+
return originalDb;
|
|
313
|
+
}
|
|
314
|
+
const proxy = Object.create(originalDb);
|
|
315
|
+
proxy.put = async (collection, object) => {
|
|
316
|
+
const stored = await originalDb.put(collection, object);
|
|
317
|
+
if (!collection.startsWith("__thingd")) {
|
|
318
|
+
try {
|
|
319
|
+
await originalDb.events.append("__thingd:system:replication", {
|
|
320
|
+
type: "replication.objects.put",
|
|
321
|
+
collection,
|
|
322
|
+
id: stored.id,
|
|
323
|
+
object: stored,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
console.error("Replication event append failed:", err);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return stored;
|
|
331
|
+
};
|
|
332
|
+
proxy.delete = async (collection, id) => {
|
|
333
|
+
const result = await originalDb.delete(collection, id);
|
|
334
|
+
if (!collection.startsWith("__thingd")) {
|
|
335
|
+
try {
|
|
336
|
+
await originalDb.events.append("__thingd:system:replication", {
|
|
337
|
+
type: "replication.objects.delete",
|
|
338
|
+
collection,
|
|
339
|
+
id,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
console.error("Replication event append failed:", err);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return result;
|
|
347
|
+
};
|
|
348
|
+
const originalEventsAppend = originalDb.events.append.bind(originalDb.events);
|
|
349
|
+
Object.defineProperty(proxy, "events", {
|
|
350
|
+
value: {
|
|
351
|
+
...originalDb.events,
|
|
352
|
+
append: async (stream, event) => {
|
|
353
|
+
const stored = await originalEventsAppend(stream, event);
|
|
354
|
+
if (stream !== "__thingd:system:replication") {
|
|
355
|
+
try {
|
|
356
|
+
await originalEventsAppend("__thingd:system:replication", {
|
|
357
|
+
type: "replication.events.append",
|
|
358
|
+
stream,
|
|
359
|
+
event: stored,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
console.error("Replication event append failed:", err);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return stored;
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
writable: true,
|
|
370
|
+
configurable: true,
|
|
371
|
+
});
|
|
372
|
+
return proxy;
|
|
373
|
+
}
|
|
374
|
+
function* resolveLeaderUrls(cluster) {
|
|
375
|
+
if (cluster.leaderUrl) {
|
|
376
|
+
yield cluster.leaderUrl;
|
|
377
|
+
}
|
|
378
|
+
if (cluster.fallbackLeaderUrl) {
|
|
379
|
+
yield cluster.fallbackLeaderUrl;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function startReplicationRunner(state) {
|
|
383
|
+
const leaderUrl = state.cluster.leaderUrl;
|
|
384
|
+
if (!leaderUrl) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const pullInterval = 500;
|
|
388
|
+
async function runSync() {
|
|
389
|
+
if (state.replicationStopped) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const status = await state.db.get("__thingd_meta", "replication_status");
|
|
393
|
+
const lastSeq = status && typeof status.lastReplicatedSequence === "number"
|
|
394
|
+
? status.lastReplicatedSequence
|
|
395
|
+
: 0;
|
|
396
|
+
let fetched = false;
|
|
397
|
+
for (const url of resolveLeaderUrls(state.cluster)) {
|
|
398
|
+
if (state.replicationStopped) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
const fetchUrl = new URL("/v1/replication/events", url);
|
|
403
|
+
fetchUrl.searchParams.set("after", String(lastSeq));
|
|
404
|
+
const headers = {
|
|
405
|
+
Accept: "application/json",
|
|
406
|
+
};
|
|
407
|
+
if (state.cluster.forwardAuthToken) {
|
|
408
|
+
headers.Authorization = `Bearer ${state.cluster.forwardAuthToken}`;
|
|
409
|
+
}
|
|
410
|
+
const response = await fetch(fetchUrl.toString(), {
|
|
411
|
+
headers,
|
|
412
|
+
signal: AbortSignal.timeout(10_000),
|
|
413
|
+
});
|
|
414
|
+
if (!response.ok) {
|
|
415
|
+
throw new Error(`Leader replication returned HTTP ${response.status}`);
|
|
416
|
+
}
|
|
417
|
+
state.cluster.activeLeaderUrl = url;
|
|
418
|
+
const resData = (await response.json());
|
|
419
|
+
if (resData.success && Array.isArray(resData.events) && resData.events.length > 0) {
|
|
420
|
+
for (const ev of resData.events) {
|
|
421
|
+
if (state.replicationStopped) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const type = ev.type;
|
|
425
|
+
if (type === "replication.objects.put" && ev.collection) {
|
|
426
|
+
const { collection, object } = ev;
|
|
427
|
+
const cleanObj = { ...object };
|
|
428
|
+
delete cleanObj.collection;
|
|
429
|
+
delete cleanObj.createdAt;
|
|
430
|
+
delete cleanObj.updatedAt;
|
|
431
|
+
delete cleanObj.version;
|
|
432
|
+
await state.db.put(collection, cleanObj);
|
|
433
|
+
}
|
|
434
|
+
else if (type === "replication.objects.delete" && ev.collection && ev.id) {
|
|
435
|
+
const { collection, id } = ev;
|
|
436
|
+
await state.db.delete(collection, id);
|
|
437
|
+
}
|
|
438
|
+
else if (type === "replication.events.append" && ev.stream) {
|
|
439
|
+
const { stream, event } = ev;
|
|
440
|
+
const cleanEv = { ...event };
|
|
441
|
+
delete cleanEv.id;
|
|
442
|
+
delete cleanEv.createdAt;
|
|
443
|
+
delete cleanEv.stream;
|
|
444
|
+
await state.db.events.append(stream, cleanEv);
|
|
445
|
+
}
|
|
446
|
+
const seq = Number.parseInt(ev.id, 10);
|
|
447
|
+
await state.db.put("__thingd_meta", {
|
|
448
|
+
id: "replication_status",
|
|
449
|
+
lastReplicatedSequence: seq,
|
|
450
|
+
updatedAt: new Date().toISOString(),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
fetched = true;
|
|
455
|
+
// Asynchronously update the cached replication lag so /healthz never
|
|
456
|
+
// blocks on an outbound request. Fire-and-forget; errors are non-fatal.
|
|
457
|
+
void updateCachedLag(state, url, lastSeq);
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
console.error(`Replication from ${url} failed:`, error instanceof Error ? error.message : String(error));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (state.replicationStopped) {
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (!fetched) {
|
|
468
|
+
state.consecutiveReplicationFailures++;
|
|
469
|
+
if (state.cluster.leaderElection &&
|
|
470
|
+
state.consecutiveReplicationFailures >= state.cluster.electionMaxFailures) {
|
|
471
|
+
state.consecutiveReplicationFailures = 0;
|
|
472
|
+
const promoted = attemptFollowerFailover(state);
|
|
473
|
+
if (promoted) {
|
|
474
|
+
// This node is now leader. Stop the replication runner.
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// leaderUrl was updated by failover; retry immediately.
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
console.error("All leader URLs exhausted for replication");
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
state.consecutiveReplicationFailures = 0;
|
|
485
|
+
}
|
|
486
|
+
if (!state.replicationStopped) {
|
|
487
|
+
state.replicationTimer = setTimeout(() => {
|
|
488
|
+
void runSync();
|
|
489
|
+
}, pullInterval);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
state.replicationTimer = setTimeout(() => {
|
|
493
|
+
void runSync();
|
|
494
|
+
}, pullInterval);
|
|
495
|
+
}
|
|
496
|
+
function stopReplicationRunner(state) {
|
|
497
|
+
state.replicationStopped = true;
|
|
498
|
+
if (state.replicationTimer) {
|
|
499
|
+
clearTimeout(state.replicationTimer);
|
|
500
|
+
state.replicationTimer = undefined;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Asynchronously fetch the leader's last replicated sequence and update
|
|
505
|
+
* the cached lag on the cluster state. Called after each successful sync
|
|
506
|
+
* so that /healthz can return the lag without making an outbound request.
|
|
507
|
+
*/
|
|
508
|
+
async function updateCachedLag(state, leaderUrl, localLastSeq) {
|
|
509
|
+
try {
|
|
510
|
+
const leaderStatusUrl = new URL("/cluster/status", leaderUrl).toString();
|
|
511
|
+
const headers = { Accept: "application/json" };
|
|
512
|
+
if (state.cluster.forwardAuthToken) {
|
|
513
|
+
headers.Authorization = `Bearer ${state.cluster.forwardAuthToken}`;
|
|
514
|
+
}
|
|
515
|
+
const res = await fetch(leaderStatusUrl, {
|
|
516
|
+
headers,
|
|
517
|
+
signal: AbortSignal.timeout(5_000),
|
|
518
|
+
});
|
|
519
|
+
if (!res.ok) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const leaderStatus = (await res.json());
|
|
523
|
+
const leaderSeq = leaderStatus?.replication?.lastReplicatedSequence;
|
|
524
|
+
if (typeof leaderSeq === "number") {
|
|
525
|
+
state.cluster.cachedReplicationLag = Math.max(0, leaderSeq - localLastSeq);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
// Non-fatal — lag will remain at the last known value.
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Attempt a static-config leader failover from this follower.
|
|
534
|
+
* Returns true if this node promoted itself to leader.
|
|
535
|
+
*/
|
|
536
|
+
function attemptFollowerFailover(state) {
|
|
537
|
+
if (state.replicationStopped) {
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
const candidate = findNextLeaderCandidate(state.cluster);
|
|
541
|
+
if (!candidate) {
|
|
542
|
+
console.error("Leader failover: no candidate found in peer list");
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
if (candidate.isSelf) {
|
|
546
|
+
// Promote this node to leader.
|
|
547
|
+
console.error(`Leader failover: promoting self (${candidate.url}) to leader`);
|
|
548
|
+
state.db = createReplicatingDb(state.originalDb, "leader");
|
|
549
|
+
state.cluster.mode = "leader";
|
|
550
|
+
state.cluster.leaderUrl = candidate.url;
|
|
551
|
+
state.cluster.activeLeaderUrl = candidate.url;
|
|
552
|
+
state.cluster.fallbackLeaderUrl = undefined;
|
|
553
|
+
stopReplicationRunner(state);
|
|
554
|
+
state.consecutiveReplicationFailures = 0;
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
// Redirect to the next candidate leader.
|
|
558
|
+
console.error(`Leader failover: redirecting to ${candidate.url}`);
|
|
559
|
+
state.cluster.leaderUrl = candidate.url;
|
|
560
|
+
state.cluster.activeLeaderUrl = undefined;
|
|
561
|
+
state.cluster.fallbackLeaderUrl = undefined;
|
|
562
|
+
state.consecutiveReplicationFailures = 0;
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
async function handleReplicationEvents(state, request, response) {
|
|
566
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
567
|
+
response.setHeader("Allow", "GET, HEAD, OPTIONS");
|
|
568
|
+
writeJson(response, 405, { error: "method_not_allowed" });
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const url = new URL(request.url ?? "", "http://localhost");
|
|
572
|
+
const afterStr = url.searchParams.get("after");
|
|
573
|
+
const afterSeq = afterStr ? Number.parseInt(afterStr, 10) : 0;
|
|
574
|
+
try {
|
|
575
|
+
const filteredEvents = await state.db.events.list("__thingd:system:replication", {
|
|
576
|
+
fromSequence: afterSeq,
|
|
577
|
+
});
|
|
578
|
+
writeJson(response, 200, {
|
|
579
|
+
success: true,
|
|
580
|
+
events: filteredEvents,
|
|
581
|
+
}, request.method === "HEAD");
|
|
582
|
+
}
|
|
583
|
+
catch (error) {
|
|
584
|
+
writeJson(response, 500, {
|
|
585
|
+
error: error instanceof Error ? error.message : String(error),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { appendMcpAuditEvent, createThingdMcpServer, jsonResult, parseCollectionAllowlist, parsePayloadSizeLimit, type RegisterThingdToolsOptions, readMcpHardeningOptionsFromEnv, registerThingdTools, resolveThingdMcpAuditOptions, type ThingdMcpAuditMetadata, type ThingdMcpAuditOptions, type ThingdMcpHardeningOptions, type ThingdMcpServerOptions, } from "@thingd/node";
|
|
2
|
+
export type { ResolvedThingdClusterOptions, ThingdClusterDiscovery, ThingdClusterMode, ThingdClusterOptions, ThingdClusterStatus, } from "./cluster.js";
|
|
3
|
+
export type { RunningThingdHttpServer, ThingdHttpServerOptions } from "./http.js";
|
|
4
|
+
export { startThingdHttpServer } from "./http.js";
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/mcp/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,mBAAmB,EACnB,qBAAqB,EACrB,UAAU,EACV,wBAAwB,EACxB,qBAAqB,EACrB,KAAK,0BAA0B,EAC/B,8BAA8B,EAC9B,mBAAmB,EACnB,4BAA4B,EAC5B,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,yBAAyB,EAC9B,KAAK,sBAAsB,GAC5B,MAAM,cAAc,CAAC;AAGtB,YAAY,EACV,4BAA4B,EAC5B,sBAAsB,EACtB,iBAAiB,EACjB,oBAAoB,EACpB,mBAAmB,GACpB,MAAM,cAAc,CAAC;AACtB,YAAY,EAAE,uBAAuB,EAAE,uBAAuB,EAAE,MAAM,WAAW,CAAC;AAClF,OAAO,EAAE,qBAAqB,EAAE,MAAM,WAAW,CAAC"}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// Re-export core MCP logic from the SDK
|
|
2
|
+
export { appendMcpAuditEvent, createThingdMcpServer, jsonResult, parseCollectionAllowlist, parsePayloadSizeLimit, readMcpHardeningOptionsFromEnv, registerThingdTools, resolveThingdMcpAuditOptions, } from "@thingd/node";
|
|
3
|
+
export { startThingdHttpServer } from "./http.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"result.d.ts","sourceRoot":"","sources":["../../src/mcp/result.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAEzE,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,cAAc,CASzD"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { ThingD } from "thingd";
|
|
3
|
+
import type { ThingdMcpAuditOptions } from "./audit.js";
|
|
4
|
+
import type { ThingdMcpHardeningOptions } from "./config.js";
|
|
5
|
+
export type ThingdMcpServerOptions = {
|
|
6
|
+
audit?: ThingdMcpAuditOptions | false;
|
|
7
|
+
hardening?: ThingdMcpHardeningOptions;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Create a new McpServer with all thingd tools registered.
|
|
11
|
+
*
|
|
12
|
+
* Note: a new instance must be created per HTTP request because
|
|
13
|
+
* @modelcontextprotocol/sdk's underlying Server.connect() throws if called
|
|
14
|
+
* on an already-connected instance (stateless HTTP mode: one transport per
|
|
15
|
+
* request lifecycle). Tool registration is cheap (Map insertions) so this
|
|
16
|
+
* is not a meaningful overhead in practice.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createThingdMcpServer(db: ThingD, options?: ThingdMcpServerOptions): McpServer;
|
|
19
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAErC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AACxD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AAG7D,MAAM,MAAM,sBAAsB,GAAG;IACnC,KAAK,CAAC,EAAE,qBAAqB,GAAG,KAAK,CAAC;IACtC,SAAS,CAAC,EAAE,yBAAyB,CAAC;CACvC,CAAC;AAEF;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,GAAE,sBAA2B,GAAG,SAAS,CAwDjG"}
|