@foldkit/devtools-mcp 0.1.2 → 0.3.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 CHANGED
@@ -4,8 +4,11 @@ A Model Context Protocol server that exposes a running [Foldkit](https://foldkit
4
4
 
5
5
  With it attached, agents can:
6
6
 
7
- - Read the current Model
8
- - List and inspect the Message history
7
+ - Read the current Model, or any historical Model by history index
8
+ - Narrow reads with dot-string paths and summarized payloads to fit token budgets
9
+ - List and inspect the Message history, with diffs and submodel chains
10
+ - Read the recorded init Model and init Command names
11
+ - Inspect runtime state: current index, retained history bounds, pause status
9
12
  - Replay to any past state and resume
10
13
  - Dispatch Messages into the runtime, decoded against your `Message` Schema
11
14
 
@@ -55,7 +58,7 @@ Runtime.makeProgram({
55
58
  })
56
59
  ```
57
60
 
58
- Restart your dev server, then restart your AI agent. The MCP server will appear with the eight `foldkit_*` tools attached.
61
+ Restart your dev server, then restart your AI agent. The MCP server will appear with the `foldkit_*` tools attached.
59
62
 
60
63
  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`.
61
64
 
@@ -63,16 +66,26 @@ The browser bridge runs inside your app, so the MCP server only sees a runtime w
63
66
 
64
67
  Each tool accepts an optional `runtime_id`. When omitted, the most recently connected runtime is used.
65
68
 
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. |
70
- | `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. |
71
- | `foldkit_get_message` | Reads one entry at a given index, including the Model before and after the Message was applied. |
72
- | `foldkit_list_keyframes` | Returns the indices Foldkit can replay back to. Index `-1` is the initial Model. |
73
- | `foldkit_replay_to_keyframe` | Time-travels the runtime to a previous state. The runtime is paused at that snapshot until `foldkit_resume` is called. |
74
- | `foldkit_resume` | Resumes normal execution after a replay. |
75
- | `foldkit_dispatch_message` | Enqueues 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. |
69
+ | Tool | Description |
70
+ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
71
+ | `foldkit_list_runtimes` | Returns metadata for every connected browser tab. Agents call this first to discover which runtime to target. |
72
+ | `foldkit_get_model` | Snapshots the current Model. Accepts an optional `path` to narrow to a subtree and `expand` to control summarization. |
73
+ | `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`. For the initial Model (and init Command names), use `foldkit_get_init`. |
74
+ | `foldkit_get_init` | Reads the recorded initial Model and the names of Commands returned from the application's `init` function. Equivalent to selecting the synthetic "init" row in the DevTools panel. |
75
+ | `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. |
76
+ | `foldkit_list_messages` | Lists recent Message history entries with pagination. Each entry carries the Message body, Command names triggered, timestamp, an `isModelChanged` flag, the diff path lists (`changedPaths` / `affectedPaths`), and any extracted Submodel chain. |
77
+ | `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. |
78
+ | `foldkit_list_keyframes` | Returns the indices Foldkit can replay back to. Index `-1` is the initial Model. |
79
+ | `foldkit_replay_to_keyframe` | Time-travels the runtime to a previous state. The runtime is paused at that snapshot until `foldkit_resume` is called. |
80
+ | `foldkit_resume` | Resumes normal execution after a replay. |
81
+ | `foldkit_dispatch_message` | Enqueues 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
+
83
+ ### Reading the Model efficiently
84
+
85
+ `foldkit_get_model` and `foldkit_get_model_at` are designed for AI agents reading state into a token-bounded context. Two parameters control the payload size:
86
+
87
+ - **`path`** is a dot-string anchored at `root` that narrows the response to a subtree. The alphabet matches the `changedPaths` array on each `SerializedEntry`, so a path observed in `foldkit_list_messages` can be passed straight back. Examples: `'root'` (the whole Model), `'root.route'`, `'root.session.user'`, `'root.cards.0'`. When the path doesn't resolve, the response is an error listing the keys available at the deepest segment that did resolve, so the agent can refine in one follow-up call.
88
+ - **`expand`** controls summarization. By default (`false`), large arrays collapse to `{ _summary: 'array', length, sample: [head, last] }`, deeply nested records collapse to `{ _summary: 'record', keys }`, and long strings collapse to `{ _summary: 'string', length, head }`. Tagged-union variants (`{ _tag, ... }`) keep their tag and recursively summarize children. With `expand: true`, the literal value at the path is returned with no summarization. Pair a narrow `path` with `expand: true` to read a specific subtree at full fidelity without paying for the rest of the Model.
76
89
 
77
90
  ## Architecture
78
91
 
package/dist/server.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
5
- import { Effect, HashMap, Option, Runtime } from 'effect';
5
+ import { Console, Effect, HashMap, Option } from 'effect';
6
6
  import { runInit } from './install.js';
7
7
  import { buildTools } from './tools.js';
8
8
  import { connectWebSocketClient } from './webSocketClient.js';
@@ -14,7 +14,7 @@ const main = Effect.gen(function* () {
14
14
  const wsClient = yield* connectWebSocketClient(`ws://${host}:${port}`);
15
15
  const tools = buildTools(wsClient);
16
16
  const toolsByName = HashMap.fromIterable(tools.map(tool => [tool.name, tool]));
17
- const runtime = yield* Effect.runtime();
17
+ const runtime = yield* Effect.context();
18
18
  const server = new Server({ name: '@foldkit/devtools-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
19
19
  server.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({
20
20
  tools: tools.map(({ name, description, inputSchema }) => ({
@@ -33,21 +33,34 @@ const main = Effect.gen(function* () {
33
33
  ],
34
34
  isError: true,
35
35
  }),
36
- onSome: tool => Runtime.runPromise(runtime)(tool.handle(request.params.arguments ?? {})),
36
+ onSome: tool => Effect.runPromiseWith(runtime)(tool.handle(request.params.arguments ?? {})),
37
37
  }));
38
38
  const transport = new StdioServerTransport();
39
39
  yield* Effect.tryPromise({
40
40
  try: () => server.connect(transport),
41
41
  catch: error => error,
42
42
  });
43
- yield* Effect.sync(() => console.error('[foldkit-devtools-mcp] MCP server ready on stdio'));
43
+ yield* Console.error('[foldkit-devtools-mcp] MCP server ready on stdio');
44
+ // NOTE: blocks until stdin closes (parent MCP host exited). Without this,
45
+ // the forked WebSocket connection-loop fiber keeps the Effect runtime alive
46
+ // forever. The subprocess outlives its parent and accumulates as a zombie
47
+ // across host restarts.
48
+ yield* Effect.callback(resume => {
49
+ const onClose = () => resume(Effect.void);
50
+ process.stdin.on('end', onClose);
51
+ process.stdin.on('close', onClose);
52
+ return Effect.sync(() => {
53
+ process.stdin.off('end', onClose);
54
+ process.stdin.off('close', onClose);
55
+ });
56
+ });
44
57
  });
45
58
  const subcommand = process.argv[2];
46
59
  if (subcommand === 'init') {
47
60
  runInit();
48
61
  }
49
62
  else {
50
- Effect.runPromise(main).catch(error => {
63
+ Effect.runPromise(main).then(() => process.exit(0), error => {
51
64
  console.error('[foldkit-devtools-mcp] fatal error', error);
52
65
  process.exit(1);
53
66
  });
@@ -1 +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"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,MAAM,EAA8B,MAAM,QAAQ,CAAA;AAiBlE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAuH3D,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;AA0GF;;;;GAIG;AACH,eAAO,MAAM,UAAU,GACrB,UAAU,eAAe,KACxB,aAAa,CAAC,cAAc,CAqI9B,CAAA"}
package/dist/tools.js CHANGED
@@ -1,22 +1,39 @@
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';
1
+ import { Array, Effect, Match, Option, Schema as S } from 'effect';
2
+ import { RequestDispatchMessage, RequestGetInit, RequestGetMessage, RequestGetModel, RequestGetModelAt, RequestGetRuntimeState, RequestListKeyframes, RequestListMessages, RequestListRuntimes, RequestReplayToKeyframe, RequestResume, } from 'foldkit/devtools-protocol';
3
3
  const RUNTIME_ID_DESCRIPTION = 'Optional connection id of a specific Foldkit runtime. Defaults to the most recently connected runtime.';
4
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({
5
+ const RuntimeIdField = S.optional(S.String.annotate({ description: RUNTIME_ID_DESCRIPTION }));
6
+ const ListLimit = S.Int.check(S.isBetween({ minimum: 1, maximum: 500 })).annotate({
7
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({
8
+ });
9
+ const SinceIndex = S.Int.annotate({
10
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({
11
+ });
12
+ const MessageIndex = S.Int.annotate({
13
13
  description: 'Absolute history index of the entry to read.',
14
- }));
15
- const KeyframeIndex = S.Number.pipe(S.int(), S.annotations({
14
+ });
15
+ const KeyframeIndex = S.Int.annotate({
16
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 ModelIndex = S.Int.annotate({
19
+ description: 'Absolute history index. Returns the Model state right after the entry at this index was applied. To inspect the Model immediately before message N, pass index N - 1. For the initial Model, use foldkit_get_init.',
20
+ });
21
+ const PathField = S.optional(S.String.annotate({
22
+ description: "Dot-string path into the Model anchored at 'root'. Examples: 'root', 'root.route', 'root.session.user', 'root.cards.0'. Matches the alphabet used by SerializedEntry.changedPaths so paths copied from one tool's output can be passed straight into the next. Defaults to 'root' (the whole Model).",
23
+ }));
24
+ const ExpandField = S.optional(S.Boolean.annotate({
25
+ description: "When false (the default), large arrays/records/strings collapse to '_summary' placeholders to keep payloads small. Set true to receive the literal value at the path. Pair with `path` to drill in: a narrow path with `expand: true` is the cheapest way to read a specific subtree at full fidelity.",
17
26
  }));
18
27
  const GetModelInput = S.Struct({
19
28
  runtime_id: RuntimeIdField,
29
+ path: PathField,
30
+ expand: ExpandField,
31
+ });
32
+ const GetModelAtInput = S.Struct({
33
+ runtime_id: RuntimeIdField,
34
+ index: ModelIndex,
35
+ path: PathField,
36
+ expand: ExpandField,
20
37
  });
21
38
  const ListMessagesInput = S.Struct({
22
39
  runtime_id: RuntimeIdField,
@@ -37,17 +54,26 @@ const ReplayToKeyframeInput = S.Struct({
37
54
  const ResumeInput = S.Struct({
38
55
  runtime_id: RuntimeIdField,
39
56
  });
57
+ const GetInitInput = S.Struct({
58
+ runtime_id: RuntimeIdField,
59
+ });
60
+ const GetRuntimeStateInput = S.Struct({
61
+ runtime_id: RuntimeIdField,
62
+ });
40
63
  const DispatchMessageInput = S.Struct({
41
64
  runtime_id: RuntimeIdField,
42
- message: S.Unknown.annotations({
65
+ message: S.Unknown.annotate({
43
66
  description: "A Foldkit Message object to dispatch into the runtime. Must match the runtime's Message Schema — read the application's source to see the exact shape. At minimum it has a `_tag` field naming the variant. The runtime decodes the payload and returns a clean error if it doesn't match.",
44
67
  }),
45
68
  });
46
69
  /**
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).
70
+ * Extract the inner JSON Schema from Effect's `JsonSchema.Document` wrapper.
71
+ * MCP's tool registry validates `inputSchema.type === "object"` at the top
72
+ * level; the Document wrapper (`{ dialect, schema, definitions }`) hides
73
+ * `type` one level deeper, so registration silently fails. Unwrapping fixes
74
+ * tool surfacing in Claude Code, Cursor, and any other MCP host.
50
75
  */
76
+ const toInputSchema = (codec) => S.toJsonSchemaDocument(codec).schema;
51
77
  const NO_INPUT_SCHEMA = {
52
78
  type: 'object',
53
79
  properties: {},
@@ -64,7 +90,7 @@ const formatError = (reason) => ({
64
90
  * Decode a tool's raw input against its Effect Schema. Failure surfaces as an
65
91
  * `Error` for the outer handler's `catchAll` to convert into a `ToolResult`.
66
92
  */
67
- const decodeInput = (schema, rawInput) => S.decodeUnknown(schema)(rawInput).pipe(Effect.mapError(error => new Error(`Invalid input: ${error.message}`)));
93
+ const decodeInput = (schema, rawInput) => S.decodeUnknownEffect(schema)(rawInput).pipe(Effect.mapError(error => new Error(`Invalid input: ${error.message}`)));
68
94
  /**
69
95
  * Resolve a runtime id, defaulting to the most recently connected runtime when
70
96
  * the caller did not specify one. Failures (no runtimes connected, relay
@@ -89,7 +115,7 @@ const callRuntimeRequest = (wsClient, explicitRuntimeId, buildRequest) => Effect
89
115
  const runtimeId = yield* resolveRuntimeId(wsClient, explicitRuntimeId);
90
116
  const response = yield* wsClient.sendRequest(buildRequest(), Option.some(runtimeId));
91
117
  return responseToToolResult(response);
92
- }).pipe(Effect.catchAll(error => Effect.succeed(formatError(error.message))));
118
+ }).pipe(Effect.catch(error => Effect.succeed(formatError(error.message))));
93
119
  /**
94
120
  * Build a tool handler that decodes its input, resolves the target runtime,
95
121
  * issues a typed `Request`, and formats the response. Used for every tool
@@ -98,7 +124,7 @@ const callRuntimeRequest = (wsClient, explicitRuntimeId, buildRequest) => Effect
98
124
  const runRuntimeTool = (inputSchema, buildRequest, wsClient) => (rawInput) => Effect.gen(function* () {
99
125
  const input = yield* decodeInput(inputSchema, rawInput);
100
126
  return yield* callRuntimeRequest(wsClient, input.runtime_id, () => buildRequest(input));
101
- }).pipe(Effect.catchAll(error => Effect.succeed(formatError(error.message))));
127
+ }).pipe(Effect.catch(error => Effect.succeed(formatError(error.message))));
102
128
  /**
103
129
  * Build the read-only Foldkit DevTools tool definitions. Each tool decodes its
104
130
  * input via Effect Schema, dispatches a typed `Request` through the WebSocket
@@ -107,47 +133,72 @@ const runRuntimeTool = (inputSchema, buildRequest, wsClient) => (rawInput) => Ef
107
133
  export const buildTools = (wsClient) => [
108
134
  {
109
135
  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),
136
+ description: "Snapshot the current Model from a connected Foldkit runtime. By default the response is summarized (large arrays/records/strings collapse to `_summary` placeholders) to keep payloads small for AI agents. Pass `path` (e.g. 'root.session.user') to narrow to a subtree, and `expand: true` to receive the literal value at that path. Returns `{ value, atPath, summarized }`.",
137
+ inputSchema: toInputSchema(GetModelInput),
138
+ handle: runRuntimeTool(GetModelInput, ({ path, expand }) => RequestGetModel({
139
+ maybePath: Option.fromNullishOr(path),
140
+ expand: expand ?? false,
141
+ }), wsClient),
142
+ },
143
+ {
144
+ name: 'foldkit_get_model_at',
145
+ description: "Snapshot a historical Model after a given history entry was applied. Pass `index: N - 1` to read the Model just before message N. Same `path`/`expand` semantics as foldkit_get_model. For the initial Model (and the names of Commands returned from the application's `init`), use foldkit_get_init.",
146
+ inputSchema: toInputSchema(GetModelAtInput),
147
+ handle: runRuntimeTool(GetModelAtInput, ({ index, path, expand }) => RequestGetModelAt({
148
+ index,
149
+ maybePath: Option.fromNullishOr(path),
150
+ expand: expand ?? false,
151
+ }), wsClient),
113
152
  },
114
153
  {
115
154
  name: 'foldkit_list_messages',
116
155
  description: 'List recent Message history entries from a Foldkit runtime, with optional pagination via since_index.',
117
- inputSchema: JSONSchema.make(ListMessagesInput),
156
+ inputSchema: toInputSchema(ListMessagesInput),
118
157
  handle: runRuntimeTool(ListMessagesInput, ({ limit, since_index }) => RequestListMessages({
119
158
  limit: limit ?? DEFAULT_LIST_MESSAGES_LIMIT,
120
- maybeSinceIndex: Option.fromNullable(since_index),
159
+ maybeSinceIndex: Option.fromNullishOr(since_index),
121
160
  }), wsClient),
122
161
  },
123
162
  {
124
163
  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),
164
+ description: 'Read a single Message history entry by absolute index. The response carries the SerializedEntry (tag, message body, commandNames, timestamp, `isModelChanged`, `changedPaths` for leaf-level mutations, `affectedPaths` adding their ancestor paths). For Submodel-routed entries (tag matches `Got*Message`), the entry also carries `submodelPath` listing wrapper tags from outer to inner and `maybeLeafTag` naming the innermost child Message. Model snapshots are not included; call foldkit_get_model_at with `index - 1` (before) and `index` (after) to inspect Model state around the entry.',
165
+ inputSchema: toInputSchema(GetMessageInput),
127
166
  handle: runRuntimeTool(GetMessageInput, ({ index }) => RequestGetMessage({ index }), wsClient),
128
167
  },
168
+ {
169
+ name: 'foldkit_get_init',
170
+ description: "Read the runtime's initial Model and the names of Commands returned from the application's `init` function. The init entry is the synthetic row at index -1 in the DevTools panel; this tool exposes the same data without time-travelling the runtime. `maybeModel` is `None` until the runtime has finished its first render and recorded init, then stays `Some` for the rest of the runtime's life.",
171
+ inputSchema: toInputSchema(GetInitInput),
172
+ handle: runRuntimeTool(GetInitInput, () => RequestGetInit(), wsClient),
173
+ },
174
+ {
175
+ name: 'foldkit_get_runtime_state',
176
+ description: "Snapshot the runtime's DevTools state: history bounds, current paused/live status, and whether init is recorded. Returns `currentIndex` (the absolute index of the most recent Message, or -1 when none), `startIndex` (the earliest absolute index still retained in the rolling buffer), `totalEntries` (count of retained entries), `isPaused`, `maybePausedAtIndex` (`Some(index)` when paused, `None` otherwise), and `hasInitModel`. Use it to reason about what `foldkit_list_messages` and `foldkit_get_message` will see, and to detect whether the runtime is currently paused at a replayed snapshot.",
177
+ inputSchema: toInputSchema(GetRuntimeStateInput),
178
+ handle: runRuntimeTool(GetRuntimeStateInput, () => RequestGetRuntimeState(), wsClient),
179
+ },
129
180
  {
130
181
  name: 'foldkit_list_keyframes',
131
182
  description: 'List the available keyframes (replayable Model snapshots) from a Foldkit runtime.',
132
- inputSchema: JSONSchema.make(ListKeyframesInput),
183
+ inputSchema: toInputSchema(ListKeyframesInput),
133
184
  handle: runRuntimeTool(ListKeyframesInput, () => RequestListKeyframes(), wsClient),
134
185
  },
135
186
  {
136
187
  name: 'foldkit_replay_to_keyframe',
137
188
  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),
189
+ inputSchema: toInputSchema(ReplayToKeyframeInput),
139
190
  handle: runRuntimeTool(ReplayToKeyframeInput, ({ keyframe_index }) => RequestReplayToKeyframe({ keyframeIndex: keyframe_index }), wsClient),
140
191
  },
141
192
  {
142
193
  name: 'foldkit_resume',
143
194
  description: 'Resume normal execution of a Foldkit runtime that was paused by foldkit_replay_to_keyframe.',
144
- inputSchema: JSONSchema.make(ResumeInput),
195
+ inputSchema: toInputSchema(ResumeInput),
145
196
  handle: runRuntimeTool(ResumeInput, () => RequestResume(), wsClient),
146
197
  },
147
198
  {
148
199
  name: 'foldkit_dispatch_message',
149
200
  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. Read the application's Message Schema source to construct a valid Message object. The runtime decodes the payload and returns a clean error if it doesn't match.",
150
- inputSchema: JSONSchema.make(DispatchMessageInput),
201
+ inputSchema: toInputSchema(DispatchMessageInput),
151
202
  handle: runRuntimeTool(DispatchMessageInput, ({ message }) => RequestDispatchMessage({ message }), wsClient),
152
203
  },
153
204
  {
@@ -157,6 +208,6 @@ export const buildTools = (wsClient) => [
157
208
  handle: () => Effect.gen(function* () {
158
209
  const response = yield* wsClient.sendRequest(RequestListRuntimes(), Option.none());
159
210
  return responseToToolResult(response);
160
- }).pipe(Effect.catchAll(error => Effect.succeed(formatError(error.message)))),
211
+ }).pipe(Effect.catch(error => Effect.succeed(formatError(error.message)))),
161
212
  },
162
213
  ];
@@ -1,28 +1,36 @@
1
1
  import { type Cause, Effect, Option } from 'effect';
2
2
  import { type Request, type Response } from 'foldkit/devtools-protocol';
3
3
  /**
4
- * A connected WebSocket client to the Foldkit Vite plugin's DevTools relay.
4
+ * A WebSocket client to the Foldkit Vite plugin's DevTools relay.
5
5
  *
6
6
  * Sends typed `Request`s and resolves with the matching `Response`. The
7
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.
8
+ * within the request timeout window, or with `Error` when no relay is
9
+ * connected or the send throws. Either way, no pending entry leaks.
10
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.
11
+ * The client manages its own connection lifecycle in a background fiber:
12
+ * the initial connect is retried with exponential backoff, and any later
13
+ * disconnect (e.g. when the user restarts the Vite dev server) reconnects
14
+ * via the same loop. The MCP server can stay live across dev-server
15
+ * restarts and even when no dev server has started yet — `sendRequest`
16
+ * returns a clear "not connected" error in that window, and tools should
17
+ * surface it to the agent so the user can start a dev server and retry.
18
+ *
19
+ * Pending response correlators live in a client-owned Ref, not on the
20
+ * socket, so they survive reconnects: in-flight requests time out and
21
+ * future requests succeed once the new socket is open.
16
22
  */
17
23
  export type WebSocketClient = Readonly<{
18
- sendRequest: (request: typeof Request.Type, maybeRuntimeId: Option.Option<string>) => Effect.Effect<typeof Response.Type, Cause.TimeoutException | Error>;
24
+ sendRequest: (request: typeof Request.Type, maybeRuntimeId: Option.Option<string>) => Effect.Effect<typeof Response.Type, Cause.TimeoutError | Error>;
19
25
  close: Effect.Effect<void>;
20
26
  }>;
21
27
  /**
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.
28
+ * Construct a WebSocket client that maintains its connection to the Foldkit
29
+ * Vite plugin's DevTools relay in the background. The Effect succeeds
30
+ * immediately with a client whose connection state evolves over time. The
31
+ * initial connect is retried with exponential backoff; later disconnects
32
+ * reconnect via the same loop. `sendRequest` fails with a clear "not
33
+ * connected" error while no relay is reachable.
26
34
  */
27
- export declare const connectWebSocketClient: (url: string) => Effect.Effect<WebSocketClient, Error>;
35
+ export declare const connectWebSocketClient: (url: string) => Effect.Effect<WebSocketClient>;
28
36
  //# sourceMappingURL=webSocketClient.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"webSocketClient.d.ts","sourceRoot":"","sources":["../src/webSocketClient.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,KAAK,EAIV,MAAM,EAIN,MAAM,EAKP,MAAM,QAAQ,CAAA;AACf,OAAO,EACL,KAAK,OAAO,EAEZ,KAAK,QAAQ,EAEd,MAAM,2BAA2B,CAAA;AAgBlC;;;;;;;;;;;;;;;;;;;GAmBG;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,YAAY,GAAG,KAAK,CAAC,CAAA;IACpE,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;CAC3B,CAAC,CAAA;AAqDF;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,GACjC,KAAK,MAAM,KACV,MAAM,CAAC,MAAM,CAAC,eAAe,CAuH5B,CAAA"}
@@ -1,15 +1,13 @@
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';
1
+ import { Console, Deferred, Duration, Effect, Exit, Fiber, HashMap, Option, Ref, Schema as S, Schedule, pipe, } from 'effect';
2
+ import { RequestFrame, ResponseFrame, } from 'foldkit/devtools-protocol';
3
3
  import { WebSocket } from 'ws';
4
4
  const REQUEST_TIMEOUT = Duration.seconds(10);
5
5
  const INITIAL_RECONNECT_DELAY = Duration.millis(500);
6
6
  const MAX_RECONNECT_DELAY = Duration.seconds(30);
7
+ const encodeRequestFrameToJson = S.encodeUnknownSync(S.fromJsonString(RequestFrame));
7
8
  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 => {
9
+ const reconnectSchedule = Schedule.exponential(INITIAL_RECONNECT_DELAY).pipe(Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.min(delay, MAX_RECONNECT_DELAY))));
10
+ const attemptOpen = (url) => Effect.callback(resume => {
13
11
  const socket = new WebSocket(url);
14
12
  let settled = false;
15
13
  socket.once('open', () => {
@@ -31,7 +29,7 @@ const attemptOpen = (url) => Effect.async(resume => {
31
29
  }
32
30
  });
33
31
  });
34
- const waitForClose = (socket) => Effect.async(resume => {
32
+ const waitForClose = (socket) => Effect.callback(resume => {
35
33
  const isAlreadyClosing = socket.readyState === WebSocket.CLOSED ||
36
34
  socket.readyState === WebSocket.CLOSING;
37
35
  if (isAlreadyClosing) {
@@ -49,58 +47,60 @@ const waitForClose = (socket) => Effect.async(resume => {
49
47
  }
50
48
  });
51
49
  /**
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.
50
+ * Construct a WebSocket client that maintains its connection to the Foldkit
51
+ * Vite plugin's DevTools relay in the background. The Effect succeeds
52
+ * immediately with a client whose connection state evolves over time. The
53
+ * initial connect is retried with exponential backoff; later disconnects
54
+ * reconnect via the same loop. `sendRequest` fails with a clear "not
55
+ * connected" error while no relay is reachable.
56
56
  */
57
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
58
  const pendingResponsesRef = yield* Ref.make(HashMap.empty());
61
- const currentSocketRef = yield* Ref.make(initialSocket);
59
+ const currentSocketRef = yield* Ref.make(Option.none());
62
60
  const isManuallyClosedRef = yield* Ref.make(false);
63
- const runtime = yield* Effect.runtime();
61
+ const capturedContext = yield* Effect.context();
64
62
  const attachMessageHandler = (socket) => {
65
63
  socket.on('message', raw => {
66
- Runtime.runFork(runtime)(handleIncomingMessage(raw, pendingResponsesRef));
64
+ Effect.runForkWith(capturedContext)(handleIncomingMessage(raw, pendingResponsesRef));
67
65
  });
68
66
  socket.on('error', error => {
69
67
  console.error(`[foldkit-devtools-mcp] socket error: ${error.message}`);
70
68
  });
71
69
  };
72
- attachMessageHandler(initialSocket);
73
- const reconnectLoop = Effect.gen(function* () {
74
- const socket = yield* Ref.get(currentSocketRef);
70
+ const openWithBackoff = pipe(attemptOpen(url), Effect.tapError(error => Console.error(`[foldkit-devtools-mcp] connect attempt failed: ${error.message}`)), Effect.retry(reconnectSchedule), Effect.orDie);
71
+ const maintainConnection = Effect.gen(function* () {
72
+ const socket = yield* openWithBackoff;
73
+ yield* Console.error(`[foldkit-devtools-mcp] connected to ${url}`);
74
+ attachMessageHandler(socket);
75
+ yield* Ref.set(currentSocketRef, Option.some(socket));
75
76
  yield* waitForClose(socket);
76
77
  const isManual = yield* Ref.get(isManuallyClosedRef);
77
78
  if (isManual) {
78
79
  return;
79
80
  }
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;
81
+ yield* Ref.set(currentSocketRef, Option.none());
82
+ yield* Console.error('[foldkit-devtools-mcp] connection lost, reconnecting');
83
+ yield* maintainConnection;
86
84
  });
87
- const reconnectFiber = yield* Effect.forkDaemon(reconnectLoop);
85
+ const connectionFiber = yield* Effect.forkDetach(maintainConnection);
88
86
  const sendRequest = (request, maybeRuntimeId) => Effect.gen(function* () {
87
+ const maybeSocket = yield* Ref.get(currentSocketRef);
88
+ const socket = yield* Option.match(maybeSocket, {
89
+ onNone: () => Effect.fail(new Error('Not connected to a Foldkit dev server. Start your Foldkit Vite dev server and retry the tool call.')),
90
+ onSome: candidate => candidate.readyState === WebSocket.OPEN
91
+ ? Effect.succeed(candidate)
92
+ : Effect.fail(new Error('Foldkit dev server connection is reconnecting. Retry the tool call in a moment.')),
93
+ });
89
94
  const id = generateRequestId();
90
95
  const deferred = yield* Deferred.make();
91
96
  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
97
  const frame = {
98
98
  id,
99
99
  maybeConnectionId: maybeRuntimeId,
100
100
  request,
101
101
  };
102
102
  yield* Effect.try({
103
- try: () => socket.send(JSON.stringify(frame)),
103
+ try: () => socket.send(encodeRequestFrameToJson(frame)),
104
104
  catch: error => error instanceof Error
105
105
  ? error
106
106
  : new Error(`Failed to send request: ${String(error)}`),
@@ -109,17 +109,20 @@ export const connectWebSocketClient = (url) => Effect.gen(function* () {
109
109
  });
110
110
  const close = Effect.gen(function* () {
111
111
  yield* Ref.set(isManuallyClosedRef, true);
112
- const socket = yield* Ref.get(currentSocketRef);
113
- yield* Effect.sync(() => socket.close());
114
- yield* Fiber.interrupt(reconnectFiber);
112
+ const maybeSocket = yield* Ref.get(currentSocketRef);
113
+ yield* Option.match(maybeSocket, {
114
+ onNone: () => Effect.void,
115
+ onSome: socket => Effect.sync(() => socket.close()),
116
+ });
117
+ yield* Fiber.interrupt(connectionFiber);
115
118
  });
116
119
  return { sendRequest, close };
117
120
  });
118
121
  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* () {
122
+ const decoded = S.decodeUnknownExit(S.fromJsonString(ResponseFrame))(raw.toString());
123
+ return Exit.match(decoded, {
124
+ onFailure: error => Effect.sync(() => console.error('[foldkit-devtools-mcp] failed to decode frame', error)),
125
+ onSuccess: responseFrame => Effect.gen(function* () {
123
126
  const map = yield* Ref.get(pendingResponsesRef);
124
127
  const maybeDeferred = HashMap.get(map, responseFrame.id);
125
128
  yield* Option.match(maybeDeferred, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldkit/devtools-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server exposing Foldkit DevTools to AI agents (Claude Code, Cursor, etc.)",
5
5
  "type": "module",
6
6
  "main": "./dist/server.js",
@@ -16,7 +16,7 @@
16
16
  }
17
17
  },
18
18
  "peerDependencies": {
19
- "effect": "^3.18.2",
19
+ "effect": "4.0.0-beta.59",
20
20
  "foldkit": "^0"
21
21
  },
22
22
  "dependencies": {
@@ -26,10 +26,10 @@
26
26
  "devDependencies": {
27
27
  "@types/node": "^22.0.0",
28
28
  "@types/ws": "^8.5.13",
29
- "effect": "^3.19.19",
29
+ "effect": "4.0.0-beta.59",
30
30
  "rimraf": "^6.0.0",
31
31
  "typescript": "^6.0.2",
32
- "foldkit": "0.77.0"
32
+ "foldkit": "0.82.0"
33
33
  },
34
34
  "files": [
35
35
  "dist"