@spooky-sync/devtools-mcp 0.0.1-canary.32 → 0.0.1-canary.321

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/AGENTS.md ADDED
@@ -0,0 +1,64 @@
1
+ # `@spooky-sync/devtools-mcp` (`sp00ky-mcp`) — agent guide
2
+
3
+ ## What this package is
4
+
5
+ A Model Context Protocol server that gives an AI assistant live, structured access to a running sp00ky app. It bridges to either:
6
+
7
+ 1. **The Sp00ky DevTools browser extension** (via WebSocket on the side channel `bridge.ts` opens), so the assistant sees the *exact* state the user's tab has — local cache, active live queries, event history, auth state.
8
+ 2. **A direct SurrealDB connection** (set `SURREAL_URL`, `SURREAL_USER`, `SURREAL_PASS`, `SURREAL_NS`, `SURREAL_DB`) — useful when there's no browser tab attached.
9
+
10
+ Most tools transparently fall through: extension first, raw DB as a fallback. A handful (auth state, event history clear) require the extension.
11
+
12
+ ## Run it
13
+
14
+ ```bash
15
+ npx @spooky-sync/devtools-mcp
16
+ # or, from inside an app, via the CLI passthrough:
17
+ spky mcp
18
+ ```
19
+
20
+ Wire into Claude / your agent via the MCP server config (`.mcp.json` at the repo root or your client's equivalent).
21
+
22
+ ## Tools exposed
23
+
24
+ ### Connection / state
25
+
26
+ - **`list_connections`** — which browser tabs are currently bridged.
27
+ - **`get_state` `[tabId]`** — full devtools state: queries, mutations, auth, database tables, versions (extension only).
28
+ - **`get_auth_state` `[tabId]`** — current auth subject + scope (extension only).
29
+ - **`get_active_queries` `[tabId]`** — registered live queries, their last result hashes, and a per-query `timings` breakdown.
30
+ - **`get_query_timings` `[tabId]`** — per-query processing-time breakdown (SSP parse/plan/snapshot + store-apply/circuit-step/transform, local & remote record fetch, frontend reconcile; each as last/p50/p90/p99), sorted slowest-first. For debugging perf.
31
+ - **`get_mutations` `[status] [includeTray=true] [tabId]`** — the outbox (pending / retrying with attempts), writes that left it since DevTools attached (synced with latency, or rolled back with the server error), debounced updates, tab role + connection, and the failed-writes tray rows with payload and before image (extension only).
32
+ - **`get_logs` `[minLevel] [contains] [after] [limit=100] [captureLevel] [tabId]`** — the client's own log lines (last 500 kept in the page), each with its structured fields. `after` = a previous `head` reads only new lines; `captureLevel: 'debug'` records debug lines without printing them to the console, for the tab's session (extension only).
33
+
34
+ ### Database (works against extension OR direct DB)
35
+
36
+ - **`run_query` `query` `[target=local|remote]` `[tabId]`** — execute arbitrary SurQL.
37
+ - **`list_tables` `[tabId]`** — names of tables defined in the running schema.
38
+ - **`get_table_data` `tableName` `[limit]` `[tabId]`** — `SELECT * LIMIT n` shortcut.
39
+ - **`update_table_row` `recordId` `updates` `[tableName] [tabId]`** — `UPDATE recordId MERGE updates`.
40
+ - **`delete_table_row` `recordId` `[tableName] [tabId]`** — `DELETE recordId`.
41
+
42
+ ### Resources
43
+
44
+ - `sp00ky://state` — full state JSON (extension only).
45
+ - `sp00ky://tables` — table list.
46
+
47
+ ## How an agent should use this
48
+
49
+ - **Before writing a query against a schema you haven't seen:** call `list_tables` and `get_table_data` with `limit: 1` on each table to learn the columns.
50
+ - **After making a mutation through `db.update`:** call `get_mutations` to confirm it synced (or read why it was rolled back), then `get_active_queries` to confirm subscribed queries refreshed.
51
+ - **When something fails without an obvious error:** `get_logs` with `minLevel: 'warn'`; set `captureLevel: 'debug'`, reproduce, and read again with `after` set to the earlier `head`.
52
+ - **When troubleshooting "why isn't this query updating?":** `get_active_queries` shows the registered SurQL and last result hash; `get_logs` with `captureLevel: 'debug'` then shows each `StreamUpdate` ingest and every statement the stores ran (category `sp00ky-client::local` / `::remote`, the SurrealQL as the message).
53
+ - **For schema spelunking from outside the browser:** point `SURREAL_URL` at the dev DB and use `run_query` with `INFO FOR DB;`.
54
+
55
+ ## Common gotchas
56
+
57
+ - **Mutations through this MCP bypass the local mutation queue.** `update_table_row` calls remote SurrealDB directly (or the extension's bridge). The local cache will see the change only when SSP pushes the live update back. For testing in-app, prefer driving mutations through the page itself.
58
+ - **Tab selection matters.** Multi-tab dev: pass `tabId` explicitly or you get whichever tab connected first.
59
+ - **Direct-DB fallback has a smaller capability set.** Anything that reads in-memory devtools state (auth, mutations, logs, active queries) only works with the extension.
60
+
61
+ ## Pointers
62
+
63
+ - Sync engine that emits the events this server reads: `node_modules/@spooky-sync/core/AGENTS.md` → `src/modules/devtools/`
64
+ - CLI passthrough: `node_modules/@spooky-sync/cli/AGENTS.md` (`spky mcp`)
package/dist/bridge.js CHANGED
@@ -15,7 +15,7 @@ export class Bridge {
15
15
  return Array.from(this.connectedTabs.values());
16
16
  }
17
17
  start() {
18
- const port = parseInt(process.env.SP00KY_MCP_PORT || '', 10) || BRIDGE_PORT;
18
+ const port = Number.parseInt(process.env.SP00KY_MCP_PORT || '', 10) || BRIDGE_PORT;
19
19
  return new Promise((resolve, reject) => {
20
20
  this.wss = new WebSocketServer({ host: '127.0.0.1', port }, () => {
21
21
  process.stderr.write(`[sp00ky-mcp] Bridge listening on ws://127.0.0.1:${port}\n`);
@@ -124,6 +124,7 @@ export class Bridge {
124
124
  reject(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms: ${method}`));
125
125
  }, REQUEST_TIMEOUT_MS);
126
126
  this.pendingRequests.set(id, { resolve, reject, timer });
127
+ // oxlint-disable-next-line no-non-null-assertion
127
128
  this.extensionSocket.send(JSON.stringify(request));
128
129
  });
129
130
  }
package/dist/index.js CHANGED
@@ -1,11 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { Bridge } from './bridge.js';
4
+ import { SurrealClient } from './surreal.js';
4
5
  import { createServer } from './server.js';
5
6
  async function main() {
6
7
  const bridge = new Bridge();
7
8
  await bridge.start();
8
- const server = createServer(bridge);
9
+ const surreal = process.env.SURREAL_URL
10
+ ? new SurrealClient({
11
+ url: process.env.SURREAL_URL,
12
+ namespace: process.env.SURREAL_NS ?? 'main',
13
+ database: process.env.SURREAL_DB ?? 'main',
14
+ username: process.env.SURREAL_USER ?? 'root',
15
+ password: process.env.SURREAL_PASS ?? 'root',
16
+ })
17
+ : null;
18
+ if (surreal) {
19
+ process.stderr.write(`[sp00ky-mcp] Direct DB mode enabled (${process.env.SURREAL_URL})\n`);
20
+ }
21
+ const server = createServer(bridge, surreal);
9
22
  const transport = new StdioServerTransport();
10
23
  await server.connect(transport);
11
24
  process.stderr.write('[sp00ky-mcp] MCP server running on stdio\n');
@@ -24,9 +24,11 @@ export declare const BRIDGE_METHODS: {
24
24
  readonly GET_STATE: "getState";
25
25
  readonly RUN_QUERY: "runQuery";
26
26
  readonly GET_TABLE_DATA: "getTableData";
27
+ readonly GET_QUERY_ROWS: "getQueryRows";
27
28
  readonly UPDATE_TABLE_ROW: "updateTableRow";
28
29
  readonly DELETE_TABLE_ROW: "deleteTableRow";
29
- readonly CLEAR_HISTORY: "clearHistory";
30
+ /** `{ method: 'mutationOp' | 'logOp', op, args }`: the Mutations / Logs dispatchers. */
31
+ readonly PAGE_OP: "pageOp";
30
32
  };
31
33
  export declare const BRIDGE_PORT = 9315;
32
34
  export declare function isBridgeResponse(msg: unknown): msg is BridgeResponse;
package/dist/protocol.js CHANGED
@@ -4,9 +4,11 @@ export const BRIDGE_METHODS = {
4
4
  GET_STATE: 'getState',
5
5
  RUN_QUERY: 'runQuery',
6
6
  GET_TABLE_DATA: 'getTableData',
7
+ GET_QUERY_ROWS: 'getQueryRows',
7
8
  UPDATE_TABLE_ROW: 'updateTableRow',
8
9
  DELETE_TABLE_ROW: 'deleteTableRow',
9
- CLEAR_HISTORY: 'clearHistory',
10
+ /** `{ method: 'mutationOp' | 'logOp', op, args }`: the Mutations / Logs dispatchers. */
11
+ PAGE_OP: 'pageOp',
10
12
  };
11
13
  export const BRIDGE_PORT = 9315;
12
14
  export function isBridgeResponse(msg) {
package/dist/server.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { Bridge } from './bridge.js';
3
- export declare function createServer(bridge: Bridge): McpServer;
2
+ import type { Bridge } from './bridge.js';
3
+ import type { SurrealClient } from './surreal.js';
4
+ export declare function createServer(bridge: Bridge, surreal?: SurrealClient | null): McpServer;
package/dist/server.js CHANGED
@@ -1,120 +1,360 @@
1
1
  import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
3
  import { BRIDGE_METHODS } from './protocol.js';
4
- export function createServer(bridge) {
4
+ function json(data) {
5
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
6
+ }
7
+ const LOG_LEVELS = { trace: 10, debug: 20, info: 30, warn: 40, error: 50, fatal: 60 };
8
+ const LEVEL_NAMES = Object.keys(LOG_LEVELS);
9
+ const levelName = (level) => Object.entries(LOG_LEVELS).reduce((name, [n, v]) => (level >= v ? n : name), 'trace');
10
+ /** A captured line as the agent reads it: the JSON parsed, minus what is already a field. */
11
+ function readableLog(e) {
12
+ let data;
13
+ try {
14
+ const { time: _time, level: _level, msg: _msg, ...rest } = JSON.parse(e.line);
15
+ data = Object.keys(rest).length ? rest : undefined;
16
+ }
17
+ catch {
18
+ data = { truncated: e.line };
19
+ }
20
+ return { seq: e.seq, time: new Date(e.time).toISOString(), level: levelName(e.level), msg: e.msg, data };
21
+ }
22
+ export function createServer(bridge, surreal) {
5
23
  const server = new McpServer({
6
24
  name: 'sp00ky-devtools',
7
25
  version: '0.0.1',
8
26
  });
9
27
  // --- Tools ---
10
28
  server.tool('list_connections', 'List browser tabs connected to Sp00ky DevTools', {}, async () => {
11
- const tabs = bridge.getConnectedTabs();
12
- return {
13
- content: [
14
- {
15
- type: 'text',
16
- text: JSON.stringify({
17
- connected: bridge.isConnected,
18
- tabs,
19
- }, null, 2),
20
- },
21
- ],
22
- };
29
+ return json({ connected: bridge.isConnected, tabs: bridge.getConnectedTabs() });
23
30
  });
24
- server.tool('get_state', 'Get the full Sp00ky DevTools state (events, queries, auth, database)', { tabId: z.number().optional().describe('Browser tab ID (uses first connected tab if omitted)') }, async ({ tabId }) => {
31
+ server.tool('get_state', 'Get the full Sp00ky DevTools state (queries, mutations, auth, database, versions)', { tabId: z.number().optional().describe('Browser tab ID (uses first connected tab if omitted)') }, async ({ tabId }) => {
32
+ if (!bridge.isConnected) {
33
+ throw new Error('No extension connected. get_state requires the Sp00ky DevTools browser extension.');
34
+ }
25
35
  const result = await bridge.request(BRIDGE_METHODS.GET_STATE, {}, tabId);
26
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
36
+ return json(result);
27
37
  });
28
38
  server.tool('run_query', 'Execute a SurrealQL query against the database', {
29
39
  query: z.string().describe('SurrealQL query to execute'),
30
40
  target: z.enum(['local', 'remote']).optional().default('remote').describe('Query target: local or remote database'),
31
41
  tabId: z.number().optional().describe('Browser tab ID'),
32
42
  }, async ({ query, target, tabId }) => {
33
- const result = await bridge.request(BRIDGE_METHODS.RUN_QUERY, { query, target }, tabId);
34
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
43
+ if (bridge.isConnected) {
44
+ const result = await bridge.request(BRIDGE_METHODS.RUN_QUERY, { query, target }, tabId);
45
+ return json(result);
46
+ }
47
+ if (surreal) {
48
+ const result = await surreal.query(query);
49
+ return json(result);
50
+ }
51
+ throw new Error('No extension connected and no direct database configured. Set SURREAL_URL or connect the browser extension.');
35
52
  });
36
53
  server.tool('list_tables', 'List all database tables', { tabId: z.number().optional().describe('Browser tab ID') }, async ({ tabId }) => {
37
- const state = (await bridge.request(BRIDGE_METHODS.GET_STATE, {}, tabId));
38
- const tables = state?.database?.tables ?? [];
39
- return { content: [{ type: 'text', text: JSON.stringify(tables, null, 2) }] };
54
+ if (bridge.isConnected) {
55
+ const state = (await bridge.request(BRIDGE_METHODS.GET_STATE, {}, tabId));
56
+ const tables = state?.database?.tables ?? [];
57
+ return json(tables);
58
+ }
59
+ if (surreal) {
60
+ const result = await surreal.query('INFO FOR DB;');
61
+ const info = result;
62
+ const tables = info?.[0]?.result?.tables ?? info?.[0]?.tables ?? {};
63
+ return json(Object.keys(tables));
64
+ }
65
+ throw new Error('No extension connected and no direct database configured.');
40
66
  });
41
- server.tool('get_table_data', 'Fetch all records from a database table', {
67
+ server.tool('get_query_rows', 'Fetch the rows (data, localArray, remoteArray) of ONE active query by its queryHash. ' +
68
+ 'The state from get_active_queries carries counts and capped ids only.', {
69
+ queryHash: z.number().describe('queryHash from get_active_queries'),
70
+ tabId: z.number().optional().describe('Browser tab ID'),
71
+ }, async ({ queryHash, tabId }) => {
72
+ if (!bridge.isConnected)
73
+ throw new Error('No extension connected.');
74
+ const result = await bridge.request(BRIDGE_METHODS.GET_QUERY_ROWS, { queryHash }, tabId);
75
+ return json(result);
76
+ });
77
+ server.tool('get_table_data', 'Fetch records from a database table', {
42
78
  tableName: z.string().describe('Name of the table'),
79
+ limit: z.number().optional().default(100).describe('Max number of records to return'),
43
80
  tabId: z.number().optional().describe('Browser tab ID'),
44
- }, async ({ tableName, tabId }) => {
45
- const result = await bridge.request(BRIDGE_METHODS.GET_TABLE_DATA, { tableName }, tabId);
46
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
81
+ }, async ({ tableName, limit, tabId }) => {
82
+ if (bridge.isConnected) {
83
+ const result = await bridge.request(BRIDGE_METHODS.GET_TABLE_DATA, { tableName }, tabId);
84
+ return json(result);
85
+ }
86
+ if (surreal) {
87
+ const result = await surreal.query(`SELECT * FROM \`${tableName}\` LIMIT ${limit};`);
88
+ return json(result);
89
+ }
90
+ throw new Error('No extension connected and no direct database configured.');
47
91
  });
48
92
  server.tool('update_table_row', 'Update a record in a database table', {
49
- tableName: z.string().describe('Name of the table'),
50
- recordId: z.string().describe('Record ID to update'),
93
+ tableName: z.string().optional().describe('Name of the table (used when browser extension is connected)'),
94
+ recordId: z.string().describe('Record ID to update (e.g. "users:abc123")'),
51
95
  updates: z.record(z.unknown()).describe('Fields to update'),
52
96
  tabId: z.number().optional().describe('Browser tab ID'),
53
97
  }, async ({ tableName, recordId, updates, tabId }) => {
54
- const result = await bridge.request(BRIDGE_METHODS.UPDATE_TABLE_ROW, { tableName, recordId, updates }, tabId);
55
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
98
+ if (bridge.isConnected) {
99
+ const result = await bridge.request(BRIDGE_METHODS.UPDATE_TABLE_ROW, { tableName, recordId, updates }, tabId);
100
+ return json(result);
101
+ }
102
+ if (surreal) {
103
+ const result = await surreal.query(`UPDATE ${recordId} MERGE ${JSON.stringify(updates)};`);
104
+ return json(result);
105
+ }
106
+ throw new Error('No extension connected and no direct database configured.');
56
107
  });
57
108
  server.tool('delete_table_row', 'Delete a record from a database table', {
58
- tableName: z.string().describe('Name of the table'),
59
- recordId: z.string().describe('Record ID to delete'),
109
+ tableName: z.string().optional().describe('Name of the table (used when browser extension is connected)'),
110
+ recordId: z.string().describe('Record ID to delete (e.g. "users:abc123")'),
60
111
  tabId: z.number().optional().describe('Browser tab ID'),
61
112
  }, async ({ tableName, recordId, tabId }) => {
62
- const result = await bridge.request(BRIDGE_METHODS.DELETE_TABLE_ROW, { tableName, recordId }, tabId);
63
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
113
+ if (bridge.isConnected) {
114
+ const result = await bridge.request(BRIDGE_METHODS.DELETE_TABLE_ROW, { tableName, recordId }, tabId);
115
+ return json(result);
116
+ }
117
+ if (surreal) {
118
+ const result = await surreal.query(`DELETE ${recordId};`);
119
+ return json(result);
120
+ }
121
+ throw new Error('No extension connected and no direct database configured.');
64
122
  });
65
- server.tool('get_active_queries', 'Get all active live queries and their data', { tabId: z.number().optional().describe('Browser tab ID') }, async ({ tabId }) => {
66
- const state = (await bridge.request(BRIDGE_METHODS.GET_STATE, {}, tabId));
67
- const queries = state?.activeQueries ?? [];
68
- return { content: [{ type: 'text', text: JSON.stringify(queries, null, 2) }] };
123
+ server.tool('get_active_queries', 'Get all active live queries: surql, params, counts, capped membership ids and timings ' +
124
+ '(rows are not included; use get_query_rows for one query)', { tabId: z.number().optional().describe('Browser tab ID') }, async ({ tabId }) => {
125
+ if (bridge.isConnected) {
126
+ const state = (await bridge.request(BRIDGE_METHODS.GET_STATE, {}, tabId));
127
+ return json(state?.activeQueries ?? []);
128
+ }
129
+ if (surreal) {
130
+ const result = await surreal.query('SELECT * FROM _00_query;');
131
+ return json(result);
132
+ }
133
+ throw new Error('No extension connected and no direct database configured.');
134
+ });
135
+ server.tool('get_query_timings', 'Per-query processing-time breakdown for debugging: SSP phases (parse/plan/snapshot at registration; store-apply/circuit-step/transform per ingest, plus whole-ingest `ssp` wall time), local & remote record-fetch, and frontend reconcile — each as { lastMs, p50, p90, p99, count } — sorted slowest-first. Requires the Sp00ky DevTools browser extension. The SurrealDB fallback only exposes persisted materialization percentiles (the per-phase samples are in-memory).', { tabId: z.number().optional().describe('Browser tab ID') }, async ({ tabId }) => {
136
+ if (bridge.isConnected) {
137
+ const state = (await bridge.request(BRIDGE_METHODS.GET_STATE, {}, tabId));
138
+ const queries = state?.activeQueries
139
+ ? Object.values(state.activeQueries)
140
+ : [];
141
+ // Rank by the dominant phases' p90 (fall back to last) so the slowest
142
+ // query/phase surfaces first.
143
+ const scoreOf = (t) => ['ssp', 'localFetch', 'remoteFetch', 'frontend'].reduce((s, k) => s + (t?.[k]?.p90 ?? t?.[k]?.lastMs ?? 0), 0);
144
+ const rows = queries
145
+ .map((q) => ({
146
+ queryHash: q.queryHash,
147
+ query: q.query,
148
+ updateCount: q.timings?.updateCount ?? q.updateCount,
149
+ errorCount: q.timings?.errorCount,
150
+ timings: q.timings ?? null,
151
+ _score: scoreOf(q.timings),
152
+ }))
153
+ .sort((a, b) => b._score - a._score)
154
+ // oxlint-disable-next-line no-unused-vars -- strip the internal sort key
155
+ .map(({ _score, ...rest }) => rest);
156
+ return json(rows);
157
+ }
158
+ if (surreal) {
159
+ const result = await surreal.query('SELECT * FROM _00_query;');
160
+ return json(result);
161
+ }
162
+ throw new Error('No extension connected and no direct database configured.');
69
163
  });
70
- server.tool('get_events', 'Get event history, optionally filtered by type', {
71
- eventType: z.string().optional().describe('Filter by event type'),
72
- limit: z.number().optional().describe('Max number of events to return'),
164
+ server.tool('get_mutations', "The client's writes: the outbox (pending / retrying with attempt counts), what left it since DevTools " +
165
+ 'attached (synced with the server latency, or rolled back with the server error), debounced updates, ' +
166
+ 'the tab role and connection (a follower tab never drains), and the rows of the persistent failed-writes ' +
167
+ 'tray with their payload and before image. Answers "did my write reach the server?" and "why was it rolled back?".', {
168
+ status: z
169
+ .enum(['pending', 'retrying', 'synced', 'rolled-back', 'dropped'])
170
+ .optional()
171
+ .describe('Only entries with this status'),
172
+ includeTray: z.boolean().optional().default(true).describe('Also read the failed-writes tray rows'),
73
173
  tabId: z.number().optional().describe('Browser tab ID'),
74
- }, async ({ eventType, limit, tabId }) => {
174
+ }, async ({ status, includeTray, tabId }) => {
175
+ if (!bridge.isConnected) {
176
+ throw new Error('No extension connected. get_mutations requires the Sp00ky DevTools browser extension.');
177
+ }
75
178
  const state = (await bridge.request(BRIDGE_METHODS.GET_STATE, {}, tabId));
76
- let events = state?.eventsHistory ?? [];
77
- if (eventType) {
78
- events = events.filter((e) => e.eventType === eventType);
179
+ const mutations = state?.mutations;
180
+ if (!mutations) {
181
+ throw new Error('This client does not report its mutations; update @spooky-sync/core.');
182
+ }
183
+ const entries = status
184
+ ? (mutations.entries ?? []).filter((e) => e.status === status)
185
+ : mutations.entries;
186
+ const failed = includeTray
187
+ ? (await bridge.request(BRIDGE_METHODS.PAGE_OP, { method: 'mutationOp', op: 'listFailed', args: {} }, tabId))?.failed
188
+ : undefined;
189
+ return json({ ...mutations, entries, failed });
190
+ });
191
+ server.tool('get_logs', "Recent lines from the client's own logger (the page keeps its last 500), oldest first, each with its " +
192
+ "structured fields. Pass `after` = a previous call's `head` to read only what is new. `captureLevel` " +
193
+ "changes what the page records (e.g. 'debug') without printing more to the console; it holds for the tab's " +
194
+ 'session, so a reload records its boot at that level.', {
195
+ minLevel: z.enum(LEVEL_NAMES).optional().describe('Only lines at or above this level'),
196
+ contains: z.string().optional().describe('Only lines whose JSON contains this text (case-insensitive)'),
197
+ after: z.number().optional().describe('Only lines after this seq (a previous `head`)'),
198
+ limit: z.number().optional().default(100).describe('Max lines, newest kept'),
199
+ captureLevel: z.enum(LEVEL_NAMES).optional().describe('Set what the page records from now on'),
200
+ tabId: z.number().optional().describe('Browser tab ID'),
201
+ }, async ({ minLevel, contains, after, limit, captureLevel, tabId }) => {
202
+ if (!bridge.isConnected) {
203
+ throw new Error('No extension connected. get_logs requires the Sp00ky DevTools browser extension.');
79
204
  }
80
- if (limit) {
81
- events = events.slice(-limit);
205
+ if (captureLevel) {
206
+ await bridge.request(BRIDGE_METHODS.PAGE_OP, { method: 'logOp', op: 'setCaptureLevel', args: { level: captureLevel } }, tabId);
82
207
  }
83
- return { content: [{ type: 'text', text: JSON.stringify(events, null, 2) }] };
208
+ const read = (await bridge.request(BRIDGE_METHODS.PAGE_OP, { method: 'logOp', op: 'read', args: { after: after ?? 0 } }, tabId));
209
+ const floor = minLevel ? LOG_LEVELS[minLevel] : 0;
210
+ const term = contains?.toLowerCase();
211
+ const entries = (read?.entries ?? []).filter((e) => e.level >= floor && (!term || String(e.line).toLowerCase().includes(term)));
212
+ return json({
213
+ head: read?.head,
214
+ dropped: read?.dropped,
215
+ consoleLevel: read?.consoleLevel,
216
+ captureLevel: read?.captureLevel,
217
+ entries: entries.slice(Math.max(0, entries.length - (limit ?? 100))).map(readableLog),
218
+ });
84
219
  });
85
220
  server.tool('get_auth_state', 'Get the current authentication state', { tabId: z.number().optional().describe('Browser tab ID') }, async ({ tabId }) => {
221
+ if (!bridge.isConnected) {
222
+ throw new Error('No extension connected. get_auth_state requires the Sp00ky DevTools browser extension.');
223
+ }
86
224
  const state = (await bridge.request(BRIDGE_METHODS.GET_STATE, {}, tabId));
87
- const auth = state?.auth ?? null;
88
- return { content: [{ type: 'text', text: JSON.stringify(auth, null, 2) }] };
225
+ return json(state?.auth ?? null);
89
226
  });
90
- server.tool('clear_history', 'Clear the event history', { tabId: z.number().optional().describe('Browser tab ID') }, async ({ tabId }) => {
91
- await bridge.request(BRIDGE_METHODS.CLEAR_HISTORY, {}, tabId);
92
- return { content: [{ type: 'text', text: 'History cleared.' }] };
227
+ server.tool('describe_schema', 'Describe all tables with columns, types, and sp00ky annotations (@crdt, @parent). Stitches INFO FOR DB with parsed schema metadata. With the browser extension this returns @crdt/@parent semantics; direct-DB mode returns raw column info only.', { tabId: z.number().optional().describe('Browser tab ID') }, async ({ tabId }) => {
228
+ if (bridge.isConnected) {
229
+ const state = (await bridge.request(BRIDGE_METHODS.GET_STATE, {}, tabId));
230
+ const dbState = state?.database ?? {};
231
+ return json({
232
+ source: 'extension',
233
+ tables: dbState.tables ?? [],
234
+ relationships: dbState.relationships ?? [],
235
+ });
236
+ }
237
+ if (surreal) {
238
+ const dbInfo = (await surreal.query('INFO FOR DB;'));
239
+ const tablesObj = dbInfo?.[0]?.result?.tables ?? dbInfo?.[0]?.tables ?? {};
240
+ const tableNames = Object.keys(tablesObj);
241
+ const tables = await Promise.all(tableNames.map(async (name) => {
242
+ try {
243
+ const info = (await surreal.query(`INFO FOR TABLE \`${name}\`;`));
244
+ const fieldsObj = info?.[0]?.result?.fields ?? info?.[0]?.fields ?? {};
245
+ const columns = Object.entries(fieldsObj).map(([fname, def]) => ({
246
+ name: fname,
247
+ definition: typeof def === 'string' ? def : JSON.stringify(def),
248
+ }));
249
+ return { name, columns };
250
+ }
251
+ catch (e) {
252
+ return { name, columns: [], error: e instanceof Error ? e.message : String(e) };
253
+ }
254
+ }));
255
+ return json({
256
+ source: 'direct-db',
257
+ note: '@crdt / @parent annotations are not visible in direct-DB mode; connect the browser extension to see them.',
258
+ tables,
259
+ });
260
+ }
261
+ throw new Error('No extension connected and no direct database configured.');
262
+ });
263
+ server.tool('lint_query', 'Validate a SurrealQL query without running it. Sends EXPLAIN <query> through the connected channel; returns parse / plan errors with location when SurrealDB provides them.', {
264
+ query: z.string().describe('SurrealQL query to validate'),
265
+ target: z
266
+ .enum(['local', 'remote'])
267
+ .optional()
268
+ .default('remote')
269
+ .describe('When using the extension: lint against local (cache) or remote DB'),
270
+ tabId: z.number().optional().describe('Browser tab ID'),
271
+ }, async ({ query, target, tabId }) => {
272
+ const trimmed = query.trim().replace(/;\s*$/, '');
273
+ const explainQuery = /^\s*EXPLAIN\b/i.test(trimmed) ? trimmed : `EXPLAIN ${trimmed};`;
274
+ const parseError = (msg) => {
275
+ const m = msg.match(/line\s+(\d+)(?:[,\s]+col(?:umn)?\s+(\d+))?/i);
276
+ return {
277
+ ok: false,
278
+ errors: [
279
+ {
280
+ message: msg,
281
+ line: m ? Number(m[1]) : undefined,
282
+ column: m && m[2] ? Number(m[2]) : undefined,
283
+ },
284
+ ],
285
+ };
286
+ };
287
+ const inspectResult = (raw) => {
288
+ const arr = Array.isArray(raw) ? raw : [raw];
289
+ const errors = arr
290
+ .map((r) => (r && r.status === 'ERR' ? r.result ?? r.message : null))
291
+ .filter(Boolean);
292
+ if (errors.length > 0) {
293
+ return { ok: false, errors: errors.map((m) => parseError(m).errors[0]) };
294
+ }
295
+ return { ok: true, plan: arr };
296
+ };
297
+ try {
298
+ if (bridge.isConnected) {
299
+ const result = await bridge.request(BRIDGE_METHODS.RUN_QUERY, { query: explainQuery, target }, tabId);
300
+ return json(inspectResult(result));
301
+ }
302
+ if (surreal) {
303
+ const result = await surreal.query(explainQuery);
304
+ return json(inspectResult(result));
305
+ }
306
+ throw new Error('No extension connected and no direct database configured.');
307
+ }
308
+ catch (e) {
309
+ const msg = e instanceof Error ? e.message : String(e);
310
+ return json(parseError(msg));
311
+ }
93
312
  });
94
313
  // --- Resources ---
95
314
  server.resource('state', 'sp00ky://state', { description: 'Full Sp00ky DevTools state' }, async (uri) => {
315
+ if (!bridge.isConnected) {
316
+ throw new Error('No extension connected. State resource requires the browser extension.');
317
+ }
96
318
  const state = await bridge.request(BRIDGE_METHODS.GET_STATE);
97
319
  return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(state, null, 2) }] };
98
320
  });
99
321
  server.resource('tables', 'sp00ky://tables', { description: 'List of database tables' }, async (uri) => {
100
- const state = (await bridge.request(BRIDGE_METHODS.GET_STATE));
101
- const tables = state?.database?.tables ?? [];
102
- return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(tables, null, 2) }] };
322
+ if (bridge.isConnected) {
323
+ const state = (await bridge.request(BRIDGE_METHODS.GET_STATE));
324
+ const tables = state?.database?.tables ?? [];
325
+ return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(tables, null, 2) }] };
326
+ }
327
+ if (surreal) {
328
+ const result = await surreal.query('INFO FOR DB;');
329
+ const info = result;
330
+ const tables = info?.[0]?.result?.tables ?? info?.[0]?.tables ?? {};
331
+ return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(Object.keys(tables), null, 2) }] };
332
+ }
333
+ throw new Error('No extension connected and no direct database configured.');
103
334
  });
104
335
  server.resource('table-data', new ResourceTemplate('sp00ky://tables/{tableName}', { list: undefined }), { description: 'Contents of a specific database table' }, async (uri, variables) => {
105
336
  const tableName = variables.tableName;
106
- const result = await bridge.request(BRIDGE_METHODS.GET_TABLE_DATA, { tableName });
107
- return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result, null, 2) }] };
337
+ if (bridge.isConnected) {
338
+ const result = await bridge.request(BRIDGE_METHODS.GET_TABLE_DATA, { tableName });
339
+ return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result, null, 2) }] };
340
+ }
341
+ if (surreal) {
342
+ const result = await surreal.query(`SELECT * FROM \`${tableName}\` LIMIT 100;`);
343
+ return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result, null, 2) }] };
344
+ }
345
+ throw new Error('No extension connected and no direct database configured.');
108
346
  });
