@foldkit/devtools-mcp 0.24.1-canary.11a69068d31a → 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 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 Submodel chains
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
- Pass your `Message` Schema to `Runtime.makeApplication` to let the agent dispatch Messages. The Runtime rejects a payload that does not match the Schema before it reaches update:
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
- Runtime.makeApplication({
42
+ Program.make({
43
+ block: ApplicationBlock,
44
+ container,
43
45
  devTools: {
44
46
  // Rest of your DevTools config
45
- Message,
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. Omitting `DevToolsConfig.Message` disables dispatch only; 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.
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
- Each tool accepts an optional `runtime_id`. When omitted, the most recently connected Runtime is used.
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 Submodel chain. Filter server-side with `changed_paths_match`, read the latest entries with `from_end`, and paginate forward with `since_index`. |
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 the Runtime hasn't configured `DevToolsConfig.Message`. |
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`): serves a WebSocket endpoint on the dev server, publishes its address for discovery, and forwards traffic between browsers and MCP clients.
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
- The MCP server looks for a running dev server in its project directory. If several relays match, it uses the most recently started one. It discovers the relay again when the dev server restarts.
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. A relay for that directory or one inside it can match. |
117
- | `FOLDKIT_DEVTOOLS_MCP_PORT` | Skips discovery and connects to this port. Set it to the `devToolsMcpPort` in your Vite config. Without this setting, the server tries `9988` if discovery finds nothing. |
118
- | `FOLDKIT_DEVTOOLS_MCP_HOST` | Overrides the hostname of a discovered relay or configured port. |
119
- | `FOLDKIT_DEVTOOLS_RELAY_DIRECTORY` | Registry location. Defaults to a directory under `XDG_RUNTIME_DIR` when set, or under the OS temporary directory. Set it in both processes if they use different sandboxes. |
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
- A relay discovered through the registry requires the token in its published address. The plugin will not publish that token into a directory owned by another user or readable by other users. This token keeps unapproved clients from connecting; it does not hide runtime data from the agent that was given the MCP server. A configured `devToolsMcpPort` opens a separate socket on every interface without a token. Do not use a fixed port on a shared or untrusted network.
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 ownership cannot be verified, so automatic discovery is unavailable. Use `devToolsMcpPort` in the Vite config and set `FOLDKIT_DEVTOOLS_MCP_PORT` to the same port.
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
- - Without `Message` in your `DevToolsConfig`, dispatch is rejected. The other (read-only) tools still work.
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. Pass your Message Schema to Runtime.makeApplication (enables dispatch):');
39
+ console.log(' 1. Enable Message dispatch in Program.make or Program.makeElement:');
40
40
  console.log('');
41
- console.log(' devTools: { Message }');
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
+ };
@@ -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 resolveRelayUrl: (settings: Settings) => Effect.Effect<string, never, never> | Effect.Effect<string, never, import("./relayRegistry.js").RelayRegistryServices>;
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,EAAU,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAO/C,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,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAe5B,CAAA;AAkBrB,eAAO,MAAM,eAAe,aAAc,QAAQ,2HAmBjD,CAAA"}
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"}
@@ -1,7 +1,12 @@
1
- import { Config, Effect, Option } from 'effect';
2
- import { discoverRelay } from './relayRegistry.js';
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 relayUrl = (host, port) => `ws://${host}:${port}`;
16
- const withConfiguredHost = (maybeConfiguredHost, url) => Option.match(maybeConfiguredHost, {
17
- onNone: () => url,
18
- onSome: host => {
19
- const parsed = new URL(url);
20
- parsed.hostname = host;
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
- export const resolveRelayUrl = (settings) => {
25
- const configuredHost = Option.getOrElse(settings.maybeConfiguredHost, () => DEFAULT_HOST);
26
- return Option.match(settings.maybeConfiguredPort, {
27
- onSome: port => Effect.succeed(relayUrl(configuredHost, port)),
28
- onNone: () => discoverRelay(settings.projectRoot).pipe(Effect.map(maybeRecord => Option.match(maybeRecord, {
29
- onSome: record => withConfiguredHost(settings.maybeConfiguredHost, record.url),
30
- onNone: () => relayUrl(configuredHost, LEGACY_DEFAULT_PORT),
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
+ });