@foldkit/devtools-mcp 0.2.0 → 0.4.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/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;AAiB9E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAgI3D,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,CAqI9B,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,27 +1,27 @@
1
- import { Array, Effect, JSONSchema, Match, Option, Schema as S } from 'effect';
1
+ import { Array, Effect, Match, Option, Schema as S } from 'effect';
2
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.Number.pipe(S.int(), S.annotations({
17
+ });
18
+ const ModelIndex = S.Int.annotate({
19
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.annotations({
20
+ });
21
+ const PathField = S.optional(S.String.annotate({
22
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
23
  }));
24
- const ExpandField = S.optional(S.Boolean.annotations({
24
+ const ExpandField = S.optional(S.Boolean.annotate({
25
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.",
26
26
  }));
27
27
  const GetModelInput = S.Struct({
@@ -62,15 +62,18 @@ const GetRuntimeStateInput = S.Struct({
62
62
  });
63
63
  const DispatchMessageInput = S.Struct({
64
64
  runtime_id: RuntimeIdField,
65
- message: S.Unknown.annotations({
65
+ message: S.Unknown.annotate({
66
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.",
67
67
  }),
68
68
  });
69
69
  /**
70
- * JSON Schema for tools that take no input. Inlined as a literal because
71
- * `JSONSchema.make(S.Struct({}))` produces a shape MCP's AJV validator
72
- * 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.
73
75
  */
76
+ const toInputSchema = (codec) => S.toJsonSchemaDocument(codec).schema;
74
77
  const NO_INPUT_SCHEMA = {
75
78
  type: 'object',
76
79
  properties: {},
@@ -87,7 +90,7 @@ const formatError = (reason) => ({
87
90
  * Decode a tool's raw input against its Effect Schema. Failure surfaces as an
88
91
  * `Error` for the outer handler's `catchAll` to convert into a `ToolResult`.
89
92
  */
90
- 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}`)));
91
94
  /**
92
95
  * Resolve a runtime id, defaulting to the most recently connected runtime when
93
96
  * the caller did not specify one. Failures (no runtimes connected, relay
@@ -112,7 +115,7 @@ const callRuntimeRequest = (wsClient, explicitRuntimeId, buildRequest) => Effect
112
115
  const runtimeId = yield* resolveRuntimeId(wsClient, explicitRuntimeId);
113
116
  const response = yield* wsClient.sendRequest(buildRequest(), Option.some(runtimeId));
114
117
  return responseToToolResult(response);
115
- }).pipe(Effect.catchAll(error => Effect.succeed(formatError(error.message))));
118
+ }).pipe(Effect.catch(error => Effect.succeed(formatError(error.message))));
116
119
  /**
117
120
  * Build a tool handler that decodes its input, resolves the target runtime,
118
121
  * issues a typed `Request`, and formats the response. Used for every tool
@@ -121,7 +124,7 @@ const callRuntimeRequest = (wsClient, explicitRuntimeId, buildRequest) => Effect
121
124
  const runRuntimeTool = (inputSchema, buildRequest, wsClient) => (rawInput) => Effect.gen(function* () {
122
125
  const input = yield* decodeInput(inputSchema, rawInput);
123
126
  return yield* callRuntimeRequest(wsClient, input.runtime_id, () => buildRequest(input));
124
- }).pipe(Effect.catchAll(error => Effect.succeed(formatError(error.message))));
127
+ }).pipe(Effect.catch(error => Effect.succeed(formatError(error.message))));
125
128
  /**
126
129
  * Build the read-only Foldkit DevTools tool definitions. Each tool decodes its
127
130
  * input via Effect Schema, dispatches a typed `Request` through the WebSocket
@@ -131,71 +134,71 @@ export const buildTools = (wsClient) => [
131
134
  {
132
135
  name: 'foldkit_get_model',
133
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 }`.",
134
- inputSchema: JSONSchema.make(GetModelInput),
137
+ inputSchema: toInputSchema(GetModelInput),
135
138
  handle: runRuntimeTool(GetModelInput, ({ path, expand }) => RequestGetModel({
136
- maybePath: Option.fromNullable(path),
139
+ maybePath: Option.fromNullishOr(path),
137
140
  expand: expand ?? false,
138
141
  }), wsClient),
139
142
  },
140
143
  {
141
144
  name: 'foldkit_get_model_at',
142
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.",
143
- inputSchema: JSONSchema.make(GetModelAtInput),
146
+ inputSchema: toInputSchema(GetModelAtInput),
144
147
  handle: runRuntimeTool(GetModelAtInput, ({ index, path, expand }) => RequestGetModelAt({
145
148
  index,
146
- maybePath: Option.fromNullable(path),
149
+ maybePath: Option.fromNullishOr(path),
147
150
  expand: expand ?? false,
148
151
  }), wsClient),
149
152
  },
150
153
  {
151
154
  name: 'foldkit_list_messages',
152
155
  description: 'List recent Message history entries from a Foldkit runtime, with optional pagination via since_index.',
153
- inputSchema: JSONSchema.make(ListMessagesInput),
156
+ inputSchema: toInputSchema(ListMessagesInput),
154
157
  handle: runRuntimeTool(ListMessagesInput, ({ limit, since_index }) => RequestListMessages({
155
158
  limit: limit ?? DEFAULT_LIST_MESSAGES_LIMIT,
156
- maybeSinceIndex: Option.fromNullable(since_index),
159
+ maybeSinceIndex: Option.fromNullishOr(since_index),
157
160
  }), wsClient),
158
161
  },
159
162
  {
160
163
  name: 'foldkit_get_message',
161
- 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.',
162
- inputSchema: JSONSchema.make(GetMessageInput),
164
+ description: 'Read a single Message history entry by absolute index. The response carries the SerializedEntry (tag, message body, commandNames, mountStartNames, mountEndNames, timestamp, `isModelChanged`, `changedPaths` for leaf-level mutations, `affectedPaths` adding their ancestor paths). `mountStartNames` lists Mounts that fired during the render after this Message; `mountEndNames` lists Mounts whose elements were unmounted during that render. 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),
163
166
  handle: runRuntimeTool(GetMessageInput, ({ index }) => RequestGetMessage({ index }), wsClient),
164
167
  },
165
168
  {
166
169
  name: 'foldkit_get_init',
167
- 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.",
168
- inputSchema: JSONSchema.make(GetInitInput),
170
+ description: "Read the runtime's initial Model, the names of Commands returned from the application's `init` function, and the names of Mounts that fired during the first render. 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. `commandNames` lists init-time Commands in the order they were produced; `mountStartNames` lists Mounts whose elements appeared in the initial render.",
171
+ inputSchema: toInputSchema(GetInitInput),
169
172
  handle: runRuntimeTool(GetInitInput, () => RequestGetInit(), wsClient),
170
173
  },
171
174
  {
172
175
  name: 'foldkit_get_runtime_state',
173
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.",
174
- inputSchema: JSONSchema.make(GetRuntimeStateInput),
177
+ inputSchema: toInputSchema(GetRuntimeStateInput),
175
178
  handle: runRuntimeTool(GetRuntimeStateInput, () => RequestGetRuntimeState(), wsClient),
176
179
  },
177
180
  {
178
181
  name: 'foldkit_list_keyframes',
179
182
  description: 'List the available keyframes (replayable Model snapshots) from a Foldkit runtime.',
180
- inputSchema: JSONSchema.make(ListKeyframesInput),
183
+ inputSchema: toInputSchema(ListKeyframesInput),
181
184
  handle: runRuntimeTool(ListKeyframesInput, () => RequestListKeyframes(), wsClient),
182
185
  },
183
186
  {
184
187
  name: 'foldkit_replay_to_keyframe',
185
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.',
186
- inputSchema: JSONSchema.make(ReplayToKeyframeInput),
189
+ inputSchema: toInputSchema(ReplayToKeyframeInput),
187
190
  handle: runRuntimeTool(ReplayToKeyframeInput, ({ keyframe_index }) => RequestReplayToKeyframe({ keyframeIndex: keyframe_index }), wsClient),
188
191
  },
189
192
  {
190
193
  name: 'foldkit_resume',
191
194
  description: 'Resume normal execution of a Foldkit runtime that was paused by foldkit_replay_to_keyframe.',
192
- inputSchema: JSONSchema.make(ResumeInput),
195
+ inputSchema: toInputSchema(ResumeInput),
193
196
  handle: runRuntimeTool(ResumeInput, () => RequestResume(), wsClient),
194
197
  },
195
198
  {
196
199
  name: 'foldkit_dispatch_message',
197
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.",
198
- inputSchema: JSONSchema.make(DispatchMessageInput),
201
+ inputSchema: toInputSchema(DispatchMessageInput),
199
202
  handle: runRuntimeTool(DispatchMessageInput, ({ message }) => RequestDispatchMessage({ message }), wsClient),
200
203
  },
201
204
  {
@@ -205,6 +208,6 @@ export const buildTools = (wsClient) => [
205
208
  handle: () => Effect.gen(function* () {
206
209
  const response = yield* wsClient.sendRequest(RequestListRuntimes(), Option.none());
207
210
  return responseToToolResult(response);
208
- }).pipe(Effect.catchAll(error => Effect.succeed(formatError(error.message)))),
211
+ }).pipe(Effect.catch(error => Effect.succeed(formatError(error.message)))),
209
212
  },
210
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.2.0",
3
+ "version": "0.4.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.78.0"
32
+ "foldkit": "0.84.0"
33
33
  },
34
34
  "files": [
35
35
  "dist"