@spooky-sync/cli 0.0.1-canary.21 → 0.0.1-canary.210
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 +138 -0
- package/README.md +9 -9
- package/devtools-mcp/bridge.d.ts +23 -0
- package/devtools-mcp/bridge.js +155 -0
- package/devtools-mcp/dist/bridge.d.ts +23 -0
- package/devtools-mcp/dist/bridge.js +155 -0
- package/devtools-mcp/dist/index.d.ts +2 -0
- package/devtools-mcp/dist/index.js +37 -0
- package/devtools-mcp/dist/protocol.d.ts +35 -0
- package/devtools-mcp/dist/protocol.js +38 -0
- package/devtools-mcp/dist/server.d.ts +4 -0
- package/devtools-mcp/dist/server.js +330 -0
- package/devtools-mcp/dist/surreal.d.ts +13 -0
- package/devtools-mcp/dist/surreal.js +27 -0
- package/devtools-mcp/index.d.ts +2 -0
- package/devtools-mcp/index.js +37 -0
- package/devtools-mcp/protocol.d.ts +34 -0
- package/devtools-mcp/protocol.js +37 -0
- package/devtools-mcp/server.d.ts +4 -0
- package/devtools-mcp/server.js +290 -0
- package/devtools-mcp/surreal.d.ts +13 -0
- package/devtools-mcp/surreal.js +27 -0
- package/dist/cli.cjs +1 -1
- package/dist/cli.js +2 -2
- package/dist/resolve-binary-J8VAJRMF.cjs +8 -0
- package/dist/resolve-binary-kH1Jwk0z.js +60 -0
- package/dist/resolve-binary.d.ts.map +1 -1
- package/dist/syncgen.cjs +1 -1
- package/dist/syncgen.js +7 -7
- package/package.json +15 -10
- package/templates/cookbook/INDEX.md +31 -0
- package/templates/cookbook/crdt-text-field.tsx +36 -0
- package/templates/cookbook/live-list.tsx +22 -0
- package/templates/cookbook/optimistic-mutation.tsx +33 -0
- package/dist/resolve-binary-BYotYL89.js +0 -51
- package/dist/resolve-binary-CFmJcXEj.cjs +0 -8
package/AGENTS.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# `@spooky-sync/cli` (`spky`) — agent guide
|
|
2
|
+
|
|
3
|
+
## What this package is
|
|
4
|
+
|
|
5
|
+
The sp00ky toolchain. A Rust binary (`spky`) plus a thin npm wrapper. It parses `.surql` schemas, emits typed `schema.gen.ts` (and `.dart`), runs migrations, manages buckets and API backends, drives the local dev environment, and orchestrates Sp00ky Cloud deployments.
|
|
6
|
+
|
|
7
|
+
## Binary
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
spky <subcommand> [flags]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Installed via `npx @spooky-sync/cli` or globally as `spky`. The `bin` field in `package.json` is `spky` — *not* `sp00ky`.
|
|
14
|
+
|
|
15
|
+
## Project layout it expects
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
your-app/
|
|
19
|
+
├── sp00ky.yml # config: schema path, generated outputs, backends, buckets
|
|
20
|
+
├── schema/
|
|
21
|
+
│ └── schema.surql # source of truth — your domain model
|
|
22
|
+
├── src/
|
|
23
|
+
│ └── schema.gen.ts # GENERATED — never hand-edit
|
|
24
|
+
└── migrations/ # GENERATED migrations; modified files are tracked by checksum
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`spky` finds `sp00ky.yml` in the current directory by default; pass `--config <path>` to override.
|
|
28
|
+
|
|
29
|
+
## Subcommands an app developer/agent uses most
|
|
30
|
+
|
|
31
|
+
- **`spky generate` / `spky gen`** — read `sp00ky.yml`, parse all `.surql`, emit `schema.gen.ts` (and Dart equivalents per config). **Run this after every schema edit.**
|
|
32
|
+
- **`spky migrate create <name>`** — diff current schema against the last applied migration and emit a new `.surql` migration file.
|
|
33
|
+
- **`spky migrate apply`** — apply pending migrations against the configured database. `--fix-checksums` updates stored checksums for legitimately-modified migration files.
|
|
34
|
+
- **`spky migrate status`** — show pending vs applied vs modified-but-applied migrations.
|
|
35
|
+
- **`spky migrate fix [--fix-checksums]`** — repair schema drift / checksum mismatches.
|
|
36
|
+
- **`spky verify [--fix]`** — confirm SSP/scheduler snapshot matches upstream SurrealDB, and print the scheduler's own drift verdict (`/health/snapshot` `drift`). `--fix` re-clones the scheduler replica when its counts are off, otherwise forces every SSP to re-bootstrap. The scheduler runs the same count check itself at startup and after each snapshot drain and auto-reclones by default (`SPKY_DRIFT_AUTO_RECLONE`).
|
|
37
|
+
- **`spky lint`** — validate `sp00ky.yml` and referenced files exist.
|
|
38
|
+
- **`spky dev [--apply-migrations] [--clean] [--verbose]`** — boots a local SurrealDB + SSP + scheduler stack via Docker. `--clean` wipes SSP/scheduler state but preserves user data in SurrealDB. Startup renders as step lines via `src/ui.rs` (indicatif/console; plain lines when not a TTY); after "ready" only ERROR/crash-looking lines (infra and app streams) show unless `--verbose` / `SPKY_VERBOSE=1`. A bare `logLevel` is scoped to sp00ky crates by `backend::scoped_rust_log`.
|
|
39
|
+
- **`spky create`** — scaffold a new sp00ky project.
|
|
40
|
+
- **`spky bucket add`** / **`spky api add`** — append a bucket or backend definition to `sp00ky.yml`.
|
|
41
|
+
- **`spky mcp`** — start the bundled `@spooky-sync/devtools-mcp` server (so AI assistants can introspect the running app).
|
|
42
|
+
|
|
43
|
+
## Cloud subcommands (deployment)
|
|
44
|
+
|
|
45
|
+
`spky cloud login | create | deploy | status | logs | scale | restart | destroy | backup | env | keys | link | team | vault | credentials`. See `spky cloud --help`. Most app code agents touch never need these.
|
|
46
|
+
|
|
47
|
+
## Schema annotations the parser recognizes
|
|
48
|
+
|
|
49
|
+
In your `.surql` source, comment annotations attached to `DEFINE FIELD` / `DEFINE TABLE` change codegen output:
|
|
50
|
+
|
|
51
|
+
- `-- @crdt text` (above a `DEFINE FIELD`) — marks a field as a Loro CRDT text field. Consumers must use `useCrdtField` to read/write it; plain `useQuery` will see stale or unmerged content.
|
|
52
|
+
- `-- @parent` (suffix on `DEFINE FIELD ... TYPE record<...>`) — marks the column as the parent side of a relationship; written automatically from the auth context, never by client code.
|
|
53
|
+
- `-- @nosync` (above a `DEFINE TABLE`) — marks a table as server-only: it is omitted from generated types and relations (and any `record<...>` link pointing at it is dropped), no sync events are emitted for it, and the scheduler/SSP exclude it from snapshots and bootstrap. The table still lives in the main DB and is still backed up. The CLI bakes a `COMMENT 'sp00ky:nosync'` marker onto the server-side `DEFINE TABLE` so the runtime services detect it via `INFO FOR DB`. Distinct from `PERMISSIONS FOR select WHERE false`, which only locks reads — a permission-locked table is still synced.
|
|
54
|
+
- `-- @nosync` (above a `DEFINE FIELD`) — marks a single field server-only: omitted from generated types and from the client's local cache schema, omitted from sync event payloads, and omitted from the scheduler replica and SSP bootstrap row scans. **Not a read barrier**: a client's down-sync `SELECT` still returns the column over the wire, and it is only discarded on arrival (`cleanRecord`). For real secrecy use `PERMISSIONS FOR select WHERE false` on the field, or move it to a `@nosync` table.
|
|
55
|
+
- `-- @opaque` (above a `DEFINE FIELD`) — the field IS synced to the client (it stays in generated types and the local cache, flagged `opaque: true` on the column) but no server-side component stores the value. Intended for large blobs you render but never query on. Because nothing holds the value it cannot be evaluated: using it in `where`/`orderBy`/a join throws in the query builder and is rejected with a 400 at SSP registration, and a schema whose `PERMISSIONS` or `DEFINE INDEX` references one fails to build. Delivery works because sync payloads carry ids + versions, not field values — the client reads the row body straight from SurrealDB.
|
|
56
|
+
|
|
57
|
+
All three field-level exclusions (`@nosync`, `@crdt`, `@opaque`) get `COMMENT 'sp00ky:opaque'` baked onto the server `DEFINE FIELD` (`schema_builder::add_opaque_field_markers`). The scheduler replica and the SSP bootstrap read that marker from `INFO FOR TABLE` and turn it into a `SELECT * OMIT ...` projection. Both halves are required: skipping a field from the ingest payload while the bootstrap still loads it makes the SSP circuit and the scheduler replica disagree about the row's key set permanently (the replica applies updates with `MERGE`, the circuit replaces the whole row), which shows up as an unfixable `spky verify` mismatch.
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
```sql
|
|
61
|
+
DEFINE TABLE thread SCHEMAFULL ...;
|
|
62
|
+
|
|
63
|
+
-- @crdt text
|
|
64
|
+
DEFINE FIELD content ON TABLE thread TYPE string ASSERT $value != NONE;
|
|
65
|
+
|
|
66
|
+
DEFINE FIELD author ON TABLE thread TYPE record<user>; -- @parent
|
|
67
|
+
|
|
68
|
+
-- @opaque
|
|
69
|
+
DEFINE FIELD preview_png ON TABLE thread TYPE option<bytes>;
|
|
70
|
+
|
|
71
|
+
-- @nosync
|
|
72
|
+
DEFINE TABLE audit_log SCHEMALESS;
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
A descriptor must sit directly above its statement (no blank line between). One that attaches to nothing is warned about, not silently dropped (`annotations::warn_unattached_annotations`).
|
|
76
|
+
|
|
77
|
+
## Docker dev apps (`type: docker`)
|
|
78
|
+
|
|
79
|
+
Besides `backend`/`frontend` apps, `sp00ky.yml` can declare `type: docker` apps —
|
|
80
|
+
containers `spky dev` runs alongside SurrealDB/SSP/scheduler on the
|
|
81
|
+
`sp00ky-dev-net` network (each reachable from the others by its app **name**, via
|
|
82
|
+
a `--network-alias`). Use `scope: devOnly` for local-only sidecars: never
|
|
83
|
+
deployed, and they skip the backend spec/method/deploy validation.
|
|
84
|
+
|
|
85
|
+
Fields:
|
|
86
|
+
|
|
87
|
+
- `image` (required) — image to run, e.g. `bluenviron/mediamtx:latest` or `golang:1.22`.
|
|
88
|
+
- `ports` — published to the host: `[1935, "8189/udp", "3000:8080"]` (a bare value maps the same port host:container; `/udp` suffix preserved).
|
|
89
|
+
- `args` — appended after the image (the container command), e.g. `["go", "run", "."]`.
|
|
90
|
+
- `env` — same forms as other apps (inline map / dotenv path / vault). User values **override** the auto-injected `SPKY_*` vars. `${PROJECT_DIR}` (the absolute dir of `sp00ky.yml`) is expanded in values.
|
|
91
|
+
- `volumes` — bind/volume mounts (`-v`), e.g. `["/var/run/docker.sock:/var/run/docker.sock", "${PROJECT_DIR}/../..:/src", "gomod:/go"]`. `${PROJECT_DIR}` is expanded in the host portion (Docker normalizes `..`).
|
|
92
|
+
- `workdir` — working directory inside the container (`-w`).
|
|
93
|
+
- `dependsOn` — names of other docker apps that must be **ready** before this one starts; `spky dev` starts apps in dependency order. Validated at config load — an unknown name, a self-dependency, or a **cycle** is a hard error (`spky lint` reports it).
|
|
94
|
+
- `healthcheck` — an HTTP path (e.g. `/health`) polled on the app's first published host port until it returns 200. Lets a dependency signal real readiness so `dependsOn` waits for "up", not just "container started". Without it, a dependency counts as ready once its container is running.
|
|
95
|
+
|
|
96
|
+
Containers run as `sp00ky-dev-<name>` with `--rm` and are killed on Ctrl-C.
|
|
97
|
+
`dependsOn`/`healthcheck` are `spky dev` concerns; the cloud deploy path ignores
|
|
98
|
+
them (and `cloudOnly` docker apps are skipped by `spky dev`).
|
|
99
|
+
|
|
100
|
+
Example — a relay built from source via `go run`, and a publisher that waits for it:
|
|
101
|
+
```yaml
|
|
102
|
+
apps:
|
|
103
|
+
relay:
|
|
104
|
+
type: docker
|
|
105
|
+
scope: devOnly
|
|
106
|
+
image: golang:1.22
|
|
107
|
+
workdir: /src/apps/relay
|
|
108
|
+
args: ["go", "run", "."]
|
|
109
|
+
ports: [3670]
|
|
110
|
+
healthcheck: /health
|
|
111
|
+
volumes:
|
|
112
|
+
- "${PROJECT_DIR}/../..:/src"
|
|
113
|
+
- "gomod:/go"
|
|
114
|
+
publisher:
|
|
115
|
+
type: docker
|
|
116
|
+
scope: devOnly
|
|
117
|
+
image: golang:1.22
|
|
118
|
+
workdir: /src/apps/publisher
|
|
119
|
+
args: ["go", "run", "."]
|
|
120
|
+
dependsOn: [relay] # started only after relay's /health returns 200
|
|
121
|
+
volumes:
|
|
122
|
+
- "${PROJECT_DIR}/../..:/src"
|
|
123
|
+
- "gomod:/go"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Common gotchas
|
|
127
|
+
|
|
128
|
+
- **`schema.gen.ts` must be regenerated after every `.surql` change.** `spky generate`. CI typically asserts no drift.
|
|
129
|
+
- **Migrations are checksum-tracked.** Editing a previously-applied migration file won't silently re-run; `spky migrate status` flags it. Use `--fix-checksums` only when you're sure the change is semantically a no-op.
|
|
130
|
+
- **`sp00ky.yml` is the entry point.** The CLI never crawls for `.surql`; everything is wired explicitly through the config.
|
|
131
|
+
- **Generation modes matter.** The `--mode` flag (`singlenode`, `cluster`, `surrealism`) changes what the generated client connects to. Default is `singlenode` (HTTP to a single SSP). `surrealism` embeds the WASM stream processor in-browser.
|
|
132
|
+
- **Don't commit the bin output.** The Rust binary is built per-platform and shipped via the npm tarball under `dist/`.
|
|
133
|
+
|
|
134
|
+
## Pointers
|
|
135
|
+
|
|
136
|
+
- Sync engine the generated client targets: `node_modules/@spooky-sync/core/AGENTS.md`
|
|
137
|
+
- Reactive UI bindings: `node_modules/@spooky-sync/client-solid/AGENTS.md`
|
|
138
|
+
- Live MCP introspection during dev: `node_modules/@spooky-sync/devtools-mcp/AGENTS.md`
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Sp00ky CLI
|
|
2
2
|
|
|
3
3
|
Generate TypeScript and Dart types from SurrealDB schema files.
|
|
4
4
|
|
|
@@ -18,28 +18,28 @@ This package wraps a Rust-based code generator that parses SurrealDB `.surql` sc
|
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
# Generate TypeScript types
|
|
21
|
-
|
|
21
|
+
spky --input schema.surql --output types.ts
|
|
22
22
|
|
|
23
23
|
# Generate Dart types
|
|
24
|
-
|
|
24
|
+
spky --input schema.surql --output types.dart
|
|
25
25
|
|
|
26
26
|
# Generate JSON Schema
|
|
27
|
-
|
|
27
|
+
spky --input schema.surql --output schema.json
|
|
28
28
|
|
|
29
29
|
# Generate all formats at once
|
|
30
|
-
|
|
30
|
+
spky --input schema.surql --output output --all
|
|
31
31
|
|
|
32
32
|
# Specify format explicitly
|
|
33
|
-
|
|
33
|
+
spky --input schema.surql --output output.ts --format typescript
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
### Programmatic API
|
|
37
37
|
|
|
38
38
|
```typescript
|
|
39
|
-
import {
|
|
39
|
+
import { runSp00ky } from 'sp00ky-cli';
|
|
40
40
|
|
|
41
41
|
// Generate types
|
|
42
|
-
const output = await
|
|
42
|
+
const output = await runSp00ky({
|
|
43
43
|
input: 'path/to/schema.surql',
|
|
44
44
|
output: 'path/to/output.ts',
|
|
45
45
|
format: 'typescript', // or 'dart', 'json'
|
|
@@ -95,7 +95,7 @@ cli/
|
|
|
95
95
|
|
|
96
96
|
## How It Works
|
|
97
97
|
|
|
98
|
-
1. The Rust binary (`
|
|
98
|
+
1. The Rust binary (`spky`) parses SurrealDB schema files and generates JSON Schema
|
|
99
99
|
2. For TypeScript/Dart output, it uses `quicktype` to convert JSON Schema to the target language
|
|
100
100
|
3. The TypeScript wrapper (`src/index.ts`) spawns the Rust binary as a child process
|
|
101
101
|
4. Vite bundles the TypeScript wrapper for distribution
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
interface ConnectedTab {
|
|
2
|
+
tabId: number;
|
|
3
|
+
url?: string;
|
|
4
|
+
title?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare class Bridge {
|
|
7
|
+
private wss;
|
|
8
|
+
private extensionSocket;
|
|
9
|
+
private connectedTabs;
|
|
10
|
+
private pendingRequests;
|
|
11
|
+
private requestCounter;
|
|
12
|
+
private pingInterval;
|
|
13
|
+
get isConnected(): boolean;
|
|
14
|
+
getConnectedTabs(): ConnectedTab[];
|
|
15
|
+
start(): Promise<void>;
|
|
16
|
+
private startPing;
|
|
17
|
+
private stopPing;
|
|
18
|
+
private handleMessage;
|
|
19
|
+
request(method: string, params?: Record<string, unknown>, tabId?: number): Promise<unknown>;
|
|
20
|
+
private getDefaultTabId;
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
2
|
+
import { isBridgeResponse, isBridgeNotification, BRIDGE_PORT, } from './protocol.js';
|
|
3
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
4
|
+
export class Bridge {
|
|
5
|
+
wss = null;
|
|
6
|
+
extensionSocket = null;
|
|
7
|
+
connectedTabs = new Map();
|
|
8
|
+
pendingRequests = new Map();
|
|
9
|
+
requestCounter = 0;
|
|
10
|
+
pingInterval = null;
|
|
11
|
+
get isConnected() {
|
|
12
|
+
return this.extensionSocket?.readyState === WebSocket.OPEN;
|
|
13
|
+
}
|
|
14
|
+
getConnectedTabs() {
|
|
15
|
+
return Array.from(this.connectedTabs.values());
|
|
16
|
+
}
|
|
17
|
+
start() {
|
|
18
|
+
const port = Number.parseInt(process.env.SP00KY_MCP_PORT || '', 10) || BRIDGE_PORT;
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
this.wss = new WebSocketServer({ host: '127.0.0.1', port }, () => {
|
|
21
|
+
process.stderr.write(`[sp00ky-mcp] Bridge listening on ws://127.0.0.1:${port}\n`);
|
|
22
|
+
resolve();
|
|
23
|
+
});
|
|
24
|
+
this.wss.on('error', (err) => {
|
|
25
|
+
process.stderr.write(`[sp00ky-mcp] Bridge error: ${err.message}\n`);
|
|
26
|
+
reject(err);
|
|
27
|
+
});
|
|
28
|
+
this.wss.on('connection', (ws) => {
|
|
29
|
+
process.stderr.write('[sp00ky-mcp] Extension connected\n');
|
|
30
|
+
// Only allow one extension connection at a time
|
|
31
|
+
if (this.extensionSocket) {
|
|
32
|
+
this.extensionSocket.close();
|
|
33
|
+
}
|
|
34
|
+
this.extensionSocket = ws;
|
|
35
|
+
// Start keepalive pings
|
|
36
|
+
this.startPing(ws);
|
|
37
|
+
ws.on('message', (data) => {
|
|
38
|
+
try {
|
|
39
|
+
const msg = JSON.parse(data.toString());
|
|
40
|
+
this.handleMessage(msg);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
process.stderr.write(`[sp00ky-mcp] Bad message: ${err}\n`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
ws.on('close', () => {
|
|
47
|
+
process.stderr.write('[sp00ky-mcp] Extension disconnected\n');
|
|
48
|
+
if (this.extensionSocket === ws) {
|
|
49
|
+
this.extensionSocket = null;
|
|
50
|
+
this.connectedTabs.clear();
|
|
51
|
+
this.stopPing();
|
|
52
|
+
// Reject all pending requests
|
|
53
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
54
|
+
pending.reject(new Error('Extension disconnected'));
|
|
55
|
+
clearTimeout(pending.timer);
|
|
56
|
+
this.pendingRequests.delete(id);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
ws.on('error', (err) => {
|
|
61
|
+
process.stderr.write(`[sp00ky-mcp] Socket error: ${err.message}\n`);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
startPing(ws) {
|
|
67
|
+
this.stopPing();
|
|
68
|
+
this.pingInterval = setInterval(() => {
|
|
69
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
70
|
+
ws.ping();
|
|
71
|
+
}
|
|
72
|
+
}, 20_000);
|
|
73
|
+
}
|
|
74
|
+
stopPing() {
|
|
75
|
+
if (this.pingInterval) {
|
|
76
|
+
clearInterval(this.pingInterval);
|
|
77
|
+
this.pingInterval = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
handleMessage(msg) {
|
|
81
|
+
// Handle response to a pending request
|
|
82
|
+
if (isBridgeResponse(msg)) {
|
|
83
|
+
const pending = this.pendingRequests.get(msg.id);
|
|
84
|
+
if (pending) {
|
|
85
|
+
clearTimeout(pending.timer);
|
|
86
|
+
this.pendingRequests.delete(msg.id);
|
|
87
|
+
if (msg.error) {
|
|
88
|
+
pending.reject(new Error(msg.error.message));
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
pending.resolve(msg.result);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// Handle notifications from extension
|
|
97
|
+
if (isBridgeNotification(msg)) {
|
|
98
|
+
if (msg.method === 'tabsChanged') {
|
|
99
|
+
this.connectedTabs.clear();
|
|
100
|
+
const tabs = msg.params.tabs;
|
|
101
|
+
for (const tab of tabs) {
|
|
102
|
+
this.connectedTabs.set(tab.tabId, tab);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async request(method, params = {}, tabId) {
|
|
109
|
+
if (!this.extensionSocket || this.extensionSocket.readyState !== WebSocket.OPEN) {
|
|
110
|
+
throw new Error('No extension connected. Make sure the Sp00ky DevTools extension is running and has a page with Sp00ky open.');
|
|
111
|
+
}
|
|
112
|
+
const id = `mcp-${++this.requestCounter}`;
|
|
113
|
+
const resolvedTabId = tabId ?? this.getDefaultTabId();
|
|
114
|
+
const request = {
|
|
115
|
+
jsonrpc: '2.0',
|
|
116
|
+
id,
|
|
117
|
+
method,
|
|
118
|
+
params,
|
|
119
|
+
...(resolvedTabId !== undefined ? { tabId: resolvedTabId } : {}),
|
|
120
|
+
};
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const timer = setTimeout(() => {
|
|
123
|
+
this.pendingRequests.delete(id);
|
|
124
|
+
reject(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms: ${method}`));
|
|
125
|
+
}, REQUEST_TIMEOUT_MS);
|
|
126
|
+
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
127
|
+
// oxlint-disable-next-line no-non-null-assertion
|
|
128
|
+
this.extensionSocket.send(JSON.stringify(request));
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
getDefaultTabId() {
|
|
132
|
+
const tabs = this.getConnectedTabs();
|
|
133
|
+
return tabs.length > 0 ? tabs[0].tabId : undefined;
|
|
134
|
+
}
|
|
135
|
+
async stop() {
|
|
136
|
+
this.stopPing();
|
|
137
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
138
|
+
clearTimeout(pending.timer);
|
|
139
|
+
pending.reject(new Error('Bridge shutting down'));
|
|
140
|
+
this.pendingRequests.delete(id);
|
|
141
|
+
}
|
|
142
|
+
if (this.extensionSocket) {
|
|
143
|
+
this.extensionSocket.close();
|
|
144
|
+
this.extensionSocket = null;
|
|
145
|
+
}
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
if (this.wss) {
|
|
148
|
+
this.wss.close(() => resolve());
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
resolve();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
interface ConnectedTab {
|
|
2
|
+
tabId: number;
|
|
3
|
+
url?: string;
|
|
4
|
+
title?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare class Bridge {
|
|
7
|
+
private wss;
|
|
8
|
+
private extensionSocket;
|
|
9
|
+
private connectedTabs;
|
|
10
|
+
private pendingRequests;
|
|
11
|
+
private requestCounter;
|
|
12
|
+
private pingInterval;
|
|
13
|
+
get isConnected(): boolean;
|
|
14
|
+
getConnectedTabs(): ConnectedTab[];
|
|
15
|
+
start(): Promise<void>;
|
|
16
|
+
private startPing;
|
|
17
|
+
private stopPing;
|
|
18
|
+
private handleMessage;
|
|
19
|
+
request(method: string, params?: Record<string, unknown>, tabId?: number): Promise<unknown>;
|
|
20
|
+
private getDefaultTabId;
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
2
|
+
import { isBridgeResponse, isBridgeNotification, BRIDGE_PORT, } from './protocol.js';
|
|
3
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
4
|
+
export class Bridge {
|
|
5
|
+
wss = null;
|
|
6
|
+
extensionSocket = null;
|
|
7
|
+
connectedTabs = new Map();
|
|
8
|
+
pendingRequests = new Map();
|
|
9
|
+
requestCounter = 0;
|
|
10
|
+
pingInterval = null;
|
|
11
|
+
get isConnected() {
|
|
12
|
+
return this.extensionSocket?.readyState === WebSocket.OPEN;
|
|
13
|
+
}
|
|
14
|
+
getConnectedTabs() {
|
|
15
|
+
return Array.from(this.connectedTabs.values());
|
|
16
|
+
}
|
|
17
|
+
start() {
|
|
18
|
+
const port = Number.parseInt(process.env.SP00KY_MCP_PORT || '', 10) || BRIDGE_PORT;
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
this.wss = new WebSocketServer({ host: '127.0.0.1', port }, () => {
|
|
21
|
+
process.stderr.write(`[sp00ky-mcp] Bridge listening on ws://127.0.0.1:${port}\n`);
|
|
22
|
+
resolve();
|
|
23
|
+
});
|
|
24
|
+
this.wss.on('error', (err) => {
|
|
25
|
+
process.stderr.write(`[sp00ky-mcp] Bridge error: ${err.message}\n`);
|
|
26
|
+
reject(err);
|
|
27
|
+
});
|
|
28
|
+
this.wss.on('connection', (ws) => {
|
|
29
|
+
process.stderr.write('[sp00ky-mcp] Extension connected\n');
|
|
30
|
+
// Only allow one extension connection at a time
|
|
31
|
+
if (this.extensionSocket) {
|
|
32
|
+
this.extensionSocket.close();
|
|
33
|
+
}
|
|
34
|
+
this.extensionSocket = ws;
|
|
35
|
+
// Start keepalive pings
|
|
36
|
+
this.startPing(ws);
|
|
37
|
+
ws.on('message', (data) => {
|
|
38
|
+
try {
|
|
39
|
+
const msg = JSON.parse(data.toString());
|
|
40
|
+
this.handleMessage(msg);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
process.stderr.write(`[sp00ky-mcp] Bad message: ${err}\n`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
ws.on('close', () => {
|
|
47
|
+
process.stderr.write('[sp00ky-mcp] Extension disconnected\n');
|
|
48
|
+
if (this.extensionSocket === ws) {
|
|
49
|
+
this.extensionSocket = null;
|
|
50
|
+
this.connectedTabs.clear();
|
|
51
|
+
this.stopPing();
|
|
52
|
+
// Reject all pending requests
|
|
53
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
54
|
+
pending.reject(new Error('Extension disconnected'));
|
|
55
|
+
clearTimeout(pending.timer);
|
|
56
|
+
this.pendingRequests.delete(id);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
ws.on('error', (err) => {
|
|
61
|
+
process.stderr.write(`[sp00ky-mcp] Socket error: ${err.message}\n`);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
startPing(ws) {
|
|
67
|
+
this.stopPing();
|
|
68
|
+
this.pingInterval = setInterval(() => {
|
|
69
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
70
|
+
ws.ping();
|
|
71
|
+
}
|
|
72
|
+
}, 20_000);
|
|
73
|
+
}
|
|
74
|
+
stopPing() {
|
|
75
|
+
if (this.pingInterval) {
|
|
76
|
+
clearInterval(this.pingInterval);
|
|
77
|
+
this.pingInterval = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
handleMessage(msg) {
|
|
81
|
+
// Handle response to a pending request
|
|
82
|
+
if (isBridgeResponse(msg)) {
|
|
83
|
+
const pending = this.pendingRequests.get(msg.id);
|
|
84
|
+
if (pending) {
|
|
85
|
+
clearTimeout(pending.timer);
|
|
86
|
+
this.pendingRequests.delete(msg.id);
|
|
87
|
+
if (msg.error) {
|
|
88
|
+
pending.reject(new Error(msg.error.message));
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
pending.resolve(msg.result);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// Handle notifications from extension
|
|
97
|
+
if (isBridgeNotification(msg)) {
|
|
98
|
+
if (msg.method === 'tabsChanged') {
|
|
99
|
+
this.connectedTabs.clear();
|
|
100
|
+
const tabs = msg.params.tabs;
|
|
101
|
+
for (const tab of tabs) {
|
|
102
|
+
this.connectedTabs.set(tab.tabId, tab);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async request(method, params = {}, tabId) {
|
|
109
|
+
if (!this.extensionSocket || this.extensionSocket.readyState !== WebSocket.OPEN) {
|
|
110
|
+
throw new Error('No extension connected. Make sure the Sp00ky DevTools extension is running and has a page with Sp00ky open.');
|
|
111
|
+
}
|
|
112
|
+
const id = `mcp-${++this.requestCounter}`;
|
|
113
|
+
const resolvedTabId = tabId ?? this.getDefaultTabId();
|
|
114
|
+
const request = {
|
|
115
|
+
jsonrpc: '2.0',
|
|
116
|
+
id,
|
|
117
|
+
method,
|
|
118
|
+
params,
|
|
119
|
+
...(resolvedTabId !== undefined ? { tabId: resolvedTabId } : {}),
|
|
120
|
+
};
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const timer = setTimeout(() => {
|
|
123
|
+
this.pendingRequests.delete(id);
|
|
124
|
+
reject(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms: ${method}`));
|
|
125
|
+
}, REQUEST_TIMEOUT_MS);
|
|
126
|
+
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
127
|
+
// oxlint-disable-next-line no-non-null-assertion
|
|
128
|
+
this.extensionSocket.send(JSON.stringify(request));
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
getDefaultTabId() {
|
|
132
|
+
const tabs = this.getConnectedTabs();
|
|
133
|
+
return tabs.length > 0 ? tabs[0].tabId : undefined;
|
|
134
|
+
}
|
|
135
|
+
async stop() {
|
|
136
|
+
this.stopPing();
|
|
137
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
138
|
+
clearTimeout(pending.timer);
|
|
139
|
+
pending.reject(new Error('Bridge shutting down'));
|
|
140
|
+
this.pendingRequests.delete(id);
|
|
141
|
+
}
|
|
142
|
+
if (this.extensionSocket) {
|
|
143
|
+
this.extensionSocket.close();
|
|
144
|
+
this.extensionSocket = null;
|
|
145
|
+
}
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
if (this.wss) {
|
|
148
|
+
this.wss.close(() => resolve());
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
resolve();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { Bridge } from './bridge.js';
|
|
4
|
+
import { SurrealClient } from './surreal.js';
|
|
5
|
+
import { createServer } from './server.js';
|
|
6
|
+
async function main() {
|
|
7
|
+
const bridge = new Bridge();
|
|
8
|
+
await bridge.start();
|
|
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);
|
|
22
|
+
const transport = new StdioServerTransport();
|
|
23
|
+
await server.connect(transport);
|
|
24
|
+
process.stderr.write('[sp00ky-mcp] MCP server running on stdio\n');
|
|
25
|
+
// Graceful shutdown
|
|
26
|
+
const cleanup = async () => {
|
|
27
|
+
process.stderr.write('[sp00ky-mcp] Shutting down...\n');
|
|
28
|
+
await bridge.stop();
|
|
29
|
+
process.exit(0);
|
|
30
|
+
};
|
|
31
|
+
process.on('SIGINT', cleanup);
|
|
32
|
+
process.on('SIGTERM', cleanup);
|
|
33
|
+
}
|
|
34
|
+
main().catch((err) => {
|
|
35
|
+
process.stderr.write(`[sp00ky-mcp] Fatal error: ${err.message}\n`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface BridgeRequest {
|
|
2
|
+
jsonrpc: '2.0';
|
|
3
|
+
id: string;
|
|
4
|
+
method: string;
|
|
5
|
+
params: Record<string, unknown>;
|
|
6
|
+
tabId?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface BridgeResponse {
|
|
9
|
+
jsonrpc: '2.0';
|
|
10
|
+
id: string;
|
|
11
|
+
result?: unknown;
|
|
12
|
+
error?: {
|
|
13
|
+
code: number;
|
|
14
|
+
message: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface BridgeNotification {
|
|
18
|
+
jsonrpc: '2.0';
|
|
19
|
+
method: string;
|
|
20
|
+
params: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
export type BridgeMessage = BridgeRequest | BridgeResponse | BridgeNotification;
|
|
23
|
+
export declare const BRIDGE_METHODS: {
|
|
24
|
+
readonly GET_STATE: "getState";
|
|
25
|
+
readonly RUN_QUERY: "runQuery";
|
|
26
|
+
readonly GET_TABLE_DATA: "getTableData";
|
|
27
|
+
readonly GET_QUERY_ROWS: "getQueryRows";
|
|
28
|
+
readonly UPDATE_TABLE_ROW: "updateTableRow";
|
|
29
|
+
readonly DELETE_TABLE_ROW: "deleteTableRow";
|
|
30
|
+
readonly CLEAR_HISTORY: "clearHistory";
|
|
31
|
+
};
|
|
32
|
+
export declare const BRIDGE_PORT = 9315;
|
|
33
|
+
export declare function isBridgeResponse(msg: unknown): msg is BridgeResponse;
|
|
34
|
+
export declare function isBridgeRequest(msg: unknown): msg is BridgeRequest;
|
|
35
|
+
export declare function isBridgeNotification(msg: unknown): msg is BridgeNotification;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Shared message types for MCP Server <-> Chrome Extension bridge (JSON-RPC 2.0 style)
|
|
2
|
+
// Methods the MCP server can call on the extension
|
|
3
|
+
export const BRIDGE_METHODS = {
|
|
4
|
+
GET_STATE: 'getState',
|
|
5
|
+
RUN_QUERY: 'runQuery',
|
|
6
|
+
GET_TABLE_DATA: 'getTableData',
|
|
7
|
+
GET_QUERY_ROWS: 'getQueryRows',
|
|
8
|
+
UPDATE_TABLE_ROW: 'updateTableRow',
|
|
9
|
+
DELETE_TABLE_ROW: 'deleteTableRow',
|
|
10
|
+
CLEAR_HISTORY: 'clearHistory',
|
|
11
|
+
};
|
|
12
|
+
export const BRIDGE_PORT = 9315;
|
|
13
|
+
export function isBridgeResponse(msg) {
|
|
14
|
+
return (typeof msg === 'object' &&
|
|
15
|
+
msg !== null &&
|
|
16
|
+
'jsonrpc' in msg &&
|
|
17
|
+
msg.jsonrpc === '2.0' &&
|
|
18
|
+
'id' in msg &&
|
|
19
|
+
('result' in msg || 'error' in msg));
|
|
20
|
+
}
|
|
21
|
+
export function isBridgeRequest(msg) {
|
|
22
|
+
return (typeof msg === 'object' &&
|
|
23
|
+
msg !== null &&
|
|
24
|
+
'jsonrpc' in msg &&
|
|
25
|
+
msg.jsonrpc === '2.0' &&
|
|
26
|
+
'method' in msg &&
|
|
27
|
+
'id' in msg &&
|
|
28
|
+
!('result' in msg) &&
|
|
29
|
+
!('error' in msg));
|
|
30
|
+
}
|
|
31
|
+
export function isBridgeNotification(msg) {
|
|
32
|
+
return (typeof msg === 'object' &&
|
|
33
|
+
msg !== null &&
|
|
34
|
+
'jsonrpc' in msg &&
|
|
35
|
+
msg.jsonrpc === '2.0' &&
|
|
36
|
+
'method' in msg &&
|
|
37
|
+
!('id' in msg));
|
|
38
|
+
}
|