@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.
@@ -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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"}
@@ -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.4.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"