@foldkit/devtools-mcp 0.24.1-canary.1ce3d4b05055 → 0.24.1-canary.1fbeaffe6563
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 +43 -35
- package/dist/install.js +2 -2
- package/dist/relayClient.d.ts +37 -0
- package/dist/relayClient.d.ts.map +1 -0
- package/dist/relayClient.js +276 -0
- package/dist/relayLocation.d.ts +9 -1
- package/dist/relayLocation.d.ts.map +1 -1
- package/dist/relayLocation.js +57 -19
- package/dist/relayRegistry.d.ts +12 -2
- package/dist/relayRegistry.d.ts.map +1 -1
- package/dist/relayRegistry.js +74 -14
- package/dist/relayRegistryTrust.d.ts +19 -0
- package/dist/relayRegistryTrust.d.ts.map +1 -0
- package/dist/relayRegistryTrust.js +456 -0
- package/dist/server.js +6361 -1381
- package/dist/tools.d.ts +2 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +37 -56
- package/package.json +4 -4
- package/dist/webSocketClient.d.ts +0 -28
- package/dist/webSocketClient.d.ts.map +0 -1
- package/dist/webSocketClient.js +0 -134
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ With it attached, agents can:
|
|
|
6
6
|
|
|
7
7
|
- Read the current Model, or any historical Model by history index
|
|
8
8
|
- Narrow reads with dot-string paths and summarized payloads to fit token budgets
|
|
9
|
-
- List and inspect the Message history, with Command and Mount lifecycle, diffs, and
|
|
9
|
+
- List and inspect the Message history, with Command and Mount lifecycle, diffs, and Block chains
|
|
10
10
|
- Query history server-side: filter entries by changed Model paths, count Messages by tag, tail the latest entries, and diff the Models at two indices
|
|
11
11
|
- Read the recorded init Model, the Commands returned from `init`, and the Mounts that fired during the first render
|
|
12
12
|
- Inspect Runtime state: current index, retained history bounds, pause status
|
|
@@ -36,17 +36,21 @@ pnpm add -D @foldkit/devtools-mcp
|
|
|
36
36
|
yarn add -D @foldkit/devtools-mcp
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
Set `devTools.dispatch` to `true` when calling `Program.make` to let the agent discover and dispatch Messages. Foldkit derives the dispatch Schema from the completed root Block, including generated envelopes for registered child Blocks. The Runtime rejects a payload that does not match that Schema before it reaches update:
|
|
40
40
|
|
|
41
41
|
```typescript
|
|
42
|
-
|
|
42
|
+
Program.make({
|
|
43
|
+
block: ApplicationBlock,
|
|
44
|
+
container,
|
|
43
45
|
devTools: {
|
|
44
46
|
// Rest of your DevTools config
|
|
45
|
-
|
|
47
|
+
dispatch: true,
|
|
46
48
|
},
|
|
47
49
|
})
|
|
48
50
|
```
|
|
49
51
|
|
|
52
|
+
If the completed Message Codec needs Services for decoding, `dispatch: true` adds them to the Program's requirements. Supply any remaining decoding Services through `program.provide(...)`; the dispatch opt-in does not provide them.
|
|
53
|
+
|
|
50
54
|
Restart your dev server, then restart your AI agent. The MCP server will appear with the `foldkit_*` tools attached.
|
|
51
55
|
|
|
52
56
|
The browser bridge runs inside your app, so the MCP server only sees a Runtime while the app is open in a browser tab. Close the tab and the Runtime disappears from `foldkit_list_runtimes`.
|
|
@@ -57,29 +61,29 @@ Treat this server as privileged access to the running application. A connected a
|
|
|
57
61
|
|
|
58
62
|
Keep API keys, passwords, access and refresh tokens, authorization headers, session material, and private personal data out of those values. Long-lived secrets belong behind a server boundary, including in local demos. If the browser must temporarily carry a sensitive value, use Effect `Redacted` with a `Schema.Redacted` field configured with `disallowJsonEncode: true`. `Redacted` protects normal string, JSON, and inspection output; it does not encrypt the value or hide a browser-side credential from the person running the browser.
|
|
59
63
|
|
|
60
|
-
`excludeFromHistory` is for noisy Messages, not sensitive ones. The resulting Model remains inspectable.
|
|
64
|
+
`excludeFromHistory` is for noisy Messages, not sensitive ones. The resulting Model remains inspectable. External dispatch is disabled by default; read and replay tools remain available. Set `devToolsMcpPort: false` in the Foldkit Vite plugin options to keep the in-browser DevTools without starting the MCP relay, or set `devTools: false` in the application config to disable recording and the browser bridge too.
|
|
61
65
|
|
|
62
66
|
## Tools
|
|
63
67
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
| Tool | Description
|
|
67
|
-
| ------------------------------- |
|
|
68
|
-
| `foldkit_list_runtimes` | Returns metadata for every connected browser tab. Agents call this first to discover which Runtime to target.
|
|
69
|
-
| `foldkit_get_model` | Snapshots the current Model. Accepts an optional `path` to narrow to a subtree and `expand` to control summarization.
|
|
70
|
-
| `foldkit_get_model_at` | Snapshots a historical Model after a given history entry. Pass `index: N - 1` to read the Model just before Message `N`. Same `path`/`expand` semantics as `foldkit_get_model`. Indices outside the readable range (older entries are evicted past the rolling buffer) are rejected with the valid bounds. For the initial Model (and the init Commands and Mounts), use `foldkit_get_init`.
|
|
71
|
-
| `foldkit_get_init` | Reads the recorded initial Model, the Commands returned from the application's `init` function, and the Mounts that fired during the first render. Each Command and Mount carries its declared args. Equivalent to selecting the synthetic "init" row in the DevTools panel.
|
|
72
|
-
| `foldkit_get_runtime_state` | Snapshots the Runtime's DevTools state: history bounds, current paused/live status, and whether init is recorded. Useful for understanding what `foldkit_list_messages` and `foldkit_get_message` will see and detecting whether the Runtime is paused.
|
|
73
|
-
| `foldkit_list_messages` | Lists Message history entries. Each entry carries the Message body, Commands triggered (with args), Mounts that started or ended during the resulting render (with args), timestamp, an `isModelChanged` flag, the diff path lists (`changedPaths` / `affectedPaths`), and any extracted
|
|
74
|
-
| `foldkit_count_messages_by_tag` | Counts retained history entries by Message tag, without payloads, sorted by count descending. A cheap reconnaissance call before paging through history: it surfaces the high-frequency Messages worth filtering out, and with `changed_paths_match` it answers which Message tags touch a Model subtree.
|
|
75
|
-
| `foldkit_diff_models` | Diffs the Models at two history indices server-side, returning path-level changes `{ path, before, after }` with summarized values. Each side is `{ _tag: 'Present', value }`, or `{ _tag: 'Absent' }` when the path does not exist on that side. Pass `changed_paths_match` to narrow the diff to a subtree.
|
|
76
|
-
| `foldkit_get_message` | Reads one entry at a given index. The response carries the SerializedEntry only; to inspect the Model around the entry, call `foldkit_get_model_at` with `index - 1` (before) and `index` (after). Use `foldkit_get_init` for the synthetic init entry.
|
|
77
|
-
| `foldkit_list_keyframes` | Returns the indices Foldkit can replay back to. Index `-1` is the initial Model.
|
|
78
|
-
| `foldkit_replay_to_keyframe` | Time-travels the Runtime to a previous state. The Runtime is paused at that snapshot until `foldkit_resume` is called.
|
|
79
|
-
| `foldkit_resume` | Resumes normal execution after a replay.
|
|
80
|
-
| `foldkit_get_message_schema` | Describes the Runtime's Message Schema so agents can construct valid Messages without reading the application source. With no arguments, returns a small variant index (top-level tag names plus payload fields). With `variant_tag` set to a dot-separated path of variant tags (e.g. `"GotChildMessage.Opened"`), narrows the JSON Schema along the chain and collapses deeper unions to summary placeholders. Returns `maybeResult: None` when
|
|
81
|
-
| `foldkit_dispatch_message` | Dispatches a Message into the Runtime as if your application produced it. The Runtime decodes the payload against your Schema and returns a clean error if it does not match.
|
|
82
|
-
| `foldkit_dispatch_messages` | Dispatches an ordered batch of 1 to 100 Messages in one call, first to last. The Runtime validates every payload before dispatching any of them, so one invalid entry rejects the whole batch with its zero-based position and nothing is dispatched. The response reports the predicted history index for each Message.
|
|
68
|
+
Every tool except `foldkit_list_runtimes` accepts an optional `runtime_id`. Without one, the tool uses the most recently connected Runtime of the most recently started dev server.
|
|
69
|
+
|
|
70
|
+
| Tool | Description |
|
|
71
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
72
|
+
| `foldkit_list_runtimes` | Returns metadata for every connected browser tab. Each tab carries the `projectRoot` of the dev server it belongs to, unless `FOLDKIT_DEVTOOLS_MCP_PORT` or `FOLDKIT_DEVTOOLS_MCP_HOST` is set. Agents call this first to discover which Runtime to target. |
|
|
73
|
+
| `foldkit_get_model` | Snapshots the current Model. Accepts an optional `path` to narrow to a subtree and `expand` to control summarization. |
|
|
74
|
+
| `foldkit_get_model_at` | Snapshots a historical Model after a given history entry. Pass `index: N - 1` to read the Model just before Message `N`. Same `path`/`expand` semantics as `foldkit_get_model`. Indices outside the readable range (older entries are evicted past the rolling buffer) are rejected with the valid bounds. For the initial Model (and the init Commands and Mounts), use `foldkit_get_init`. |
|
|
75
|
+
| `foldkit_get_init` | Reads the recorded initial Model, the Commands returned from the application's `init` function, and the Mounts that fired during the first render. Each Command and Mount carries its declared args. Equivalent to selecting the synthetic "init" row in the DevTools panel. |
|
|
76
|
+
| `foldkit_get_runtime_state` | Snapshots the Runtime's DevTools state: history bounds, current paused/live status, and whether init is recorded. Useful for understanding what `foldkit_list_messages` and `foldkit_get_message` will see and detecting whether the Runtime is paused. |
|
|
77
|
+
| `foldkit_list_messages` | Lists Message history entries. Each entry carries the Message body, Commands triggered (with args), Mounts that started or ended during the resulting render (with args), timestamp, an `isModelChanged` flag, the diff path lists (`changedPaths` / `affectedPaths`), and any extracted Block chain. Filter server-side with `changed_paths_match`, read the latest entries with `from_end`, and paginate forward with `since_index`. |
|
|
78
|
+
| `foldkit_count_messages_by_tag` | Counts retained history entries by Message tag, without payloads, sorted by count descending. A cheap reconnaissance call before paging through history: it surfaces the high-frequency Messages worth filtering out, and with `changed_paths_match` it answers which Message tags touch a Model subtree. |
|
|
79
|
+
| `foldkit_diff_models` | Diffs the Models at two history indices server-side, returning path-level changes `{ path, before, after }` with summarized values. Each side is `{ _tag: 'Present', value }`, or `{ _tag: 'Absent' }` when the path does not exist on that side. Pass `changed_paths_match` to narrow the diff to a subtree. |
|
|
80
|
+
| `foldkit_get_message` | Reads one entry at a given index. The response carries the SerializedEntry only; to inspect the Model around the entry, call `foldkit_get_model_at` with `index - 1` (before) and `index` (after). Use `foldkit_get_init` for the synthetic init entry. |
|
|
81
|
+
| `foldkit_list_keyframes` | Returns the indices Foldkit can replay back to. Index `-1` is the initial Model. |
|
|
82
|
+
| `foldkit_replay_to_keyframe` | Time-travels the Runtime to a previous state. The Runtime is paused at that snapshot until `foldkit_resume` is called. |
|
|
83
|
+
| `foldkit_resume` | Resumes normal execution after a replay. |
|
|
84
|
+
| `foldkit_get_message_schema` | Describes the Runtime's Message Schema so agents can construct valid Messages without reading the application source. With no arguments, returns a small variant index (top-level tag names plus payload fields). With `variant_tag` set to a dot-separated path of variant tags (e.g. `"GotChildMessage.Opened"`), narrows the JSON Schema along the chain and collapses deeper unions to summary placeholders. Returns `maybeResult: None` when external dispatch is not enabled. |
|
|
85
|
+
| `foldkit_dispatch_message` | Dispatches a Message into the Runtime as if your application produced it. The Runtime decodes the payload against your Schema and returns a clean error if it does not match. |
|
|
86
|
+
| `foldkit_dispatch_messages` | Dispatches an ordered batch of 1 to 100 Messages in one call, first to last. The Runtime validates every payload before dispatching any of them, so one invalid entry rejects the whole batch with its zero-based position and nothing is dispatched. The response reports the predicted history index for each Message. |
|
|
83
87
|
|
|
84
88
|
### Reading the Model efficiently
|
|
85
89
|
|
|
@@ -102,30 +106,34 @@ High-frequency flows (drag-paint, scroll, keystroke) can fill the history buffer
|
|
|
102
106
|
Three components cooperate:
|
|
103
107
|
|
|
104
108
|
- **Browser bridge** (in `foldkit`): runs alongside DevTools, subscribes to the DevTools store, and exchanges typed frames over Vite's HMR WebSocket.
|
|
105
|
-
- **Vite plugin relay** (in `@foldkit/vite-plugin`):
|
|
109
|
+
- **Vite plugin relay** (in `@foldkit/vite-plugin`): gives each dev server its own loopback listener by default, publishes its address, and forwards traffic between browsers and MCP clients.
|
|
106
110
|
- **MCP server** (this package): runs as a Node child process under your AI agent, connects to the plugin's relay over WebSocket, and exposes the typed tools over MCP's stdio transport.
|
|
107
111
|
|
|
108
112
|
Multiple browser tabs can be connected at once and each is addressable by its connection id. Tabs that close (gracefully or not) are pruned from the live Runtime list automatically.
|
|
109
113
|
|
|
114
|
+
The bridge derives JSON Schema for its transport boundary. A Message payload must have a supported JSON representation to be dispatched from MCP. Browser-memory values such as `File` cannot be constructed through this connection.
|
|
115
|
+
|
|
110
116
|
## Configuration
|
|
111
117
|
|
|
112
|
-
|
|
118
|
+
An agent started at a workspace root can inspect every Foldkit app running under it. If no dev server runs under the agent's project root, the MCP server finds the nearest root that encloses it. This also lets an agent started in `src/` or through a symlink find the app. `foldkit_list_runtimes` lists the Runtimes from every matching dev server and includes each server's `projectRoot`.
|
|
119
|
+
|
|
120
|
+
The MCP server connects when a tool is called. It checks the registry on every call, so the next call can reach a dev server that started or restarted after the agent.
|
|
113
121
|
|
|
114
|
-
| Environment variable | What it changes
|
|
115
|
-
| ---------------------------------- |
|
|
116
|
-
| `FOLDKIT_PROJECT_ROOT` | Project to search. Defaults to the working directory.
|
|
117
|
-
| `FOLDKIT_DEVTOOLS_MCP_PORT` | Skips discovery and connects to this port. Set it to the `devToolsMcpPort` in your Vite config.
|
|
118
|
-
| `FOLDKIT_DEVTOOLS_MCP_HOST` |
|
|
119
|
-
| `FOLDKIT_DEVTOOLS_RELAY_DIRECTORY` | Registry location. Defaults to a directory under `XDG_RUNTIME_DIR` when set, or
|
|
122
|
+
| Environment variable | What it changes |
|
|
123
|
+
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
124
|
+
| `FOLDKIT_PROJECT_ROOT` | Project to search. Defaults to the working directory. |
|
|
125
|
+
| `FOLDKIT_DEVTOOLS_MCP_PORT` | Skips discovery and connects to this port. Set it to the `devToolsMcpPort` in your Vite config. When discovery finds no relay, set this variable to use a fixed port. |
|
|
126
|
+
| `FOLDKIT_DEVTOOLS_MCP_HOST` | Skips discovery and connects to this host, on `FOLDKIT_DEVTOOLS_MCP_PORT` or `9988`. Set it when a `devToolsMcpPort` relay runs on another host. |
|
|
127
|
+
| `FOLDKIT_DEVTOOLS_RELAY_DIRECTORY` | Registry location. Defaults to a directory under `XDG_RUNTIME_DIR` when set, `~/.cache` on macOS and Linux, or the user temporary directory on Windows. Set it in both processes if they use different sandboxes. On Windows, it must be a direct child of the user's temporary directory. |
|
|
120
128
|
|
|
121
|
-
|
|
129
|
+
An automatically discovered relay's registry record contains a random token. The MCP client uses it to sign a connection challenge and checks the relay's response before sending tool requests. The plugin refuses to publish the record if another user can read or replace the registry directory. The MCP server applies the same check before reading it. Both reject symlinks and junctions; on macOS they also check directory ACLs. Clients without access to the registry cannot connect, but the agent using this MCP server can inspect the Runtime data.
|
|
122
130
|
|
|
123
|
-
On Windows, directory
|
|
131
|
+
On Windows, both processes use PowerShell to check the directory's owner, access list, ancestors, and reparse points. The registry must be a private direct child of the user's temporary directory. If the check cannot run or refuses the directory, configure `devToolsMcpPort` in Vite and set `FOLDKIT_DEVTOOLS_MCP_PORT` to the same port. A fixed port listens on every interface without a token. Do not use it on a shared or untrusted network.
|
|
124
132
|
|
|
125
133
|
## Notes
|
|
126
134
|
|
|
127
135
|
- The MCP bridge shares its lifecycle with Foldkit DevTools. If you set `devTools: false` in your program config, the bridge does not start and the Runtime is invisible to MCP. The default enables the bridge in dev.
|
|
128
|
-
-
|
|
136
|
+
- External dispatch and Message Schema discovery require `devTools: { dispatch: true }`. The other tools still work with the default configuration.
|
|
129
137
|
- The relay only runs at dev time. Production builds never include the relay or the bridge, regardless of any `show` setting.
|
|
130
138
|
|
|
131
139
|
## Documentation
|
package/dist/install.js
CHANGED
|
@@ -36,9 +36,9 @@ const printNextSteps = (alreadyRegistered) => {
|
|
|
36
36
|
console.log('');
|
|
37
37
|
console.log('Next steps:');
|
|
38
38
|
console.log('');
|
|
39
|
-
console.log(' 1.
|
|
39
|
+
console.log(' 1. Enable Message dispatch in Program.make or Program.makeElement:');
|
|
40
40
|
console.log('');
|
|
41
|
-
console.log(' devTools: {
|
|
41
|
+
console.log(' devTools: { dispatch: true }');
|
|
42
42
|
console.log('');
|
|
43
43
|
console.log(' 2. Start your dev server, then restart your AI agent (Claude Code, Cursor, etc.).');
|
|
44
44
|
console.log('');
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Cause, Effect, Option } from 'effect';
|
|
2
|
+
import { Request, type Response, type RuntimeInfo } from 'foldkit/devtools-protocol';
|
|
3
|
+
import type { RelayTarget } from './relayLocation.js';
|
|
4
|
+
export type ListedRuntime = Readonly<{
|
|
5
|
+
runtime: typeof RuntimeInfo.Type;
|
|
6
|
+
maybeProjectRoot: Option.Option<string>;
|
|
7
|
+
}>;
|
|
8
|
+
/**
|
|
9
|
+
* Connects MCP tool calls to the DevTools relays for a project.
|
|
10
|
+
*
|
|
11
|
+
* Each call checks the registry, connects to matching relays, and lists their
|
|
12
|
+
* Runtimes before routing a request. It closes connections to relays that are
|
|
13
|
+
* no longer published. Concurrent calls share one registry lookup. A dev server
|
|
14
|
+
* started or restarted after the previous call is reached on the next one.
|
|
15
|
+
*
|
|
16
|
+
* `listRuntimes` returns Runtimes from every connected relay, oldest dev server
|
|
17
|
+
* first. `sendRequest` uses the newest relay that lists the requested Runtime.
|
|
18
|
+
* Without an id, it uses the most recently connected Runtime of the most
|
|
19
|
+
* recently started dev server. An id that no relay lists returns an error
|
|
20
|
+
* without waiting for the request timeout.
|
|
21
|
+
*
|
|
22
|
+
* Cancelling one call leaves shared discovery available to other calls. `close`
|
|
23
|
+
* interrupts discovery, closes the relay connections, and fails pending
|
|
24
|
+
* requests.
|
|
25
|
+
*/
|
|
26
|
+
export type RelayClient = Readonly<{
|
|
27
|
+
listRuntimes: Effect.Effect<ReadonlyArray<ListedRuntime>, Error>;
|
|
28
|
+
sendRequest: (request: typeof Request.Type, maybeRuntimeId: Option.Option<string>) => Effect.Effect<typeof Response.Type, Cause.TimeoutError | Error>;
|
|
29
|
+
close: Effect.Effect<void>;
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* Returns an error message for display. Effect's `TimeoutError` has no
|
|
33
|
+
* `message`, so its string form is used instead.
|
|
34
|
+
*/
|
|
35
|
+
export declare const errorReason: (error: Error) => string;
|
|
36
|
+
export declare const makeRelayClient: <Services>(resolveTargets: Effect.Effect<ReadonlyArray<RelayTarget>, never, Services>) => Effect.Effect<RelayClient, never, Services>;
|
|
37
|
+
//# sourceMappingURL=relayClient.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relayClient.d.ts","sourceRoot":"","sources":["../src/relayClient.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,KAAK,EAKV,MAAM,EAKN,MAAM,EASP,MAAM,QAAQ,CAAA;AACf,OAAO,EACL,OAAO,EAEP,KAAK,QAAQ,EAEb,KAAK,WAAW,EACjB,MAAM,2BAA2B,CAAA;AAIlC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAqDrD,MAAM,MAAM,aAAa,GAAG,QAAQ,CAAC;IACnC,OAAO,EAAE,OAAO,WAAW,CAAC,IAAI,CAAA;IAChC,gBAAgB,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;CACxC,CAAC,CAAA;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,WAAW,GAAG,QAAQ,CAAC;IACjC,YAAY,EAAE,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,aAAa,CAAC,EAAE,KAAK,CAAC,CAAA;IAChE,WAAW,EAAE,CACX,OAAO,EAAE,OAAO,OAAO,CAAC,IAAI,EAC5B,cAAc,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,KAClC,MAAM,CAAC,MAAM,CAAC,OAAO,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,YAAY,GAAG,KAAK,CAAC,CAAA;IACpE,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;CAC3B,CAAC,CAAA;AAiBF;;;GAGG;AACH,eAAO,MAAM,WAAW,UAAW,KAAK,KAAG,MACe,CAAA;AA4H1D,eAAO,MAAM,eAAe,GAAI,QAAQ,kBACtB,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,WAAW,CAAC,EAAE,KAAK,EAAE,QAAQ,CAAC,KACzE,MAAM,CAAC,MAAM,CAAC,WAAW,EAAE,KAAK,EAAE,QAAQ,CAkTzC,CAAA"}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { Array, Console, Data, Deferred, Duration, Effect, Exit, HashMap, HashSet, Match, Option, Predicate, Ref, Result, Schema, Scope, Semaphore, String, pipe, } from 'effect';
|
|
2
|
+
import { Request, RequestFrame, ResponseFrame, } from 'foldkit/devtools-protocol';
|
|
3
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { WebSocket } from 'ws';
|
|
5
|
+
const REQUEST_TIMEOUT = Duration.seconds(10);
|
|
6
|
+
const CONNECT_TIMEOUT = Duration.seconds(2);
|
|
7
|
+
const LIST_RUNTIMES_TIMEOUT = Duration.seconds(2);
|
|
8
|
+
const NOT_CONNECTED_REASON = 'Not connected to a Foldkit dev server. Start your Foldkit Vite dev server and retry the tool call.';
|
|
9
|
+
const NO_RUNTIMES_REASON = 'No connected Foldkit runtimes. Open a Foldkit dev page and try again.';
|
|
10
|
+
const INVALID_ADDRESS_REASON = 'the relay address is not a valid URL';
|
|
11
|
+
const UNVERIFIED_RELAY_REASON = 'the relay could not prove its identity';
|
|
12
|
+
const CLOSED_REASON = 'The Foldkit relay client is closed.';
|
|
13
|
+
const RELAY_TOKEN_PARAMETER = 'token';
|
|
14
|
+
const RELAY_CHALLENGE_HEADER = 'X-Foldkit-Relay-Challenge';
|
|
15
|
+
const RELAY_CLIENT_PROOF_HEADER = 'X-Foldkit-Relay-Client-Proof';
|
|
16
|
+
const RELAY_SERVER_PROOF_HEADER = 'x-foldkit-relay-server-proof';
|
|
17
|
+
const RELAY_TOKEN_PATTERN = /^[0-9a-f]{64}$/;
|
|
18
|
+
const RELAY_PROOF_PATTERN = /^[0-9a-f]{64}$/;
|
|
19
|
+
const RELAY_CHALLENGE_BYTES = 32;
|
|
20
|
+
const encodeRequestFrameToJson = Schema.encodeUnknownSync(Schema.fromJsonString(RequestFrame));
|
|
21
|
+
const RefreshTurn = Data.taggedEnum();
|
|
22
|
+
const generateRequestId = () => `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
23
|
+
const parseUrl = Option.liftThrowable((url) => new URL(url));
|
|
24
|
+
const relayUrlForLog = (url) => Option.match(parseUrl(url), {
|
|
25
|
+
onNone: () => Array.headNonEmpty(String.split(url, '?')),
|
|
26
|
+
onSome: parsed => {
|
|
27
|
+
parsed.search = '';
|
|
28
|
+
return parsed.toString();
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Returns an error message for display. Effect's `TimeoutError` has no
|
|
33
|
+
* `message`, so its string form is used instead.
|
|
34
|
+
*/
|
|
35
|
+
export const errorReason = (error) => error.message ? error.message : globalThis.String(error);
|
|
36
|
+
const toError = (description) => (cause) => cause instanceof Error
|
|
37
|
+
? cause
|
|
38
|
+
: new Error(`${description}: ${globalThis.String(cause)}`);
|
|
39
|
+
const computeRelayProof = (token, role, challenge) => createHmac('sha256', token).update(`${role}:${challenge}`).digest('hex');
|
|
40
|
+
const attemptOpen = (url) => Effect.suspend(() => Option.match(parseUrl(url), {
|
|
41
|
+
onNone: () => Effect.fail(new Error(INVALID_ADDRESS_REASON)),
|
|
42
|
+
onSome: parsedUrl => {
|
|
43
|
+
const maybeToken = Option.fromNullishOr(parsedUrl.searchParams.get(RELAY_TOKEN_PARAMETER));
|
|
44
|
+
if (Option.isSome(maybeToken) &&
|
|
45
|
+
!RELAY_TOKEN_PATTERN.test(maybeToken.value)) {
|
|
46
|
+
return Effect.fail(new Error(INVALID_ADDRESS_REASON));
|
|
47
|
+
}
|
|
48
|
+
const maybeChallenge = Option.map(maybeToken, () => randomBytes(RELAY_CHALLENGE_BYTES).toString('hex'));
|
|
49
|
+
parsedUrl.searchParams.delete(RELAY_TOKEN_PARAMETER);
|
|
50
|
+
return Effect.callback(resume => {
|
|
51
|
+
const constructed = Result.try({
|
|
52
|
+
try: () => Option.match(Option.all([maybeToken, maybeChallenge]), {
|
|
53
|
+
onNone: () => new WebSocket(parsedUrl),
|
|
54
|
+
onSome: ([token, challenge]) => new WebSocket(parsedUrl, {
|
|
55
|
+
headers: {
|
|
56
|
+
[RELAY_CHALLENGE_HEADER]: challenge,
|
|
57
|
+
[RELAY_CLIENT_PROOF_HEADER]: computeRelayProof(token, 'client', challenge),
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
}),
|
|
61
|
+
catch: toError('Failed to open a connection'),
|
|
62
|
+
});
|
|
63
|
+
if (Result.isFailure(constructed)) {
|
|
64
|
+
resume(Effect.fail(constructed.failure));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const socket = constructed.success;
|
|
68
|
+
let isSettled = false;
|
|
69
|
+
let isVerified = Option.isNone(maybeToken);
|
|
70
|
+
socket.once('upgrade', response => {
|
|
71
|
+
if (Option.isNone(maybeToken) || Option.isNone(maybeChallenge)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const presented = response.headers[RELAY_SERVER_PROOF_HEADER];
|
|
75
|
+
const expected = Buffer.from(computeRelayProof(maybeToken.value, 'server', maybeChallenge.value), 'hex');
|
|
76
|
+
const isValidProof = Predicate.isString(presented) &&
|
|
77
|
+
RELAY_PROOF_PATTERN.test(presented) &&
|
|
78
|
+
timingSafeEqual(expected, Buffer.from(presented, 'hex'));
|
|
79
|
+
if (isValidProof) {
|
|
80
|
+
isVerified = true;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
isSettled = true;
|
|
84
|
+
socket.terminate();
|
|
85
|
+
resume(Effect.fail(new Error(UNVERIFIED_RELAY_REASON)));
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
socket.once('open', () => {
|
|
89
|
+
if (!isVerified) {
|
|
90
|
+
isSettled = true;
|
|
91
|
+
socket.terminate();
|
|
92
|
+
resume(Effect.fail(new Error(UNVERIFIED_RELAY_REASON)));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
isSettled = true;
|
|
96
|
+
resume(Effect.succeed(socket));
|
|
97
|
+
});
|
|
98
|
+
socket.on('error', error => {
|
|
99
|
+
if (!isSettled) {
|
|
100
|
+
isSettled = true;
|
|
101
|
+
resume(Effect.fail(error));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
return Effect.sync(() => {
|
|
105
|
+
isSettled = true;
|
|
106
|
+
socket.terminate();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
}));
|
|
111
|
+
const isOpen = ({ socket }) => socket.readyState === WebSocket.OPEN;
|
|
112
|
+
export const makeRelayClient = (resolveTargets) => Effect.gen(function* () {
|
|
113
|
+
const pendingResponsesRef = yield* Ref.make(HashMap.empty());
|
|
114
|
+
const socketsRef = yield* Ref.make(HashMap.empty());
|
|
115
|
+
const refreshLock = yield* Semaphore.make(1);
|
|
116
|
+
const pendingRefreshRef = yield* Ref.make(Option.none());
|
|
117
|
+
const refreshScope = yield* Scope.make();
|
|
118
|
+
const closed = yield* Deferred.make();
|
|
119
|
+
const context = yield* Effect.context();
|
|
120
|
+
const attachHandlers = (socket) => {
|
|
121
|
+
socket.on('message', raw => {
|
|
122
|
+
Effect.runForkWith(context)(handleIncomingMessage(raw, pendingResponsesRef));
|
|
123
|
+
});
|
|
124
|
+
socket.on('error', error => {
|
|
125
|
+
console.error(`[foldkit-devtools-mcp] socket error: ${error.message}`);
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
const openTarget = (target) => Effect.uninterruptibleMask(restore => restore(attemptOpen(target.url).pipe(Effect.timeoutOrElse({
|
|
129
|
+
duration: CONNECT_TIMEOUT,
|
|
130
|
+
orElse: () => Effect.fail(new Error(`no connection within ${Duration.format(CONNECT_TIMEOUT)}`)),
|
|
131
|
+
}))).pipe(Effect.flatMap(socket => Effect.gen(function* () {
|
|
132
|
+
attachHandlers(socket);
|
|
133
|
+
yield* Ref.update(socketsRef, HashMap.set(target.key, { socket, target }));
|
|
134
|
+
yield* Console.error(`[foldkit-devtools-mcp] connected to ${relayUrlForLog(target.url)}`);
|
|
135
|
+
})))).pipe(Effect.catch(error => Console.error(`[foldkit-devtools-mcp] connect attempt to ${relayUrlForLog(target.url)} failed: ${errorReason(error)}`)));
|
|
136
|
+
const reconcile = Effect.gen(function* () {
|
|
137
|
+
if (yield* Deferred.isDone(closed)) {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
const targets = yield* Effect.provideContext(resolveTargets, context);
|
|
141
|
+
const targetKeys = HashSet.fromIterable(Array.map(targets, ({ key }) => key));
|
|
142
|
+
const sockets = yield* Ref.get(socketsRef);
|
|
143
|
+
const isKept = (relaySocket, key) => HashSet.has(targetKeys, key) && isOpen(relaySocket);
|
|
144
|
+
const keptSockets = HashMap.filter(sockets, isKept);
|
|
145
|
+
const droppedSockets = HashMap.filter(sockets, (relaySocket, key) => !isKept(relaySocket, key));
|
|
146
|
+
yield* Effect.sync(() => {
|
|
147
|
+
for (const { socket } of HashMap.values(droppedSockets)) {
|
|
148
|
+
socket.terminate();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
yield* Ref.set(socketsRef, keptSockets);
|
|
152
|
+
const missingTargets = Array.filter(targets, ({ key }) => !HashMap.has(keptSockets, key));
|
|
153
|
+
yield* Effect.forEach(missingTargets, openTarget, {
|
|
154
|
+
concurrency: 'unbounded',
|
|
155
|
+
discard: true,
|
|
156
|
+
});
|
|
157
|
+
const nextSockets = yield* Ref.get(socketsRef);
|
|
158
|
+
return pipe(targets, Array.reverse, Array.map(({ key }) => HashMap.get(nextSockets, key)), Array.getSomes);
|
|
159
|
+
});
|
|
160
|
+
const takeRefreshTurn = (ownRefresh) => Ref.modify(pendingRefreshRef, (maybePendingRefresh) => Option.match(maybePendingRefresh, {
|
|
161
|
+
onNone: () => [
|
|
162
|
+
RefreshTurn.Leader({ refresh: ownRefresh }),
|
|
163
|
+
Option.some(ownRefresh),
|
|
164
|
+
],
|
|
165
|
+
onSome: refresh => [
|
|
166
|
+
RefreshTurn.Follower({ refresh }),
|
|
167
|
+
maybePendingRefresh,
|
|
168
|
+
],
|
|
169
|
+
}));
|
|
170
|
+
const leadRefresh = (refresh) => Deferred.into(refreshLock.withPermits(1)(Effect.gen(function* () {
|
|
171
|
+
yield* Ref.set(pendingRefreshRef, Option.none());
|
|
172
|
+
return yield* reconcile;
|
|
173
|
+
})), refresh).pipe(Effect.asVoid);
|
|
174
|
+
const refreshRelays = Effect.uninterruptibleMask(restore => Effect.gen(function* () {
|
|
175
|
+
if (yield* Deferred.isDone(closed)) {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
const ownRefresh = yield* Deferred.make();
|
|
179
|
+
const turn = yield* takeRefreshTurn(ownRefresh);
|
|
180
|
+
yield* Match.value(turn).pipe(Match.withReturnType(), Match.tagsExhaustive({
|
|
181
|
+
Leader: ({ refresh }) => Effect.forkIn(leadRefresh(refresh), refreshScope, {
|
|
182
|
+
startImmediately: true,
|
|
183
|
+
}).pipe(Effect.asVoid),
|
|
184
|
+
Follower: () => Effect.void,
|
|
185
|
+
}));
|
|
186
|
+
return yield* restore(Deferred.await(turn.refresh));
|
|
187
|
+
}));
|
|
188
|
+
const exchange = (socket, request, maybeConnectionId, timeout) => Effect.acquireUseRelease(Effect.gen(function* () {
|
|
189
|
+
if (yield* Deferred.isDone(closed)) {
|
|
190
|
+
return yield* Effect.fail(new Error(CLOSED_REASON));
|
|
191
|
+
}
|
|
192
|
+
const id = generateRequestId();
|
|
193
|
+
const deferred = yield* Deferred.make();
|
|
194
|
+
yield* Ref.update(pendingResponsesRef, HashMap.set(id, deferred));
|
|
195
|
+
return { id, deferred };
|
|
196
|
+
}), ({ id, deferred }) => {
|
|
197
|
+
const frame = {
|
|
198
|
+
id,
|
|
199
|
+
maybeConnectionId,
|
|
200
|
+
request,
|
|
201
|
+
};
|
|
202
|
+
return Effect.try({
|
|
203
|
+
try: () => socket.send(encodeRequestFrameToJson(frame)),
|
|
204
|
+
catch: toError('Failed to send request'),
|
|
205
|
+
}).pipe(Effect.andThen(Effect.raceFirst(Deferred.await(deferred).pipe(Effect.timeout(timeout)), Deferred.await(closed).pipe(Effect.andThen(Effect.fail(new Error(CLOSED_REASON)))))));
|
|
206
|
+
}, ({ id }) => Ref.update(pendingResponsesRef, HashMap.remove(id)));
|
|
207
|
+
const listRelayRuntimes = ({ socket, target, }) => exchange(socket, Request.RequestListRuntimes(), Option.none(), LIST_RUNTIMES_TIMEOUT).pipe(Effect.flatMap(response => Match.value(response).pipe(Match.withReturnType(), Match.tag('ResponseRuntimes', ({ runtimes }) => Effect.succeed(runtimes)), Match.tag('ResponseError', ({ reason }) => Effect.fail(new Error(reason))), Match.orElse(({ _tag }) => Effect.fail(new Error(`the relay answered ${_tag}`))))), Effect.catch(error => Console.error(`[foldkit-devtools-mcp] listing runtimes at ${relayUrlForLog(target.url)} failed: ${errorReason(error)}`).pipe(Effect.as([]))));
|
|
208
|
+
const listRelays = Effect.gen(function* () {
|
|
209
|
+
const relaySockets = yield* refreshRelays;
|
|
210
|
+
if (Array.isReadonlyArrayEmpty(relaySockets)) {
|
|
211
|
+
return yield* Effect.fail(new Error(NOT_CONNECTED_REASON));
|
|
212
|
+
}
|
|
213
|
+
return yield* Effect.forEach(relaySockets, relaySocket => Effect.map(listRelayRuntimes(relaySocket), runtimes => ({
|
|
214
|
+
relaySocket,
|
|
215
|
+
runtimes,
|
|
216
|
+
})), { concurrency: 'unbounded' });
|
|
217
|
+
});
|
|
218
|
+
const listRuntimes = Effect.map(listRelays, listings => Array.flatMap(listings, ({ relaySocket, runtimes }) => Array.map(runtimes, runtime => ({
|
|
219
|
+
runtime,
|
|
220
|
+
maybeProjectRoot: relaySocket.target.maybeProjectRoot,
|
|
221
|
+
}))));
|
|
222
|
+
const sendRequest = (request, maybeRuntimeId) => Effect.gen(function* () {
|
|
223
|
+
const listings = yield* listRelays;
|
|
224
|
+
const route = yield* routeFromListings(listings, maybeRuntimeId);
|
|
225
|
+
return yield* exchange(route.socket, request, Option.some(route.connectionId), REQUEST_TIMEOUT);
|
|
226
|
+
});
|
|
227
|
+
const close = Effect.uninterruptible(Effect.gen(function* () {
|
|
228
|
+
yield* Deferred.succeed(closed, undefined);
|
|
229
|
+
yield* Scope.close(refreshScope, Exit.void);
|
|
230
|
+
yield* refreshLock.withPermits(1)(Effect.gen(function* () {
|
|
231
|
+
const sockets = yield* Ref.getAndSet(socketsRef, HashMap.empty());
|
|
232
|
+
yield* Effect.sync(() => {
|
|
233
|
+
for (const { socket } of HashMap.values(sockets)) {
|
|
234
|
+
socket.terminate();
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}));
|
|
238
|
+
}));
|
|
239
|
+
const client = { listRuntimes, sendRequest, close };
|
|
240
|
+
return client;
|
|
241
|
+
});
|
|
242
|
+
const routeToRuntime = (listings, runtimeId) => Option.match(Array.findLast(listings, ({ runtimes }) => Array.some(runtimes, ({ connectionId }) => connectionId === runtimeId)), {
|
|
243
|
+
onNone: () => Effect.fail(new Error(`No connected Foldkit Runtime has the id ${runtimeId}. Call foldkit_list_runtimes for the current ids.`)),
|
|
244
|
+
onSome: ({ relaySocket }) => Effect.succeed({
|
|
245
|
+
socket: relaySocket.socket,
|
|
246
|
+
connectionId: runtimeId,
|
|
247
|
+
}),
|
|
248
|
+
});
|
|
249
|
+
const routeToDefaultRuntime = (listings) => Option.match(pipe(listings, Array.flatMap(({ relaySocket, runtimes }) => Array.map(runtimes, ({ connectionId }) => ({
|
|
250
|
+
socket: relaySocket.socket,
|
|
251
|
+
connectionId,
|
|
252
|
+
}))), Array.last), {
|
|
253
|
+
onNone: () => Effect.fail(new Error(NO_RUNTIMES_REASON)),
|
|
254
|
+
onSome: Effect.succeed,
|
|
255
|
+
});
|
|
256
|
+
const routeFromListings = (listings, maybeRuntimeId) => Option.match(maybeRuntimeId, {
|
|
257
|
+
onNone: () => routeToDefaultRuntime(listings),
|
|
258
|
+
onSome: runtimeId => routeToRuntime(listings, runtimeId),
|
|
259
|
+
});
|
|
260
|
+
const handleIncomingMessage = (raw, pendingResponsesRef) => {
|
|
261
|
+
const decoded = Schema.decodeExit(Schema.fromJsonString(ResponseFrame))(raw.toString());
|
|
262
|
+
return Exit.match(decoded, {
|
|
263
|
+
onFailure: error => Effect.sync(() => console.error('[foldkit-devtools-mcp] failed to decode frame', error)),
|
|
264
|
+
onSuccess: responseFrame => Effect.gen(function* () {
|
|
265
|
+
const map = yield* Ref.get(pendingResponsesRef);
|
|
266
|
+
const maybeDeferred = HashMap.get(map, responseFrame.id);
|
|
267
|
+
yield* Option.match(maybeDeferred, {
|
|
268
|
+
onNone: () => Effect.void,
|
|
269
|
+
onSome: deferred => Effect.gen(function* () {
|
|
270
|
+
yield* Ref.update(pendingResponsesRef, HashMap.remove(responseFrame.id));
|
|
271
|
+
yield* Deferred.succeed(deferred, responseFrame.response);
|
|
272
|
+
}),
|
|
273
|
+
});
|
|
274
|
+
}),
|
|
275
|
+
});
|
|
276
|
+
};
|
package/dist/relayLocation.d.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { Effect, Option } from 'effect';
|
|
2
|
+
import type { ChildProcessSpawner } from 'effect/process';
|
|
3
|
+
import { type RelayRegistryReader, type RelayRegistryServices } from './relayRegistry.js';
|
|
2
4
|
export type Settings = Readonly<{
|
|
3
5
|
maybeConfiguredPort: Option.Option<string>;
|
|
4
6
|
maybeConfiguredHost: Option.Option<string>;
|
|
5
7
|
projectRoot: string;
|
|
6
8
|
}>;
|
|
9
|
+
export type RelayTarget = Readonly<{
|
|
10
|
+
key: string;
|
|
11
|
+
url: string;
|
|
12
|
+
maybeProjectRoot: Option.Option<string>;
|
|
13
|
+
}>;
|
|
7
14
|
export declare const loadSettings: Effect.Effect<Settings>;
|
|
8
|
-
export declare const
|
|
15
|
+
export declare const configuredRelayUrl: (settings: Settings) => Option.Option<string>;
|
|
16
|
+
export declare const resolveRelayTargets: (settings: Settings, registryReader: RelayRegistryReader) => Effect.Effect<ReadonlyArray<RelayTarget>, never, RelayRegistryServices | ChildProcessSpawner.ChildProcessSpawner>;
|
|
9
17
|
//# sourceMappingURL=relayLocation.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relayLocation.d.ts","sourceRoot":"","sources":["../src/relayLocation.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"relayLocation.d.ts","sourceRoot":"","sources":["../src/relayLocation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AACtD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAA;AAGzD,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,EAE3B,MAAM,oBAAoB,CAAA;AAS3B,MAAM,MAAM,QAAQ,GAAG,QAAQ,CAAC;IAC9B,mBAAmB,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IAC1C,mBAAmB,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IAC1C,WAAW,EAAE,MAAM,CAAA;CACpB,CAAC,CAAA;AAEF,MAAM,MAAM,WAAW,GAAG,QAAQ,CAAC;IACjC,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,gBAAgB,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;CACxC,CAAC,CAAA;AAEF,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAe5B,CAAA;AAmDrB,eAAO,MAAM,kBAAkB,aACnB,QAAQ,KACjB,MAAM,CAAC,MAAM,CAAC,MAAM,CAiBtB,CAAA;AAED,eAAO,MAAM,mBAAmB,aACpB,QAAQ,kBACF,mBAAmB,KAClC,MAAM,CAAC,MAAM,CACd,aAAa,CAAC,WAAW,CAAC,EAC1B,KAAK,EACL,qBAAqB,GAAG,mBAAmB,CAAC,mBAAmB,CAmB7D,CAAA"}
|
package/dist/relayLocation.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { Config, Effect, Option } from 'effect';
|
|
2
|
-
import {
|
|
1
|
+
import { Array, Config, Effect, Option } from 'effect';
|
|
2
|
+
import { isIPv6 } from 'node:net';
|
|
3
|
+
import { discoverRelays, } from './relayRegistry.js';
|
|
3
4
|
const LEGACY_DEFAULT_PORT = 9988;
|
|
4
5
|
const DEFAULT_HOST = 'localhost';
|
|
6
|
+
const AUTOMATIC_RELAY_HOST = '127.0.0.1';
|
|
7
|
+
const AUTOMATIC_RELAY_PATH = '/__foldkit/devtools-mcp';
|
|
8
|
+
const RELAY_TOKEN_PARAMETER = 'token';
|
|
9
|
+
const RELAY_TOKEN_PATTERN = /^[0-9a-f]{64}$/;
|
|
5
10
|
export const loadSettings = Effect.gen(function* () {
|
|
6
11
|
const maybeConfiguredPort = yield* Config.option(Config.String('FOLDKIT_DEVTOOLS_MCP_PORT'));
|
|
7
12
|
const maybeConfiguredHost = yield* Config.option(Config.String('FOLDKIT_DEVTOOLS_MCP_HOST'));
|
|
@@ -12,22 +17,55 @@ export const loadSettings = Effect.gen(function* () {
|
|
|
12
17
|
projectRoot: Option.getOrElse(maybeProjectRoot, () => process.cwd()),
|
|
13
18
|
};
|
|
14
19
|
}).pipe(Effect.orDie);
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return parsed.toString();
|
|
22
|
-
},
|
|
20
|
+
const urlHost = (host) => (isIPv6(host) ? `[${host}]` : host);
|
|
21
|
+
const relayUrl = (host, port) => `ws://${urlHost(host)}:${port}`;
|
|
22
|
+
const fixedTarget = (url) => ({
|
|
23
|
+
key: url,
|
|
24
|
+
url,
|
|
25
|
+
maybeProjectRoot: Option.none(),
|
|
23
26
|
});
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}))),
|
|
32
|
-
});
|
|
27
|
+
const parseUrl = Option.liftThrowable((url) => new URL(url));
|
|
28
|
+
const hasValidPort = (url) => {
|
|
29
|
+
const port = globalThis.Number(url.port);
|
|
30
|
+
return (url.port !== '' &&
|
|
31
|
+
globalThis.Number.isInteger(port) &&
|
|
32
|
+
port > 0 &&
|
|
33
|
+
port <= 65_535);
|
|
33
34
|
};
|
|
35
|
+
const isPublishedRelayUrl = (value) => Option.exists(parseUrl(value), url => {
|
|
36
|
+
if (url.protocol !== 'ws:' ||
|
|
37
|
+
url.username !== '' ||
|
|
38
|
+
url.password !== '' ||
|
|
39
|
+
url.hash !== '' ||
|
|
40
|
+
!hasValidPort(url)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const parameters = Array.fromIterable(url.searchParams);
|
|
44
|
+
return (url.hostname === AUTOMATIC_RELAY_HOST &&
|
|
45
|
+
url.pathname === AUTOMATIC_RELAY_PATH &&
|
|
46
|
+
Array.length(parameters) === 1 &&
|
|
47
|
+
Option.exists(Array.head(parameters), ([name, token]) => name === RELAY_TOKEN_PARAMETER && RELAY_TOKEN_PATTERN.test(token)));
|
|
48
|
+
});
|
|
49
|
+
export const configuredRelayUrl = (settings) => {
|
|
50
|
+
const { maybeConfiguredHost, maybeConfiguredPort } = settings;
|
|
51
|
+
if (Option.isSome(maybeConfiguredHost) ||
|
|
52
|
+
Option.isSome(maybeConfiguredPort)) {
|
|
53
|
+
const host = Option.getOrElse(maybeConfiguredHost, () => DEFAULT_HOST);
|
|
54
|
+
const port = Option.getOrElse(maybeConfiguredPort, () => LEGACY_DEFAULT_PORT);
|
|
55
|
+
return Option.some(relayUrl(host, port));
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
return Option.none();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
export const resolveRelayTargets = (settings, registryReader) => Option.match(configuredRelayUrl(settings), {
|
|
62
|
+
onSome: url => Effect.succeed([fixedTarget(url)]),
|
|
63
|
+
onNone: () => Effect.map(discoverRelays(settings.projectRoot, registryReader), records => {
|
|
64
|
+
const trustedRecords = Array.filter(records, record => isPublishedRelayUrl(record.url));
|
|
65
|
+
return Array.map(trustedRecords, (record) => ({
|
|
66
|
+
key: record.id,
|
|
67
|
+
url: record.url,
|
|
68
|
+
maybeProjectRoot: Option.some(record.root),
|
|
69
|
+
}));
|
|
70
|
+
}),
|
|
71
|
+
});
|