@foldkit/devtools-mcp 0.1.0

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 ADDED
@@ -0,0 +1,104 @@
1
+ # @foldkit/devtools-mcp
2
+
3
+ A Model Context Protocol server that exposes a running [Foldkit](https://foldkit.dev) app to AI agents (Claude Code, Codex, Cursor, Windsurf, anything that speaks MCP).
4
+
5
+ With it attached, agents can:
6
+
7
+ - Read the current Model
8
+ - List and inspect the Message history
9
+ - Replay to any past state and resume
10
+ - Dispatch Messages into the runtime, validated against your `Message` Schema
11
+
12
+ ## Quick Start
13
+
14
+ Projects scaffolded with [`create-foldkit-app`](https://foldkit.dev/getting-started) ship with the MCP server pre-wired. Open the project in your AI agent and the tools appear under the `foldkit-devtools` prefix.
15
+
16
+ For existing projects, run the init command in your project root:
17
+
18
+ ```bash
19
+ npx @foldkit/devtools-mcp init
20
+ ```
21
+
22
+ This writes a `.mcp.json` (or merges into an existing one) so any MCP-aware agent picks up the server.
23
+
24
+ For faster startup, install the MCP server as a devDependency. Otherwise `npx` fetches it on each AI agent restart:
25
+
26
+ ```bash
27
+ npm install -D @foldkit/devtools-mcp
28
+ # or
29
+ pnpm add -D @foldkit/devtools-mcp
30
+ # or
31
+ yarn add -D @foldkit/devtools-mcp
32
+ ```
33
+
34
+ Then make two edits to your project.
35
+
36
+ In `vite.config.ts`, pass `devToolsMcpPort` to the Foldkit plugin so it opens the relay:
37
+
38
+ ```typescript
39
+ import { foldkit } from '@foldkit/vite-plugin'
40
+ import { defineConfig } from 'vite'
41
+
42
+ export default defineConfig({
43
+ plugins: [foldkit({ devToolsMcpPort: 9988 })],
44
+ })
45
+ ```
46
+
47
+ In your `Runtime.makeProgram` call, pass your `Message` Schema. This is what the agent sees when it asks "what Messages can I dispatch?", and it gates dispatch by validating every payload before it reaches your update function:
48
+
49
+ ```typescript
50
+ Runtime.makeProgram({
51
+ devTools: {
52
+ // Rest of your DevTools config
53
+ Message,
54
+ },
55
+ })
56
+ ```
57
+
58
+ Restart your dev server, then restart your AI agent. The MCP server will appear with the eight `foldkit_*` tools attached.
59
+
60
+ ## Tools
61
+
62
+ Each tool accepts an optional `runtime_id`. When omitted, the most recently connected runtime is used.
63
+
64
+ | Tool | Description |
65
+ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
66
+ | `foldkit_list_runtimes` | Returns metadata for every connected browser tab, including each runtime's Message Schema as JSON Schema. Agents call this first to discover what they can dispatch. |
67
+ | `foldkit_get_model` | Snapshots the current Model. |
68
+ | `foldkit_list_messages` | Lists recent Message history entries with pagination. Each entry carries the Message body, command names triggered, timestamp, and a path-level diff. |
69
+ | `foldkit_get_message` | Reads one entry at a given index, including the Model before and after the Message was applied. |
70
+ | `foldkit_list_keyframes` | Returns the indices Foldkit can replay back to. Index `-1` is the initial Model. |
71
+ | `foldkit_replay_to_keyframe` | Time-travels the runtime to a previous state. The runtime is paused at that snapshot until `foldkit_resume` is called. |
72
+ | `foldkit_resume` | Resumes normal execution after a replay. |
73
+ | `foldkit_dispatch_message` | Enqueues a Message into the runtime as if your application produced it. The bridge validates the payload against your Schema before it reaches the update loop. |
74
+
75
+ ## Architecture
76
+
77
+ Three components cooperate:
78
+
79
+ - **Browser bridge** (in `foldkit`): runs alongside DevTools, subscribes to the DevTools store, and exchanges typed frames over Vite's HMR WebSocket.
80
+ - **Vite plugin relay** (in `@foldkit/vite-plugin`): opens a separate WebSocket server on `devToolsMcpPort` and forwards traffic between browsers and MCP clients.
81
+ - **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.
82
+
83
+ 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.
84
+
85
+ ## Configuration
86
+
87
+ | Environment variable | Default | Description |
88
+ | --------------------------- | ----------- | ---------------------------------------------------------------------------------------- |
89
+ | `FOLDKIT_DEVTOOLS_MCP_HOST` | `localhost` | Hostname of the Vite plugin relay. |
90
+ | `FOLDKIT_DEVTOOLS_MCP_PORT` | `9988` | Port the Vite plugin relay listens on. Must match `devToolsMcpPort` in your Vite config. |
91
+
92
+ ## Notes
93
+
94
+ - 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.
95
+ - Without `Message` in your `DevToolsConfig`, dispatch is rejected. The other (read-only) tools still work.
96
+ - The relay only runs at dev time. Production builds never include the relay or the bridge, regardless of any `show` setting.
97
+
98
+ ## Documentation
99
+
100
+ See [foldkit.dev/ai/mcp](https://foldkit.dev/ai/mcp) for the full guide.
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Initialize the Foldkit DevTools MCP server in the current working directory.
3
+ * Writes (or merges into) `.mcp.json` so any AI agent that respects the file
4
+ * picks up the server. Idempotent: re-running overwrites only the
5
+ * `foldkit-devtools` entry, leaving any other configured servers alone.
6
+ */
7
+ export declare const runInit: () => void;
8
+ //# sourceMappingURL=install.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../src/install.ts"],"names":[],"mappings":"AAsEA;;;;;GAKG;AACH,eAAO,MAAM,OAAO,QAAO,IAkB1B,CAAA"}
@@ -0,0 +1,72 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ const MCP_SERVER_NAME = 'foldkit-devtools';
4
+ const DEFAULT_MCP_FILE_NAME = '.mcp.json';
5
+ const SERVER_ENTRY = {
6
+ command: 'npx',
7
+ args: ['-y', '@foldkit/devtools-mcp'],
8
+ };
9
+ const loadExistingConfig = (mcpPath) => {
10
+ if (!existsSync(mcpPath)) {
11
+ return {};
12
+ }
13
+ try {
14
+ const raw = readFileSync(mcpPath, 'utf-8');
15
+ const parsed = JSON.parse(raw);
16
+ if (typeof parsed !== 'object' || parsed === null) {
17
+ throw new Error('config root is not an object');
18
+ }
19
+ return parsed;
20
+ }
21
+ catch (error) {
22
+ const message = error instanceof Error ? error.message : String(error);
23
+ console.error(`[foldkit-devtools-mcp] failed to parse existing ${mcpPath}: ${message}`);
24
+ console.error('[foldkit-devtools-mcp] aborting init to avoid clobbering your config. Fix the JSON and re-run.');
25
+ process.exit(1);
26
+ }
27
+ };
28
+ const printNextSteps = (alreadyRegistered) => {
29
+ console.log('');
30
+ if (alreadyRegistered) {
31
+ console.log('Updated existing foldkit-devtools entry.');
32
+ }
33
+ else {
34
+ console.log('Added foldkit-devtools to .mcp.json.');
35
+ }
36
+ console.log('');
37
+ console.log('Next steps:');
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.makeProgram (enables dispatch):');
44
+ console.log('');
45
+ console.log(' devTools: { Message }');
46
+ console.log('');
47
+ console.log(' 3. Restart your dev server, then restart your AI agent (Claude Code, Cursor, etc.).');
48
+ console.log('');
49
+ console.log('Tools will appear under the foldkit-devtools server, e.g. foldkit_get_model, foldkit_dispatch_message.');
50
+ };
51
+ /**
52
+ * Initialize the Foldkit DevTools MCP server in the current working directory.
53
+ * Writes (or merges into) `.mcp.json` so any AI agent that respects the file
54
+ * picks up the server. Idempotent: re-running overwrites only the
55
+ * `foldkit-devtools` entry, leaving any other configured servers alone.
56
+ */
57
+ export const runInit = () => {
58
+ const cwd = process.cwd();
59
+ const mcpPath = join(cwd, DEFAULT_MCP_FILE_NAME);
60
+ const existing = loadExistingConfig(mcpPath);
61
+ const existingServers = (existing.mcpServers ?? {});
62
+ const alreadyRegistered = MCP_SERVER_NAME in existingServers;
63
+ const next = {
64
+ ...existing,
65
+ mcpServers: {
66
+ ...existingServers,
67
+ [MCP_SERVER_NAME]: SERVER_ENTRY,
68
+ },
69
+ };
70
+ writeFileSync(mcpPath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8');
71
+ printNextSteps(alreadyRegistered);
72
+ };
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":""}
package/dist/server.js ADDED
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
5
+ import { Effect, HashMap, Option, Runtime } from 'effect';
6
+ import { runInit } from './install.js';
7
+ import { buildTools } from './tools.js';
8
+ import { connectWebSocketClient } from './webSocketClient.js';
9
+ const DEFAULT_PORT = 9988;
10
+ const DEFAULT_HOST = 'localhost';
11
+ const port = Number(process.env['FOLDKIT_DEVTOOLS_MCP_PORT'] ?? DEFAULT_PORT);
12
+ const host = process.env['FOLDKIT_DEVTOOLS_MCP_HOST'] ?? DEFAULT_HOST;
13
+ const main = Effect.gen(function* () {
14
+ const wsClient = yield* connectWebSocketClient(`ws://${host}:${port}`);
15
+ const tools = buildTools(wsClient);
16
+ const toolsByName = HashMap.fromIterable(tools.map(tool => [tool.name, tool]));
17
+ const runtime = yield* Effect.runtime();
18
+ const server = new Server({ name: '@foldkit/devtools-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
19
+ server.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({
20
+ tools: tools.map(({ name, description, inputSchema }) => ({
21
+ name,
22
+ description,
23
+ inputSchema,
24
+ })),
25
+ }));
26
+ server.setRequestHandler(CallToolRequestSchema, request => Option.match(HashMap.get(toolsByName, request.params.name), {
27
+ onNone: () => Promise.resolve({
28
+ content: [
29
+ {
30
+ type: 'text',
31
+ text: `Error: unknown tool ${request.params.name}`,
32
+ },
33
+ ],
34
+ isError: true,
35
+ }),
36
+ onSome: tool => Runtime.runPromise(runtime)(tool.handle(request.params.arguments ?? {})),
37
+ }));
38
+ const transport = new StdioServerTransport();
39
+ yield* Effect.tryPromise({
40
+ try: () => server.connect(transport),
41
+ catch: error => error,
42
+ });
43
+ yield* Effect.sync(() => console.error('[foldkit-devtools-mcp] MCP server ready on stdio'));
44
+ });
45
+ const subcommand = process.argv[2];
46
+ if (subcommand === 'init') {
47
+ runInit();
48
+ }
49
+ else {
50
+ Effect.runPromise(main).catch(error => {
51
+ console.error('[foldkit-devtools-mcp] fatal error', error);
52
+ process.exit(1);
53
+ });
54
+ }
@@ -0,0 +1,24 @@
1
+ import { Effect } from 'effect';
2
+ import type { WebSocketClient } from './webSocketClient.js';
3
+ type ToolResult = Readonly<{
4
+ content: ReadonlyArray<Readonly<{
5
+ type: 'text';
6
+ text: string;
7
+ }>>;
8
+ isError?: boolean;
9
+ }>;
10
+ /** A tool registration the MCP server hands to its low-level `Server.setRequestHandler` for `tools/list` and `tools/call`. */
11
+ export type ToolDefinition = Readonly<{
12
+ name: string;
13
+ description: string;
14
+ inputSchema: object;
15
+ handle: (rawInput: unknown) => Effect.Effect<ToolResult>;
16
+ }>;
17
+ /**
18
+ * Build the read-only Foldkit DevTools tool definitions. Each tool decodes its
19
+ * input via Effect Schema, dispatches a typed `Request` through the WebSocket
20
+ * relay, and formats the typed `Response` as MCP tool content.
21
+ */
22
+ export declare const buildTools: (wsClient: WebSocketClient) => ReadonlyArray<ToolDefinition>;
23
+ export {};
24
+ //# sourceMappingURL=tools.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,MAAM,EAA0C,MAAM,QAAQ,CAAA;AAc9E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAyF3D,KAAK,UAAU,GAAG,QAAQ,CAAC;IACzB,OAAO,EAAE,aAAa,CAAC,QAAQ,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAA;IAChE,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB,CAAC,CAAA;AAEF,8HAA8H;AAC9H,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC;IACpC,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,CAAC,QAAQ,EAAE,OAAO,KAAK,MAAM,CAAC,MAAM,CAAC,UAAU,CAAC,CAAA;CACzD,CAAC,CAAA;AA4GF;;;;GAIG;AACH,eAAO,MAAM,UAAU,GACrB,UAAU,eAAe,KACxB,aAAa,CAAC,cAAc,CA0F9B,CAAA"}
package/dist/tools.js ADDED
@@ -0,0 +1,162 @@
1
+ import { Array, Effect, JSONSchema, Match, Option, Schema as S } from 'effect';
2
+ import { RequestDispatchMessage, RequestGetMessage, RequestGetModel, RequestListKeyframes, RequestListMessages, RequestListRuntimes, RequestReplayToKeyframe, RequestResume, } from 'foldkit/devtools-protocol';
3
+ const RUNTIME_ID_DESCRIPTION = 'Optional connection id of a specific Foldkit runtime. Defaults to the most recently connected runtime.';
4
+ const DEFAULT_LIST_MESSAGES_LIMIT = 50;
5
+ const RuntimeIdField = S.optional(S.String.annotations({ description: RUNTIME_ID_DESCRIPTION }));
6
+ const ListLimit = S.Number.pipe(S.int(), S.between(1, 500), S.annotations({
7
+ description: `Maximum number of entries to return. Defaults to ${DEFAULT_LIST_MESSAGES_LIMIT}; max 500.`,
8
+ }));
9
+ const SinceIndex = S.Number.pipe(S.int(), S.annotations({
10
+ description: 'Absolute history index to start from. Use the maybeNextIndex returned by a prior call to paginate.',
11
+ }));
12
+ const MessageIndex = S.Number.pipe(S.int(), S.annotations({
13
+ description: 'Absolute history index of the entry to read.',
14
+ }));
15
+ const KeyframeIndex = S.Number.pipe(S.int(), S.annotations({
16
+ description: 'Index to replay to. Use -1 to jump to the initial Model (before any messages). Use a non-negative index to jump to the Model state right after that history index. Call foldkit_list_keyframes for the canonical replay points.',
17
+ }));
18
+ const GetModelInput = S.Struct({
19
+ runtime_id: RuntimeIdField,
20
+ });
21
+ const ListMessagesInput = S.Struct({
22
+ runtime_id: RuntimeIdField,
23
+ limit: S.optional(ListLimit),
24
+ since_index: S.optional(SinceIndex),
25
+ });
26
+ const GetMessageInput = S.Struct({
27
+ runtime_id: RuntimeIdField,
28
+ index: MessageIndex,
29
+ });
30
+ const ListKeyframesInput = S.Struct({
31
+ runtime_id: RuntimeIdField,
32
+ });
33
+ const ReplayToKeyframeInput = S.Struct({
34
+ runtime_id: RuntimeIdField,
35
+ keyframe_index: KeyframeIndex,
36
+ });
37
+ const ResumeInput = S.Struct({
38
+ runtime_id: RuntimeIdField,
39
+ });
40
+ const DispatchMessageInput = S.Struct({
41
+ runtime_id: RuntimeIdField,
42
+ message: S.Unknown.annotations({
43
+ description: "A Foldkit Message object to dispatch into the runtime. Must match the runtime's Message Schema (call foldkit_list_runtimes and inspect each runtime's maybeMessageSchema field for the exact shape). At minimum it has a `_tag` field naming the variant.",
44
+ }),
45
+ });
46
+ /**
47
+ * JSON Schema for tools that take no input. Inlined as a literal because
48
+ * `JSONSchema.make(S.Struct({}))` produces a shape MCP's AJV validator
49
+ * rejects (no top-level `type: "object"` for an empty struct).
50
+ */
51
+ const NO_INPUT_SCHEMA = {
52
+ type: 'object',
53
+ properties: {},
54
+ additionalProperties: false,
55
+ };
56
+ const formatResult = (value) => ({
57
+ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
58
+ });
59
+ const formatError = (reason) => ({
60
+ content: [{ type: 'text', text: `Error: ${reason}` }],
61
+ isError: true,
62
+ });
63
+ /**
64
+ * Decode a tool's raw input against its Effect Schema. Failure surfaces as an
65
+ * `Error` for the outer handler's `catchAll` to convert into a `ToolResult`.
66
+ */
67
+ const decodeInput = (schema, rawInput) => S.decodeUnknown(schema)(rawInput).pipe(Effect.mapError(error => new Error(`Invalid input: ${error.message}`)));
68
+ /**
69
+ * Resolve a runtime id, defaulting to the most recently connected runtime when
70
+ * the caller did not specify one. Failures (no runtimes connected, relay
71
+ * error) surface as `Error` for the outer handler's `catchAll` to convert.
72
+ */
73
+ const resolveRuntimeId = (wsClient, explicit) => {
74
+ if (explicit !== undefined) {
75
+ return Effect.succeed(explicit);
76
+ }
77
+ return Effect.gen(function* () {
78
+ const response = yield* wsClient.sendRequest(RequestListRuntimes(), Option.none());
79
+ return yield* Match.value(response).pipe(Match.tag('ResponseRuntimes', ({ runtimes }) => Array.last(runtimes).pipe(Option.match({
80
+ onNone: () => Effect.fail(new Error('No connected Foldkit runtimes. Open a Foldkit dev page and try again.')),
81
+ onSome: runtime => Effect.succeed(runtime.connectionId),
82
+ }))), Match.tag('ResponseError', ({ reason }) => Effect.fail(new Error(reason))), Match.orElse(({ _tag }) => Effect.fail(new Error(`Unexpected response from RequestListRuntimes: ${_tag}`))));
83
+ });
84
+ };
85
+ const responseToToolResult = (response) => response._tag === 'ResponseError'
86
+ ? formatError(response.reason)
87
+ : formatResult(response);
88
+ const callRuntimeRequest = (wsClient, explicitRuntimeId, buildRequest) => Effect.gen(function* () {
89
+ const runtimeId = yield* resolveRuntimeId(wsClient, explicitRuntimeId);
90
+ const response = yield* wsClient.sendRequest(buildRequest(), Option.some(runtimeId));
91
+ return responseToToolResult(response);
92
+ }).pipe(Effect.catchAll(error => Effect.succeed(formatError(error.message))));
93
+ /**
94
+ * Build a tool handler that decodes its input, resolves the target runtime,
95
+ * issues a typed `Request`, and formats the response. Used for every tool
96
+ * except `foldkit_list_runtimes`, which does not target a specific runtime.
97
+ */
98
+ const runRuntimeTool = (inputSchema, buildRequest, wsClient) => (rawInput) => Effect.gen(function* () {
99
+ const input = yield* decodeInput(inputSchema, rawInput);
100
+ return yield* callRuntimeRequest(wsClient, input.runtime_id, () => buildRequest(input));
101
+ }).pipe(Effect.catchAll(error => Effect.succeed(formatError(error.message))));
102
+ /**
103
+ * Build the read-only Foldkit DevTools tool definitions. Each tool decodes its
104
+ * input via Effect Schema, dispatches a typed `Request` through the WebSocket
105
+ * relay, and formats the typed `Response` as MCP tool content.
106
+ */
107
+ export const buildTools = (wsClient) => [
108
+ {
109
+ name: 'foldkit_get_model',
110
+ description: 'Snapshot the current Model from a connected Foldkit runtime.',
111
+ inputSchema: JSONSchema.make(GetModelInput),
112
+ handle: runRuntimeTool(GetModelInput, () => RequestGetModel(), wsClient),
113
+ },
114
+ {
115
+ name: 'foldkit_list_messages',
116
+ description: 'List recent Message history entries from a Foldkit runtime, with optional pagination via since_index.',
117
+ inputSchema: JSONSchema.make(ListMessagesInput),
118
+ handle: runRuntimeTool(ListMessagesInput, ({ limit, since_index }) => RequestListMessages({
119
+ limit: limit ?? DEFAULT_LIST_MESSAGES_LIMIT,
120
+ maybeSinceIndex: Option.fromNullable(since_index),
121
+ }), wsClient),
122
+ },
123
+ {
124
+ name: 'foldkit_get_message',
125
+ description: 'Read a single Message history entry by absolute index, including before/after Model snapshots.',
126
+ inputSchema: JSONSchema.make(GetMessageInput),
127
+ handle: runRuntimeTool(GetMessageInput, ({ index }) => RequestGetMessage({ index }), wsClient),
128
+ },
129
+ {
130
+ name: 'foldkit_list_keyframes',
131
+ description: 'List the available keyframes (replayable Model snapshots) from a Foldkit runtime.',
132
+ inputSchema: JSONSchema.make(ListKeyframesInput),
133
+ handle: runRuntimeTool(ListKeyframesInput, () => RequestListKeyframes(), wsClient),
134
+ },
135
+ {
136
+ name: 'foldkit_replay_to_keyframe',
137
+ description: 'Time-travel a Foldkit runtime back to a previous Model snapshot. Pass `keyframe_index: -1` for the initial Model, or a non-negative index for the state right after that history entry. The runtime is paused at the snapshot until foldkit_resume is called.',
138
+ inputSchema: JSONSchema.make(ReplayToKeyframeInput),
139
+ handle: runRuntimeTool(ReplayToKeyframeInput, ({ keyframe_index }) => RequestReplayToKeyframe({ keyframeIndex: keyframe_index }), wsClient),
140
+ },
141
+ {
142
+ name: 'foldkit_resume',
143
+ description: 'Resume normal execution of a Foldkit runtime that was paused by foldkit_replay_to_keyframe.',
144
+ inputSchema: JSONSchema.make(ResumeInput),
145
+ handle: runRuntimeTool(ResumeInput, () => RequestResume(), wsClient),
146
+ },
147
+ {
148
+ name: 'foldkit_dispatch_message',
149
+ description: "Dispatch a Message into a Foldkit runtime's message queue, as if the application itself produced it. Requires the runtime to have configured DevToolsConfig.Message; without it, dispatch is rejected. Use foldkit_list_runtimes to discover the runtime's maybeMessageSchema and construct a Message object.",
150
+ inputSchema: JSONSchema.make(DispatchMessageInput),
151
+ handle: runRuntimeTool(DispatchMessageInput, ({ message }) => RequestDispatchMessage({ message }), wsClient),
152
+ },
153
+ {
154
+ name: 'foldkit_list_runtimes',
155
+ description: 'List Foldkit runtimes (browser tabs) currently connected to the dev server.',
156
+ inputSchema: NO_INPUT_SCHEMA,
157
+ handle: () => Effect.gen(function* () {
158
+ const response = yield* wsClient.sendRequest(RequestListRuntimes(), Option.none());
159
+ return responseToToolResult(response);
160
+ }).pipe(Effect.catchAll(error => Effect.succeed(formatError(error.message)))),
161
+ },
162
+ ];
@@ -0,0 +1,28 @@
1
+ import { type Cause, Effect, Option } from 'effect';
2
+ import { type Request, type Response } from 'foldkit/devtools-protocol';
3
+ /**
4
+ * A connected WebSocket client to the Foldkit Vite plugin's DevTools relay.
5
+ *
6
+ * Sends typed `Request`s and resolves with the matching `Response`. The
7
+ * `sendRequest` Effect fails with `TimeoutException` when no response arrives
8
+ * within the request timeout window, or with `Error` when the socket is not
9
+ * open or the send throws. Either way, no pending entry leaks.
10
+ *
11
+ * The client transparently reconnects with exponential backoff when the
12
+ * underlying socket closes (e.g. when the user restarts the Vite dev server).
13
+ * Pending response correlators live in a client-owned Ref, not on the socket,
14
+ * so they survive reconnects: in-flight requests time out and future requests
15
+ * succeed once the new socket is open.
16
+ */
17
+ export type WebSocketClient = Readonly<{
18
+ sendRequest: (request: typeof Request.Type, maybeRuntimeId: Option.Option<string>) => Effect.Effect<typeof Response.Type, Cause.TimeoutException | Error>;
19
+ close: Effect.Effect<void>;
20
+ }>;
21
+ /**
22
+ * Open a WebSocket connection to the Foldkit Vite plugin's DevTools relay.
23
+ * The Effect succeeds once the initial connection is open and ready for
24
+ * traffic. It fails with the underlying `Error` if the *first* connection
25
+ * cannot be opened. Subsequent disconnects reconnect transparently.
26
+ */
27
+ export declare const connectWebSocketClient: (url: string) => Effect.Effect<WebSocketClient, Error>;
28
+ //# sourceMappingURL=webSocketClient.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webSocketClient.d.ts","sourceRoot":"","sources":["../src/webSocketClient.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,KAAK,EAGV,MAAM,EAIN,MAAM,EAMP,MAAM,QAAQ,CAAA;AACf,OAAO,EACL,KAAK,OAAO,EAEZ,KAAK,QAAQ,EAEd,MAAM,2BAA2B,CAAA;AAYlC;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC;IACrC,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,gBAAgB,GAAG,KAAK,CAAC,CAAA;IACxE,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;CAC3B,CAAC,CAAA;AAwDF;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,GACjC,KAAK,MAAM,KACV,MAAM,CAAC,MAAM,CAAC,eAAe,EAAE,KAAK,CAuHnC,CAAA"}
@@ -0,0 +1,134 @@
1
+ import { Deferred, Duration, Effect, Either, Fiber, HashMap, Option, Ref, Runtime, Schema as S, Schedule, pipe, } from 'effect';
2
+ import { ResponseFrame, } from 'foldkit/devtools-protocol';
3
+ import { WebSocket } from 'ws';
4
+ const REQUEST_TIMEOUT = Duration.seconds(10);
5
+ const INITIAL_RECONNECT_DELAY = Duration.millis(500);
6
+ const MAX_RECONNECT_DELAY = Duration.seconds(30);
7
+ const generateRequestId = () => `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
8
+ /** Exponential backoff capped at MAX_RECONNECT_DELAY, retried indefinitely. */
9
+ const reconnectSchedule = Schedule.exponential(INITIAL_RECONNECT_DELAY).pipe(Schedule.modifyDelay(delay => Duration.lessThanOrEqualTo(delay, MAX_RECONNECT_DELAY)
10
+ ? delay
11
+ : MAX_RECONNECT_DELAY));
12
+ const attemptOpen = (url) => Effect.async(resume => {
13
+ const socket = new WebSocket(url);
14
+ let settled = false;
15
+ socket.once('open', () => {
16
+ if (settled)
17
+ return;
18
+ settled = true;
19
+ resume(Effect.succeed(socket));
20
+ });
21
+ socket.once('error', error => {
22
+ if (settled)
23
+ return;
24
+ settled = true;
25
+ resume(Effect.fail(error));
26
+ });
27
+ return Effect.sync(() => {
28
+ if (!settled) {
29
+ socket.removeAllListeners();
30
+ socket.close();
31
+ }
32
+ });
33
+ });
34
+ const waitForClose = (socket) => Effect.async(resume => {
35
+ const isAlreadyClosing = socket.readyState === WebSocket.CLOSED ||
36
+ socket.readyState === WebSocket.CLOSING;
37
+ if (isAlreadyClosing) {
38
+ resume(Effect.void);
39
+ return undefined;
40
+ }
41
+ else {
42
+ const handler = () => {
43
+ resume(Effect.void);
44
+ };
45
+ socket.once('close', handler);
46
+ return Effect.sync(() => {
47
+ socket.off('close', handler);
48
+ });
49
+ }
50
+ });
51
+ /**
52
+ * Open a WebSocket connection to the Foldkit Vite plugin's DevTools relay.
53
+ * The Effect succeeds once the initial connection is open and ready for
54
+ * traffic. It fails with the underlying `Error` if the *first* connection
55
+ * cannot be opened. Subsequent disconnects reconnect transparently.
56
+ */
57
+ export const connectWebSocketClient = (url) => Effect.gen(function* () {
58
+ const initialSocket = yield* attemptOpen(url);
59
+ yield* Effect.sync(() => console.error(`[foldkit-devtools-mcp] connected to ${url}`));
60
+ const pendingResponsesRef = yield* Ref.make(HashMap.empty());
61
+ const currentSocketRef = yield* Ref.make(initialSocket);
62
+ const isManuallyClosedRef = yield* Ref.make(false);
63
+ const runtime = yield* Effect.runtime();
64
+ const attachMessageHandler = (socket) => {
65
+ socket.on('message', raw => {
66
+ Runtime.runFork(runtime)(handleIncomingMessage(raw, pendingResponsesRef));
67
+ });
68
+ socket.on('error', error => {
69
+ console.error(`[foldkit-devtools-mcp] socket error: ${error.message}`);
70
+ });
71
+ };
72
+ attachMessageHandler(initialSocket);
73
+ const reconnectLoop = Effect.gen(function* () {
74
+ const socket = yield* Ref.get(currentSocketRef);
75
+ yield* waitForClose(socket);
76
+ const isManual = yield* Ref.get(isManuallyClosedRef);
77
+ if (isManual) {
78
+ return;
79
+ }
80
+ yield* Effect.sync(() => console.error('[foldkit-devtools-mcp] connection lost, reconnecting with backoff'));
81
+ const newSocket = yield* pipe(attemptOpen(url), Effect.tapError(error => Effect.sync(() => console.error(`[foldkit-devtools-mcp] reconnect attempt failed: ${error.message}`))), Effect.retry(reconnectSchedule), Effect.orDie);
82
+ yield* Effect.sync(() => console.error(`[foldkit-devtools-mcp] reconnected to ${url}`));
83
+ attachMessageHandler(newSocket);
84
+ yield* Ref.set(currentSocketRef, newSocket);
85
+ yield* reconnectLoop;
86
+ });
87
+ const reconnectFiber = yield* Effect.forkDaemon(reconnectLoop);
88
+ const sendRequest = (request, maybeRuntimeId) => Effect.gen(function* () {
89
+ const id = generateRequestId();
90
+ const deferred = yield* Deferred.make();
91
+ yield* Ref.update(pendingResponsesRef, HashMap.set(id, deferred));
92
+ const socket = yield* Ref.get(currentSocketRef);
93
+ if (socket.readyState !== WebSocket.OPEN) {
94
+ yield* Ref.update(pendingResponsesRef, HashMap.remove(id));
95
+ return yield* Effect.fail(new Error('Socket not open. The dev server may have just restarted; the MCP client is reconnecting. Retry the tool call in a moment.'));
96
+ }
97
+ const frame = {
98
+ id,
99
+ maybeConnectionId: maybeRuntimeId,
100
+ request,
101
+ };
102
+ yield* Effect.try({
103
+ try: () => socket.send(JSON.stringify(frame)),
104
+ catch: error => error instanceof Error
105
+ ? error
106
+ : new Error(`Failed to send request: ${String(error)}`),
107
+ }).pipe(Effect.tapError(() => Ref.update(pendingResponsesRef, HashMap.remove(id))));
108
+ return yield* Deferred.await(deferred).pipe(Effect.timeout(REQUEST_TIMEOUT), Effect.onError(() => Ref.update(pendingResponsesRef, HashMap.remove(id))));
109
+ });
110
+ const close = Effect.gen(function* () {
111
+ yield* Ref.set(isManuallyClosedRef, true);
112
+ const socket = yield* Ref.get(currentSocketRef);
113
+ yield* Effect.sync(() => socket.close());
114
+ yield* Fiber.interrupt(reconnectFiber);
115
+ });
116
+ return { sendRequest, close };
117
+ });
118
+ const handleIncomingMessage = (raw, pendingResponsesRef) => {
119
+ const decoded = S.decodeUnknownEither(S.parseJson(ResponseFrame))(raw.toString());
120
+ return Either.match(decoded, {
121
+ onLeft: error => Effect.sync(() => console.error('[foldkit-devtools-mcp] failed to decode frame', error)),
122
+ onRight: responseFrame => Effect.gen(function* () {
123
+ const map = yield* Ref.get(pendingResponsesRef);
124
+ const maybeDeferred = HashMap.get(map, responseFrame.id);
125
+ yield* Option.match(maybeDeferred, {
126
+ onNone: () => Effect.void,
127
+ onSome: deferred => Effect.gen(function* () {
128
+ yield* Ref.update(pendingResponsesRef, HashMap.remove(responseFrame.id));
129
+ yield* Deferred.succeed(deferred, responseFrame.response);
130
+ }),
131
+ });
132
+ }),
133
+ });
134
+ };
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@foldkit/devtools-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server exposing Foldkit DevTools to AI agents (Claude Code, Cursor, etc.)",
5
+ "type": "module",
6
+ "main": "./dist/server.js",
7
+ "module": "./dist/server.js",
8
+ "types": "./dist/server.d.ts",
9
+ "bin": {
10
+ "foldkit-devtools-mcp": "./dist/server.js"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/server.d.ts",
15
+ "import": "./dist/server.js"
16
+ }
17
+ },
18
+ "scripts": {
19
+ "clean": "rimraf dist *.tsbuildinfo",
20
+ "build": "pnpm run clean && tsc -b",
21
+ "watch": "tsc -b --watch",
22
+ "typecheck": "tsc -b --noEmit"
23
+ },
24
+ "peerDependencies": {
25
+ "effect": "^3.18.2",
26
+ "foldkit": "workspace:^0.76.0"
27
+ },
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.29.0",
30
+ "ws": "^8.18.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^22.0.0",
34
+ "@types/ws": "^8.5.13",
35
+ "effect": "^3.19.19",
36
+ "foldkit": "workspace:*",
37
+ "rimraf": "^6.0.0",
38
+ "typescript": "^6.0.2"
39
+ },
40
+ "files": [
41
+ "dist"
42
+ ],
43
+ "keywords": [
44
+ "foldkit",
45
+ "mcp",
46
+ "model-context-protocol",
47
+ "devtools",
48
+ "ai"
49
+ ],
50
+ "author": "Devin Jameson",
51
+ "license": "MIT",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "https://github.com/foldkit/foldkit.git",
55
+ "directory": "packages/devtools-mcp"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "engines": {
61
+ "node": ">=18.0.0"
62
+ }
63
+ }