109
347
  server.resource('queries', 'sp00ky://queries', { description: 'Active live queries' }, async (uri) => {
110
- const state = (await bridge.request(BRIDGE_METHODS.GET_STATE));
111
- const queries = state?.activeQueries ?? [];
112
- return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(queries, null, 2) }] };
113
- });
114
- server.resource('events', 'sp00ky://events', { description: 'Event history' }, async (uri) => {
115
- const state = (await bridge.request(BRIDGE_METHODS.GET_STATE));
116
- const events = state?.eventsHistory ?? [];
117
- return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(events, null, 2) }] };
348
+ if (bridge.isConnected) {
349
+ const state = (await bridge.request(BRIDGE_METHODS.GET_STATE));
350
+ const queries = state?.activeQueries ?? [];
351
+ return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(queries, null, 2) }] };
352
+ }
353
+ if (surreal) {
354
+ const result = await surreal.query('SELECT * FROM _00_query;');
355
+ return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result, null, 2) }] };
356
+ }
357
+ throw new Error('No extension connected and no direct database configured.');
118
358
  });
119
359
  return server;
120
360
  }
@@ -0,0 +1,13 @@
1
+ export interface SurrealConfig {
2
+ url: string;
3
+ namespace: string;
4
+ database: string;
5
+ username: string;
6
+ password: string;
7
+ }
8
+ export declare class SurrealClient {
9
+ private config;
10
+ private authHeader;
11
+ constructor(config: SurrealConfig);
12
+ query(surql: string): Promise<unknown[]>;
13
+ }
@@ -0,0 +1,27 @@
1
+ export class SurrealClient {
2
+ config;
3
+ authHeader;
4
+ constructor(config) {
5
+ this.config = config;
6
+ this.authHeader =
7
+ 'Basic ' + Buffer.from(`${config.username}:${config.password}`).toString('base64');
8
+ }
9
+ async query(surql) {
10
+ const res = await fetch(`${this.config.url}/sql`, {
11
+ method: 'POST',
12
+ headers: {
13
+ 'Content-Type': 'application/json',
14
+ Authorization: this.authHeader,
15
+ 'surreal-ns': this.config.namespace,
16
+ 'surreal-db': this.config.database,
17
+ Accept: 'application/json',
18
+ },
19
+ body: surql,
20
+ });
21
+ if (!res.ok) {
22
+ const text = await res.text();
23
+ throw new Error(`SurrealDB query failed (${res.status}): ${text}`);
24
+ }
25
+ return res.json();
26
+ }
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/devtools-mcp",
3
- "version": "0.0.1-canary.32",
3
+ "version": "0.0.1-canary.321",
4
4
  "description": "MCP server for Sp00ky Sync devtools",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,11 +8,13 @@
8
8
  "sp00ky-mcp": "./dist/index.js"
9
9
  },
10
10
  "files": [
11
- "dist"
11
+ "dist",
12
+ "AGENTS.md"
12
13
  ],
13
14
  "scripts": {
14
15
  "build": "tsc",
15
- "dev": "tsc --watch"
16
+ "dev": "tsc --watch",
17
+ "test": "vitest run"
16
18
  },
17
19
  "repository": {
18
20
  "type": "git",
@@ -29,6 +31,7 @@
29
31
  },
30
32
  "devDependencies": {
31
33
  "@types/ws": "^8.18.0",
32
- "typescript": "^5.6.2"
34
+ "typescript": "^5.6.2",
35
+ "vitest": "^1.0.0"
33
36
  }
34
37
  }