@foldkit/devtools-mcp 0.22.0-canary.b415a3e22be5 → 0.22.0-canary.eb11872b6977

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
@@ -36,21 +36,7 @@ pnpm add -D @foldkit/devtools-mcp
36
36
  yarn add -D @foldkit/devtools-mcp
37
37
  ```
38
38
 
39
- Then make two edits to your project.
40
-
41
- In `vite.config.ts`, pass `devToolsMcpPort` to the Foldkit plugin so it opens the relay:
42
-
43
- ```typescript
44
- import { defineConfig } from 'vite'
45
-
46
- import { foldkit } from '@foldkit/vite-plugin'
47
-
48
- export default defineConfig({
49
- plugins: [foldkit({ devToolsMcpPort: 9988 })],
50
- })
51
- ```
52
-
53
- In your `Runtime.makeApplication` call, pass your `Message` Schema. The Runtime decodes every dispatched payload against it, returning a clean error if the shape does not match before it reaches your update function:
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:
54
40
 
55
41
  ```typescript
56
42
  Runtime.makeApplication({
@@ -108,17 +94,25 @@ High-frequency flows (drag-paint, scroll, keystroke) can fill the history buffer
108
94
  Three components cooperate:
109
95
 
110
96
  - **Browser bridge** (in `foldkit`): runs alongside DevTools, subscribes to the DevTools store, and exchanges typed frames over Vite's HMR WebSocket.
111
- - **Vite plugin relay** (in `@foldkit/vite-plugin`): opens a separate WebSocket server on `devToolsMcpPort` and forwards traffic between browsers and MCP clients.
97
+ - **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.
112
98
  - **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.
113
99
 
114
100
  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.
115
101
 
116
102
  ## Configuration
117
103
 
118
- | Environment variable | Default | Description |
119
- | --------------------------- | ----------- | ---------------------------------------------------------------------------------------- |
120
- | `FOLDKIT_DEVTOOLS_MCP_HOST` | `localhost` | Hostname of the Vite plugin relay. |
121
- | `FOLDKIT_DEVTOOLS_MCP_PORT` | `9988` | Port the Vite plugin relay listens on. Must match `devToolsMcpPort` in your Vite config. |
104
+ 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.
105
+
106
+ | Environment variable | What it changes |
107
+ | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
108
+ | `FOLDKIT_PROJECT_ROOT` | Project to search. Defaults to the working directory. A relay for that directory or one inside it can match. |
109
+ | `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. |
110
+ | `FOLDKIT_DEVTOOLS_MCP_HOST` | Overrides the hostname of a discovered relay or configured port. |
111
+ | `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. |
112
+
113
+ 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. A configured `devToolsMcpPort` opens a separate socket on every interface without a token.
114
+
115
+ 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.
122
116
 
123
117
  ## Notes
124
118
 
@@ -1 +1 @@
1
- {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../src/install.ts"],"names":[],"mappings":"AAsEA;;;;;GAKG;AACH,eAAO,MAAM,OAAO,QAAO,IAkB1B,CAAA"}
1
+ {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../src/install.ts"],"names":[],"mappings":"AAoEA;;;;;GAKG;AACH,eAAO,MAAM,OAAO,QAAO,IAkB1B,CAAA"}
package/dist/install.js CHANGED
@@ -36,15 +36,13 @@ const printNextSteps = (alreadyRegistered) => {
36
36
  console.log('');
37
37
  console.log('Next steps:');
38
38
  console.log('');
39
- console.log(' 1. Add devToolsMcpPort to your Vite plugin call in vite.config.ts:');
40
- console.log('');
41
- console.log(' plugins: [foldkit({ devToolsMcpPort: 9988 })]');
42
- console.log('');
43
- console.log(' 2. Pass your Message Schema to Runtime.makeApplication (enables dispatch):');
39
+ console.log(' 1. Pass your Message Schema to Runtime.makeApplication (enables dispatch):');
44
40
  console.log('');
45
41
  console.log(' devTools: { Message }');
46
42
  console.log('');
47
- console.log(' 3. Restart your dev server, then restart your AI agent (Claude Code, Cursor, etc.).');
43
+ console.log(' 2. Start your dev server, then restart your AI agent (Claude Code, Cursor, etc.).');
44
+ console.log('');
45
+ console.log(' The server finds your dev server on its own. See https://foldkit.dev/ai/mcp');
48
46
  console.log('');
49
47
  console.log('Tools will appear under the foldkit-devtools server, e.g. foldkit_get_model, foldkit_dispatch_message.');
50
48
  };
@@ -0,0 +1,9 @@
1
+ import { Effect, Option } from 'effect';
2
+ export type Settings = Readonly<{
3
+ maybeConfiguredPort: Option.Option<string>;
4
+ maybeConfiguredHost: Option.Option<string>;
5
+ projectRoot: string;
6
+ }>;
7
+ 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>;
9
+ //# sourceMappingURL=relayLocation.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,33 @@
1
+ import { Config, Effect, Option } from 'effect';
2
+ import { discoverRelay } from './relayRegistry.js';
3
+ const LEGACY_DEFAULT_PORT = 9988;
4
+ const DEFAULT_HOST = 'localhost';
5
+ export const loadSettings = Effect.gen(function* () {
6
+ const maybeConfiguredPort = yield* Config.option(Config.String('FOLDKIT_DEVTOOLS_MCP_PORT'));
7
+ const maybeConfiguredHost = yield* Config.option(Config.String('FOLDKIT_DEVTOOLS_MCP_HOST'));
8
+ const maybeProjectRoot = yield* Config.option(Config.String('FOLDKIT_PROJECT_ROOT'));
9
+ return {
10
+ maybeConfiguredPort,
11
+ maybeConfiguredHost,
12
+ projectRoot: Option.getOrElse(maybeProjectRoot, () => process.cwd()),
13
+ };
14
+ }).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
+ },
23
+ });
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
+ });
33
+ };
@@ -0,0 +1,5 @@
1
+ import { Effect, FileSystem, Option, Path } from 'effect';
2
+ import { RelayRecord } from 'foldkit/devtools-protocol';
3
+ export type RelayRegistryServices = FileSystem.FileSystem | Path.Path;
4
+ export declare const discoverRelay: (projectRoot: string) => Effect.Effect<Option.Option<RelayRecord>, never, RelayRegistryServices>;
5
+ //# sourceMappingURL=relayRegistry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relayRegistry.d.ts","sourceRoot":"","sources":["../src/relayRegistry.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,MAAM,EACN,UAAU,EACV,MAAM,EAEN,IAAI,EAIL,MAAM,QAAQ,CAAA;AACf,OAAO,EAGL,WAAW,EACZ,MAAM,2BAA2B,CAAA;AAOlC,MAAM,MAAM,qBAAqB,GAAG,UAAU,CAAC,UAAU,GAAG,IAAI,CAAC,IAAI,CAAA;AA4FrE,eAAO,MAAM,aAAa,gBACX,MAAM,KAClB,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,KAAK,EAAE,qBAAqB,CAoCrE,CAAA"}
@@ -0,0 +1,70 @@
1
+ import { Array, Config, Effect, FileSystem, Option, Order, Path, Schema, String, pipe, } from 'effect';
2
+ import { RELAY_REGISTRY_DIRECTORY_NAME, RELAY_REGISTRY_DIRECTORY_VARIABLE, RelayRecord, } from 'foldkit/devtools-protocol';
3
+ import { tmpdir } from 'node:os';
4
+ const RUNTIME_DIRECTORY_VARIABLE = 'XDG_RUNTIME_DIR';
5
+ const RECORD_FILE_EXTENSION = '.json';
6
+ const RETIRING_RECORD_SUFFIX = '.retiring';
7
+ const decodeRelayRecord = Schema.decodeUnknownOption(Schema.fromJsonString(RelayRecord));
8
+ const relayRegistryDirectory = Effect.gen(function* () {
9
+ const path = yield* Path.Path;
10
+ const maybeConfigured = yield* Config.option(Config.String(RELAY_REGISTRY_DIRECTORY_VARIABLE));
11
+ const maybeRuntimeDirectory = yield* Config.option(Config.String(RUNTIME_DIRECTORY_VARIABLE));
12
+ return Option.getOrElse(maybeConfigured, () => path.join(Option.getOrElse(maybeRuntimeDirectory, tmpdir), RELAY_REGISTRY_DIRECTORY_NAME));
13
+ }).pipe(Effect.orDie);
14
+ const isProcessAlive = (pid) => {
15
+ try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ }
19
+ catch (error) {
20
+ return error instanceof Error && 'code' in error && error.code === 'EPERM';
21
+ }
22
+ };
23
+ const readRecordFile = (filePath) => Effect.gen(function* () {
24
+ const fileSystem = yield* FileSystem.FileSystem;
25
+ const raw = yield* fileSystem.readFileString(filePath);
26
+ return decodeRelayRecord(raw);
27
+ }).pipe(Effect.orElseSucceed(() => Option.none()));
28
+ const retireStaleRecord = (filePath, record) => Effect.gen(function* () {
29
+ const fileSystem = yield* FileSystem.FileSystem;
30
+ const retiringPath = `${filePath}.${encodeURIComponent(record.id)}${RETIRING_RECORD_SUFFIX}`;
31
+ const wasRecordMoved = yield* fileSystem
32
+ .rename(filePath, retiringPath)
33
+ .pipe(Effect.as(true), Effect.orElseSucceed(() => false));
34
+ if (!wasRecordMoved) {
35
+ return;
36
+ }
37
+ const maybeRetiringRecord = yield* readRecordFile(retiringPath);
38
+ const isOriginalRecord = Option.exists(maybeRetiringRecord, retiringRecord => retiringRecord.id === record.id);
39
+ if (!isOriginalRecord) {
40
+ yield* fileSystem.link(retiringPath, filePath).pipe(Effect.ignore);
41
+ }
42
+ yield* fileSystem.remove(retiringPath, { force: true }).pipe(Effect.ignore);
43
+ });
44
+ const readLiveRecordFile = (filePath) => Effect.gen(function* () {
45
+ const maybeRecord = yield* readRecordFile(filePath);
46
+ if (Option.isSome(maybeRecord) && !isProcessAlive(maybeRecord.value.pid)) {
47
+ yield* retireStaleRecord(filePath, maybeRecord.value);
48
+ return Option.none();
49
+ }
50
+ return maybeRecord;
51
+ });
52
+ const newestFirst = Order.mapInput(Order.flip(Order.Number), record => record.startedAt);
53
+ export const discoverRelay = (projectRoot) => Effect.gen(function* () {
54
+ const fileSystem = yield* FileSystem.FileSystem;
55
+ const path = yield* Path.Path;
56
+ const directory = yield* relayRegistryDirectory;
57
+ const isWithinProjectRoot = (candidatePath) => {
58
+ const relativePath = path.relative(projectRoot, candidatePath);
59
+ return (relativePath === '' ||
60
+ (relativePath !== '..' &&
61
+ !relativePath.startsWith(`..${path.sep}`) &&
62
+ !path.isAbsolute(relativePath)));
63
+ };
64
+ const fileNames = yield* fileSystem
65
+ .readDirectory(directory)
66
+ .pipe(Effect.orElseSucceed(() => []));
67
+ const recordFileNames = Array.filter(fileNames, String.endsWith(RECORD_FILE_EXTENSION));
68
+ const maybeRecords = yield* Effect.forEach(recordFileNames, recordFileName => readLiveRecordFile(path.join(directory, recordFileName)));
69
+ return pipe(maybeRecords, Array.getSomes, Array.filter(record => isWithinProjectRoot(record.root)), Array.sort(newestFirst), Array.head);
70
+ });