@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 +26 -13
- package/dist/server.js +18 -5
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +80 -29
- 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/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
|
-
-
|
|
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
|
|
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
|
-
| `
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
75
|
-
| `
|
|
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
|
|
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,22 +1,39 @@
|
|
|
1
|
-
import { Array, 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.
|
|
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.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.
|
|
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
|
-
*
|
|
48
|
-
* `
|
|
49
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
111
|
-
inputSchema:
|
|
112
|
-
handle: runRuntimeTool(GetModelInput, () => RequestGetModel(
|
|
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:
|
|
156
|
+
inputSchema: toInputSchema(ListMessagesInput),
|
|
118
157
|
handle: runRuntimeTool(ListMessagesInput, ({ limit, since_index }) => RequestListMessages({
|
|
119
158
|
limit: limit ?? DEFAULT_LIST_MESSAGES_LIMIT,
|
|
120
|
-
maybeSinceIndex: Option.
|
|
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,
|
|
126
|
-
inputSchema:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
|
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.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": "
|
|
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.82.0"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"dist"
|