@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 +18 -5
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +40 -37
- package/dist/webSocketClient.d.ts +22 -14
- package/dist/webSocketClient.d.ts.map +1 -1
- package/dist/webSocketClient.js +43 -40
- package/package.json +4 -4
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
|
|
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.
|
|
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 =>
|
|
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*
|
|
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).
|
|
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
|
});
|
package/dist/tools.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,MAAM,
|
|
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,
|
|
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.
|
|
6
|
-
const ListLimit = S.
|
|
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.
|
|
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.
|
|
11
|
+
});
|
|
12
|
+
const MessageIndex = S.Int.annotate({
|
|
13
13
|
description: 'Absolute history index of the entry to read.',
|
|
14
|
-
})
|
|
15
|
-
const KeyframeIndex = S.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
71
|
-
* `
|
|
72
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
137
|
+
inputSchema: toInputSchema(GetModelInput),
|
|
135
138
|
handle: runRuntimeTool(GetModelInput, ({ path, expand }) => RequestGetModel({
|
|
136
|
-
maybePath: Option.
|
|
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:
|
|
146
|
+
inputSchema: toInputSchema(GetModelAtInput),
|
|
144
147
|
handle: runRuntimeTool(GetModelAtInput, ({ index, path, expand }) => RequestGetModelAt({
|
|
145
148
|
index,
|
|
146
|
-
maybePath: Option.
|
|
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:
|
|
156
|
+
inputSchema: toInputSchema(ListMessagesInput),
|
|
154
157
|
handle: runRuntimeTool(ListMessagesInput, ({ limit, since_index }) => RequestListMessages({
|
|
155
158
|
limit: limit ?? DEFAULT_LIST_MESSAGES_LIMIT,
|
|
156
|
-
maybeSinceIndex: Option.
|
|
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:
|
|
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
|
|
168
|
-
inputSchema:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
|
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
|
|
9
|
-
*
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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,
|
|
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"}
|
package/dist/webSocketClient.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import { Deferred, Duration, 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
|
-
|
|
9
|
-
const
|
|
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.
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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(
|
|
59
|
+
const currentSocketRef = yield* Ref.make(Option.none());
|
|
62
60
|
const isManuallyClosedRef = yield* Ref.make(false);
|
|
63
|
-
const
|
|
61
|
+
const capturedContext = yield* Effect.context();
|
|
64
62
|
const attachMessageHandler = (socket) => {
|
|
65
63
|
socket.on('message', raw => {
|
|
66
|
-
|
|
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
|
-
|
|
73
|
-
const
|
|
74
|
-
const socket = yield*
|
|
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*
|
|
81
|
-
|
|
82
|
-
yield*
|
|
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
|
|
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(
|
|
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
|
|
113
|
-
yield*
|
|
114
|
-
|
|
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.
|
|
120
|
-
return
|
|
121
|
-
|
|
122
|
-
|
|
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.
|
|
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": "
|
|
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": "
|
|
29
|
+
"effect": "4.0.0-beta.59",
|
|
30
30
|
"rimraf": "^6.0.0",
|
|
31
31
|
"typescript": "^6.0.2",
|
|
32
|
-
"foldkit": "0.
|
|
32
|
+
"foldkit": "0.84.0"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"dist"
|