@macula-io/mcp 0.4.0 → 0.5.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/README.md +94 -35
- package/dist/bin/doctor.js +30 -2
- package/dist/bin/doctor.js.map +1 -1
- package/dist/bin/install.js +2 -2
- package/dist/bin/install.js.map +1 -1
- package/dist/bin/status.js +2 -2
- package/dist/bin/status.js.map +1 -1
- package/dist/bin/uninstall.js +1 -1
- package/dist/index.js +31 -14
- package/dist/index.js.map +1 -1
- package/dist/macula_cli.d.ts +71 -0
- package/dist/macula_cli.js +153 -20
- package/dist/macula_cli.js.map +1 -1
- package/dist/mesh_agents.d.ts +2 -0
- package/dist/mesh_agents.js +47 -0
- package/dist/mesh_agents.js.map +1 -0
- package/dist/mesh_etiquette.js +36 -7
- package/dist/mesh_etiquette.js.map +1 -1
- package/dist/mesh_goodbye.d.ts +2 -0
- package/dist/mesh_goodbye.js +24 -0
- package/dist/mesh_goodbye.js.map +1 -0
- package/dist/mesh_hello.d.ts +2 -0
- package/dist/mesh_hello.js +73 -0
- package/dist/mesh_hello.js.map +1 -0
- package/dist/mesh_help.js +22 -7
- package/dist/mesh_help.js.map +1 -1
- package/dist/presence.d.ts +22 -0
- package/dist/presence.js +217 -0
- package/dist/presence.js.map +1 -0
- package/dist/roster.d.ts +26 -0
- package/dist/roster.js +93 -0
- package/dist/roster.js.map +1 -0
- package/package.json +3 -1
package/dist/presence.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Presence: this macula-mcp process's own "being on the mesh" state --
|
|
2
|
+
// a periodic agent.hello heartbeat, and durable subscriptions to
|
|
3
|
+
// agent.hello/agent.goodbye from everyone else, feeding the local
|
|
4
|
+
// roster (roster.ts). mesh_hello.ts/mesh_goodbye.ts/mesh_agents.ts are
|
|
5
|
+
// thin tool wrappers around start()/stop()/roster reads; this module
|
|
6
|
+
// owns the actual lifecycle.
|
|
7
|
+
//
|
|
8
|
+
// mesh_watch.ts's own doc comment explains why a standing subscription
|
|
9
|
+
// was deliberately NOT built before: macula-cli had no daemon, so
|
|
10
|
+
// "macula-mcp itself becoming a stateful daemon" was a real fork not
|
|
11
|
+
// taken. macula-cli has a real daemon now (serve/call/pubsub over a
|
|
12
|
+
// persistent connection, see its own README's Daemon mode section) --
|
|
13
|
+
// this module is macula-mcp finally taking that fork, scoped narrowly
|
|
14
|
+
// to what presence needs: one internally-managed macula-cli daemon,
|
|
15
|
+
// used ONLY to hold two durable subscriptions alive. The periodic
|
|
16
|
+
// PUBLISH side does NOT need it -- macula-cli's daemon protocol has no
|
|
17
|
+
// publish-via-daemon method (only call/serve/subscribe), so each
|
|
18
|
+
// heartbeat is an ordinary one-shot `pubsub publish`, exactly like
|
|
19
|
+
// mesh_publish already does.
|
|
20
|
+
import { randomBytes } from "node:crypto";
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import { binPath, defaultStation, identity, onShutdown, parseWatchLine, presenceIdentityPath, publish, MaculaCliError, } from "./macula_cli.js";
|
|
23
|
+
import { removeAgent, upsertAgent } from "./roster.js";
|
|
24
|
+
export const HELLO_TOPIC = "agent.hello";
|
|
25
|
+
export const GOODBYE_TOPIC = "agent.goodbye";
|
|
26
|
+
const DEFAULT_INTERVAL_SECONDS = 60;
|
|
27
|
+
/** Never let a misconfigured caller hammer a shared demo station. */
|
|
28
|
+
const MIN_INTERVAL_SECONDS = 10;
|
|
29
|
+
let state;
|
|
30
|
+
export function isActive() {
|
|
31
|
+
return state !== undefined;
|
|
32
|
+
}
|
|
33
|
+
/** Idempotent: a second call just updates operatorName/message for future heartbeats. */
|
|
34
|
+
export async function start(args) {
|
|
35
|
+
const host = args.host ?? defaultStation();
|
|
36
|
+
const intervalSeconds = Math.max(MIN_INTERVAL_SECONDS, args.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS);
|
|
37
|
+
if (state) {
|
|
38
|
+
state.operatorName = args.operatorName ?? state.operatorName;
|
|
39
|
+
state.message = args.message ?? state.message;
|
|
40
|
+
return {
|
|
41
|
+
node_id: state.nodeId,
|
|
42
|
+
connected_to: state.host,
|
|
43
|
+
interval_seconds: intervalSeconds,
|
|
44
|
+
already_active: true,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const { node_id: nodeId } = await identity();
|
|
48
|
+
const socketName = `presence-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
49
|
+
const daemon = await startDaemon(host, socketName);
|
|
50
|
+
const watchers = [
|
|
51
|
+
watchTopic(socketName, HELLO_TOPIC, (evt) => {
|
|
52
|
+
const payload = evt;
|
|
53
|
+
const seenNodeId = typeof payload.node_id === "string" ? payload.node_id : undefined;
|
|
54
|
+
if (!seenNodeId)
|
|
55
|
+
return;
|
|
56
|
+
upsertAgent({
|
|
57
|
+
node_id: seenNodeId,
|
|
58
|
+
operator_name: typeof payload.operator_name === "string" ? payload.operator_name : undefined,
|
|
59
|
+
message: typeof payload.message === "string" ? payload.message : undefined,
|
|
60
|
+
at: new Date().toISOString(),
|
|
61
|
+
});
|
|
62
|
+
}),
|
|
63
|
+
watchTopic(socketName, GOODBYE_TOPIC, (evt) => {
|
|
64
|
+
const payload = evt;
|
|
65
|
+
if (typeof payload.node_id === "string")
|
|
66
|
+
removeAgent(payload.node_id);
|
|
67
|
+
}),
|
|
68
|
+
];
|
|
69
|
+
const heartbeatTimer = setInterval(() => void beat(), intervalSeconds * 1000);
|
|
70
|
+
heartbeatTimer.unref(); // a pending heartbeat alone shouldn't keep the process alive
|
|
71
|
+
state = { nodeId, operatorName: args.operatorName, message: args.message, host, socketName, daemon, watchers, heartbeatTimer };
|
|
72
|
+
onShutdown(stopSync);
|
|
73
|
+
await beat(); // announce immediately rather than waiting a full interval
|
|
74
|
+
return { node_id: nodeId, connected_to: host, interval_seconds: intervalSeconds, already_active: false };
|
|
75
|
+
}
|
|
76
|
+
async function beat() {
|
|
77
|
+
if (!state)
|
|
78
|
+
return;
|
|
79
|
+
await publish({
|
|
80
|
+
host: state.host,
|
|
81
|
+
topic: HELLO_TOPIC,
|
|
82
|
+
fact: {
|
|
83
|
+
node_id: state.nodeId,
|
|
84
|
+
...(state.operatorName ? { operator_name: state.operatorName } : {}),
|
|
85
|
+
...(state.message ? { message: state.message } : {}),
|
|
86
|
+
at: new Date().toISOString(),
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** Publishes agent.goodbye, then tears everything down. No-op if not active. */
|
|
91
|
+
export async function stop() {
|
|
92
|
+
if (!state)
|
|
93
|
+
return { said_goodbye: false };
|
|
94
|
+
const { nodeId, host } = state;
|
|
95
|
+
let saidGoodbye = false;
|
|
96
|
+
try {
|
|
97
|
+
await publish({ host, topic: GOODBYE_TOPIC, fact: { node_id: nodeId, at: new Date().toISOString() } });
|
|
98
|
+
saidGoodbye = true;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// best effort -- still tear down locally even if the mesh is unreachable
|
|
102
|
+
}
|
|
103
|
+
stopSync();
|
|
104
|
+
return { said_goodbye: saidGoodbye };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Synchronous teardown only (kill child processes, clear the timer) --
|
|
108
|
+
* this is what onShutdown registers, since a SIGINT/SIGTERM handler
|
|
109
|
+
* cannot reliably wait on the async goodbye-publish above. The
|
|
110
|
+
* explicit stop() (mesh_goodbye) is the reliable way to leave
|
|
111
|
+
* gracefully; an abrupt process kill just stops heartbeating, and
|
|
112
|
+
* everyone else's roster ages this node out on its own via
|
|
113
|
+
* last_seen_at once the ordinary heartbeat stops arriving.
|
|
114
|
+
*/
|
|
115
|
+
function stopSync() {
|
|
116
|
+
if (!state)
|
|
117
|
+
return;
|
|
118
|
+
clearInterval(state.heartbeatTimer);
|
|
119
|
+
state.daemon.kill();
|
|
120
|
+
for (const w of state.watchers)
|
|
121
|
+
w.kill();
|
|
122
|
+
state = undefined;
|
|
123
|
+
}
|
|
124
|
+
function startDaemon(host, socketName) {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const child = spawn(binPath(), [
|
|
127
|
+
"daemon",
|
|
128
|
+
"start",
|
|
129
|
+
"--json",
|
|
130
|
+
"--identity",
|
|
131
|
+
presenceIdentityPath(),
|
|
132
|
+
"-socket-name",
|
|
133
|
+
socketName,
|
|
134
|
+
host,
|
|
135
|
+
]);
|
|
136
|
+
// daemon start --json pretty-prints its readiness envelope across
|
|
137
|
+
// multiple lines (report.emit's indented encoder -- the SAME shape
|
|
138
|
+
// every other one-shot --json command uses, unlike pubsub watch's
|
|
139
|
+
// deliberately single-line-per-event NDJSON). So this can't look
|
|
140
|
+
// for a first newline the way watchTopic does; it has to keep
|
|
141
|
+
// accumulating and re-attempt a parse of the WHOLE buffer until one
|
|
142
|
+
// succeeds, since there's no framing signal cheaper than "is this
|
|
143
|
+
// valid JSON yet" for a pretty-printed value of unknown length.
|
|
144
|
+
let buf = "";
|
|
145
|
+
let settled = false;
|
|
146
|
+
const onData = (chunk) => {
|
|
147
|
+
buf += chunk.toString("utf8");
|
|
148
|
+
let parsed;
|
|
149
|
+
try {
|
|
150
|
+
parsed = JSON.parse(buf.trim());
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return; // not a complete JSON value yet -- wait for more chunks
|
|
154
|
+
}
|
|
155
|
+
child.stdout.off("data", onData);
|
|
156
|
+
settled = true;
|
|
157
|
+
if (parsed.ok) {
|
|
158
|
+
child.unref(); // see watchTopic's own comment on why
|
|
159
|
+
resolve(child);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
reject(new MaculaCliError(parsed.error?.message ?? "daemon start failed"));
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
child.stdout.on("data", onData);
|
|
166
|
+
child.on("error", (e) => {
|
|
167
|
+
if (!settled) {
|
|
168
|
+
settled = true;
|
|
169
|
+
reject(e);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
child.on("exit", (code) => {
|
|
173
|
+
if (!settled) {
|
|
174
|
+
settled = true;
|
|
175
|
+
reject(new MaculaCliError(`daemon start exited before announcing readiness (code ${code})`));
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
function watchTopic(socketName, topic, onEvent) {
|
|
181
|
+
const child = spawn(binPath(), [
|
|
182
|
+
"pubsub",
|
|
183
|
+
"watch",
|
|
184
|
+
"-daemon",
|
|
185
|
+
"--json",
|
|
186
|
+
"-socket-name",
|
|
187
|
+
socketName,
|
|
188
|
+
topic,
|
|
189
|
+
]);
|
|
190
|
+
// A held-open child process is ref'd by default and would keep this
|
|
191
|
+
// MCP server's Node process alive on its own even after the MCP
|
|
192
|
+
// client disconnects and there's nothing else left to do -- unref so
|
|
193
|
+
// presence is background infrastructure, not a reason to stay up.
|
|
194
|
+
// onShutdown's stopSync() still explicitly kills it either way.
|
|
195
|
+
child.unref();
|
|
196
|
+
let buf = "";
|
|
197
|
+
child.stdout.on("data", (chunk) => {
|
|
198
|
+
buf += chunk.toString("utf8");
|
|
199
|
+
let nl;
|
|
200
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
201
|
+
const line = buf.slice(0, nl);
|
|
202
|
+
buf = buf.slice(nl + 1);
|
|
203
|
+
try {
|
|
204
|
+
const evt = parseWatchLine(line);
|
|
205
|
+
if (evt)
|
|
206
|
+
onEvent(evt.payload);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// a trailing failure envelope on this line -- the connection is
|
|
210
|
+
// presumably gone; nothing more will arrive on it, so just stop
|
|
211
|
+
// trying to parse further lines from this child.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
return child;
|
|
216
|
+
}
|
|
217
|
+
//# sourceMappingURL=presence.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"presence.js","sourceRoot":"","sources":["../src/presence.ts"],"names":[],"mappings":"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"}
|
package/dist/roster.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface AgentRecord {
|
|
2
|
+
node_id: string;
|
|
3
|
+
operator_name: string | null;
|
|
4
|
+
message: string | null;
|
|
5
|
+
first_seen_at: string;
|
|
6
|
+
last_seen_at: string;
|
|
7
|
+
}
|
|
8
|
+
/** Records (or refreshes) one agent.hello sighting. Idempotent per node_id. */
|
|
9
|
+
export declare function upsertAgent(rec: {
|
|
10
|
+
node_id: string;
|
|
11
|
+
operator_name?: string;
|
|
12
|
+
message?: string;
|
|
13
|
+
at: string;
|
|
14
|
+
}): void;
|
|
15
|
+
/** Removes one agent immediately -- called on receiving its agent.goodbye. */
|
|
16
|
+
export declare function removeAgent(nodeId: string): void;
|
|
17
|
+
export interface RosterPage {
|
|
18
|
+
total: number;
|
|
19
|
+
agents: AgentRecord[];
|
|
20
|
+
}
|
|
21
|
+
/** Most-recently-seen first. page is 1-based. */
|
|
22
|
+
export declare function listAgents(page: number, pageSize: number): RosterPage;
|
|
23
|
+
/** Drops agents not seen in maxAgeSeconds -- lazy cleanup, not a background timer. Returns rows removed. */
|
|
24
|
+
export declare function pruneStale(maxAgeSeconds: number): number;
|
|
25
|
+
/** Test/shutdown hook -- releases the file handle. Re-opens lazily on next use. */
|
|
26
|
+
export declare function closeRoster(): void;
|
package/dist/roster.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// A local, persistent cache of "who else is on the mesh" -- built
|
|
2
|
+
// entirely from consuming agent.hello/agent.goodbye facts, the same
|
|
3
|
+
// shape as a QRY-department read model built from events elsewhere in
|
|
4
|
+
// this codebase family: not an event store, just a projection over
|
|
5
|
+
// what's been observed, disposable and rebuildable from the mesh at
|
|
6
|
+
// any time (a fresh macula-mcp process starts with an empty roster and
|
|
7
|
+
// repopulates it from whoever's still heartbeating).
|
|
8
|
+
//
|
|
9
|
+
// SQLite over an in-memory Map deliberately: a Map dies with the
|
|
10
|
+
// process, and "who's on the mesh" is more useful if a restart doesn't
|
|
11
|
+
// forget everyone seen minutes ago. SQLite over PouchDB: PouchDB's
|
|
12
|
+
// whole value is offline-first sync/replication with conflict
|
|
13
|
+
// resolution across replicas -- nothing here replicates anywhere, it's
|
|
14
|
+
// one process's own local cache, and pulling in PouchDB's dependency
|
|
15
|
+
// weight for a single-writer key-value table would be solving a
|
|
16
|
+
// problem this doesn't have.
|
|
17
|
+
//
|
|
18
|
+
// better-sqlite3 is pinned to the 12.x line, NOT latest (13.x)
|
|
19
|
+
// deliberately: 13.0.3 requires Node >=22 and segfaults outright under
|
|
20
|
+
// this project's own CI-tested Node 20 (confirmed live -- exit 139,
|
|
21
|
+
// reproduced locally under Node 20 via asdf before landing the fix,
|
|
22
|
+
// npm only warns on the engine mismatch rather than failing the
|
|
23
|
+
// install, so this is silent until something actually touches the
|
|
24
|
+
// native binding). 12.11.1 explicitly declares support for 20.x --
|
|
25
|
+
// don't bump past the 12.x line without first confirming the new
|
|
26
|
+
// version's own declared `engines` covers Node 20, not just running
|
|
27
|
+
// `npm test` on whatever Node happens to be on the developer's PATH.
|
|
28
|
+
import Database from "better-sqlite3";
|
|
29
|
+
import { mkdirSync } from "node:fs";
|
|
30
|
+
import { homedir } from "node:os";
|
|
31
|
+
import { dirname, join } from "node:path";
|
|
32
|
+
function dbPath() {
|
|
33
|
+
return process.env.MACULA_MCP_ROSTER_DB ?? join(homedir(), ".macula-mcp", "roster.sqlite3");
|
|
34
|
+
}
|
|
35
|
+
let db;
|
|
36
|
+
function open() {
|
|
37
|
+
if (db)
|
|
38
|
+
return db;
|
|
39
|
+
const path = dbPath();
|
|
40
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
41
|
+
db = new Database(path);
|
|
42
|
+
db.pragma("journal_mode = WAL");
|
|
43
|
+
db.exec(`
|
|
44
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
45
|
+
node_id TEXT PRIMARY KEY,
|
|
46
|
+
operator_name TEXT,
|
|
47
|
+
message TEXT,
|
|
48
|
+
first_seen_at TEXT NOT NULL,
|
|
49
|
+
last_seen_at TEXT NOT NULL
|
|
50
|
+
)
|
|
51
|
+
`);
|
|
52
|
+
return db;
|
|
53
|
+
}
|
|
54
|
+
/** Records (or refreshes) one agent.hello sighting. Idempotent per node_id. */
|
|
55
|
+
export function upsertAgent(rec) {
|
|
56
|
+
open()
|
|
57
|
+
.prepare(`INSERT INTO agents (node_id, operator_name, message, first_seen_at, last_seen_at)
|
|
58
|
+
VALUES (@node_id, @operator_name, @message, @at, @at)
|
|
59
|
+
ON CONFLICT(node_id) DO UPDATE SET
|
|
60
|
+
operator_name = excluded.operator_name,
|
|
61
|
+
message = excluded.message,
|
|
62
|
+
last_seen_at = excluded.last_seen_at`)
|
|
63
|
+
.run({
|
|
64
|
+
node_id: rec.node_id,
|
|
65
|
+
operator_name: rec.operator_name ?? null,
|
|
66
|
+
message: rec.message ?? null,
|
|
67
|
+
at: rec.at,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Removes one agent immediately -- called on receiving its agent.goodbye. */
|
|
71
|
+
export function removeAgent(nodeId) {
|
|
72
|
+
open().prepare("DELETE FROM agents WHERE node_id = ?").run(nodeId);
|
|
73
|
+
}
|
|
74
|
+
/** Most-recently-seen first. page is 1-based. */
|
|
75
|
+
export function listAgents(page, pageSize) {
|
|
76
|
+
const d = open();
|
|
77
|
+
const total = d.prepare("SELECT COUNT(*) AS n FROM agents").get().n;
|
|
78
|
+
const agents = d
|
|
79
|
+
.prepare("SELECT * FROM agents ORDER BY last_seen_at DESC LIMIT ? OFFSET ?")
|
|
80
|
+
.all(pageSize, (Math.max(1, page) - 1) * pageSize);
|
|
81
|
+
return { total, agents };
|
|
82
|
+
}
|
|
83
|
+
/** Drops agents not seen in maxAgeSeconds -- lazy cleanup, not a background timer. Returns rows removed. */
|
|
84
|
+
export function pruneStale(maxAgeSeconds) {
|
|
85
|
+
const cutoff = new Date(Date.now() - maxAgeSeconds * 1000).toISOString();
|
|
86
|
+
return open().prepare("DELETE FROM agents WHERE last_seen_at < ?").run(cutoff).changes;
|
|
87
|
+
}
|
|
88
|
+
/** Test/shutdown hook -- releases the file handle. Re-opens lazily on next use. */
|
|
89
|
+
export function closeRoster() {
|
|
90
|
+
db?.close();
|
|
91
|
+
db = undefined;
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=roster.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"roster.js","sourceRoot":"","sources":["../src/roster.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@macula-io/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Model Context Protocol server that exposes the Macula mesh to any agent harness, via macula-cli",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -43,9 +43,11 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
46
|
+
"better-sqlite3": "^12.11.1",
|
|
46
47
|
"zod": "^3.23.0"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
50
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
49
51
|
"@types/node": "^20.0.0",
|
|
50
52
|
"typescript": "^5.4.0",
|
|
51
53
|
"vitest": "^1.0.0"
|