vite-vue-mcp-inspect 1.0.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.
@@ -0,0 +1,205 @@
1
+ import { Plugin, ViteDevServer } from "vite";
2
+ import { Hookable } from "hookable";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+
5
+ //#region ../../node_modules/.pnpm/birpc@4.0.0/node_modules/birpc/dist/index.d.mts
6
+ //#endregion
7
+ //#region src/utils.d.ts
8
+ type ArgumentsType<T> = T extends ((...args: infer A) => any) ? A : never;
9
+ type ReturnType<T> = T extends ((...args: any) => infer R) ? R : never;
10
+ //#endregion
11
+ //#region src/main.d.ts
12
+ type PromisifyFn<T> = ReturnType<T> extends Promise<any> ? T : (...args: ArgumentsType<T>) => Promise<Awaited<ReturnType<T>>>;
13
+ type BirpcFn<T> = PromisifyFn<T> & {
14
+ /**
15
+ * Send event without asking for response
16
+ */
17
+ asEvent: (...args: ArgumentsType<T>) => Promise<void>;
18
+ };
19
+ type ProxifiedRemoteFunctions<RemoteFunctions extends object = Record<string, unknown>> = { [K in keyof RemoteFunctions]: BirpcFn<RemoteFunctions[K]> };
20
+ //#endregion
21
+ //#region src/group.d.ts
22
+ interface BirpcGroupReturnBuiltin<RemoteFunctions> {
23
+ /**
24
+ * Call the remote function and wait for the result.
25
+ * An alternative to directly calling the function
26
+ */
27
+ $call: <K$1 extends keyof RemoteFunctions>(method: K$1, ...args: ArgumentsType<RemoteFunctions[K$1]>) => Promise<Awaited<ReturnType<RemoteFunctions[K$1]>>[]>;
28
+ /**
29
+ * Same as `$call`, but returns `undefined` if the function is not defined on the remote side.
30
+ */
31
+ $callOptional: <K$1 extends keyof RemoteFunctions>(method: K$1, ...args: ArgumentsType<RemoteFunctions[K$1]>) => Promise<Awaited<ReturnType<RemoteFunctions[K$1]> | undefined>>;
32
+ /**
33
+ * Send event without asking for response
34
+ */
35
+ $callEvent: <K$1 extends keyof RemoteFunctions>(method: K$1, ...args: ArgumentsType<RemoteFunctions[K$1]>) => Promise<void>;
36
+ /**
37
+ * Call the remote function with the raw options.
38
+ */
39
+ $callRaw: (options: {
40
+ method: string;
41
+ args: unknown[];
42
+ event?: boolean;
43
+ optional?: boolean;
44
+ }) => Promise<Awaited<ReturnType<any>>[]>;
45
+ }
46
+ type BirpcGroupReturn<RemoteFunctions extends object = Record<string, unknown>, Proxify extends boolean = true> = Proxify extends true ? ProxifiedRemoteFunctions<RemoteFunctions> & BirpcGroupReturnBuiltin<RemoteFunctions> : BirpcGroupReturnBuiltin<RemoteFunctions>;
47
+ //#endregion
48
+ //#region src/types.d.ts
49
+ type Awaitable<T> = T | Promise<T>;
50
+ interface IdeMcpConfig {
51
+ /**
52
+ * Enable/disable config writing for this IDE.
53
+ * @default true (only when the IDE directory exists)
54
+ */
55
+ enabled?: boolean;
56
+ /**
57
+ * The MCP server name key in the IDE config.
58
+ * @default 'vue-mcp'
59
+ */
60
+ serverName?: string;
61
+ }
62
+ interface ClaudeDesktopConfig {
63
+ enabled: boolean;
64
+ /**
65
+ * Full path to claude_desktop_config.json.
66
+ * Defaults to the platform-specific location.
67
+ */
68
+ configPath?: string;
69
+ /** @default 'vue-mcp' */
70
+ serverName?: string;
71
+ }
72
+ interface VueMcpOptions {
73
+ /**
74
+ * The hostname to listen on.
75
+ * @default 'localhost'
76
+ */
77
+ host?: string;
78
+ /**
79
+ * Print the MCP server URLs in the console.
80
+ * @default true
81
+ */
82
+ printUrl?: boolean;
83
+ /**
84
+ * Custom MCP server factory. When provided, built-in tools are skipped.
85
+ */
86
+ mcpServer?: (viteServer: ViteDevServer, ctx: VueMcpContext) => Awaitable<McpServer>;
87
+ /**
88
+ * Hook called after the MCP server is created. You can add tools or
89
+ * return a replacement McpServer instance.
90
+ */
91
+ mcpServerSetup?: (server: McpServer, viteServer: ViteDevServer) => Awaitable<void | McpServer>;
92
+ /**
93
+ * Override the MCP server name/version info.
94
+ */
95
+ mcpServerInfo?: {
96
+ name: string;
97
+ version: string;
98
+ };
99
+ /**
100
+ * The path prefix for MCP routes.
101
+ * @default '/__mcp'
102
+ */
103
+ mcpPath?: string;
104
+ /**
105
+ * Timeout in milliseconds before browser-dependent tools return an error.
106
+ * @default 10000
107
+ */
108
+ browserTimeout?: number;
109
+ /**
110
+ * Update `.cursor/mcp.json` with the MCP URL if `.cursor/` exists.
111
+ * @default true
112
+ */
113
+ updateCursorMcpJson?: boolean | IdeMcpConfig;
114
+ /**
115
+ * Update `.windsurf/mcp.json` with the MCP URL if `.windsurf/` exists.
116
+ * @default true
117
+ */
118
+ updateWindsurfMcpJson?: boolean | IdeMcpConfig;
119
+ /**
120
+ * Update `.vscode/mcp.json` with the MCP URL if `.vscode/` exists.
121
+ * @default true
122
+ */
123
+ updateVscodeMcpJson?: boolean | IdeMcpConfig;
124
+ /**
125
+ * Update the Claude Desktop config file with the MCP URL.
126
+ * Opt-in because the config file location is global (not project-local).
127
+ * @default false
128
+ */
129
+ updateClaudeDesktopConfig?: boolean | ClaudeDesktopConfig;
130
+ /**
131
+ * Append an import to the module ending with `appendTo` instead of
132
+ * injecting a <script> tag. Useful for non-HTML entry points.
133
+ *
134
+ * @default undefined (injects via <script> in HTML)
135
+ */
136
+ appendTo?: string | RegExp;
137
+ }
138
+ interface ServerRpcFunctions {
139
+ getInspectorTree(query: {
140
+ event: string;
141
+ }): void;
142
+ getDetailedComponentTree(query: {
143
+ event: string;
144
+ }): void;
145
+ getInspectorState(query: {
146
+ event: string;
147
+ componentName: string;
148
+ }): void;
149
+ editComponentState(query: {
150
+ componentName: string;
151
+ path: string[];
152
+ value: string;
153
+ valueType: string;
154
+ event: string;
155
+ }): void;
156
+ highlightComponent(query: {
157
+ componentName: string;
158
+ event: string;
159
+ }): void;
160
+ scrollToComponent(query: {
161
+ componentName: string;
162
+ event: string;
163
+ }): void;
164
+ getRouterInfo(query: {
165
+ event: string;
166
+ }): void;
167
+ getPiniaTree(query: {
168
+ event: string;
169
+ }): void;
170
+ getPiniaState(query: {
171
+ event: string;
172
+ storeName: string;
173
+ }): void;
174
+ editPiniaState(query: {
175
+ storeName: string;
176
+ path: string[];
177
+ value: string;
178
+ valueType: string;
179
+ event: string;
180
+ }): void;
181
+ navigateToRoute(query: {
182
+ path: string;
183
+ event: string;
184
+ }): void;
185
+ getAppInfo(query: {
186
+ event: string;
187
+ }): void;
188
+ reloadApp(query: {
189
+ event: string;
190
+ }): void;
191
+ getComponentByFile(query: {
192
+ filePath: string;
193
+ event: string;
194
+ }): void;
195
+ }
196
+ interface VueMcpContext {
197
+ hooks: Hookable<Record<string, any>>;
198
+ /** Populated in configureServer. Null before plugin initialises. */
199
+ rpcServer: BirpcGroupReturn<ServerRpcFunctions> | null;
200
+ }
201
+ //#endregion
202
+ //#region src/index.d.ts
203
+ declare function VueMcp(options?: VueMcpOptions): Plugin;
204
+ //#endregion
205
+ export { type ClaudeDesktopConfig, type IdeMcpConfig, type VueMcpContext, type VueMcpOptions, VueMcp as default };
package/dist/index.mjs ADDED
@@ -0,0 +1,691 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import ansis from "ansis";
5
+ import { createRPCServer } from "vite-dev-rpc";
6
+ import { searchForWorkspaceRoot, transformWithOxc } from "vite";
7
+ import { createHooks } from "hookable";
8
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
+ import { z } from "zod";
10
+ import { randomUUID } from "node:crypto";
11
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
12
+ import fs from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ //#region src/context.ts
15
+ function createVueMcpContext() {
16
+ return {
17
+ hooks: createHooks(),
18
+ rpcServer: null
19
+ };
20
+ }
21
+ //#endregion
22
+ //#region src/rpc.ts
23
+ /**
24
+ * Creates the RPC handler object passed to `createRPCServer`.
25
+ *
26
+ * Two parts:
27
+ * - Outgoing stubs (server → browser): no-op functions; the RPC framework
28
+ * serialises the call and sends it to the browser over WebSocket.
29
+ * - Incoming callbacks (browser → server): fire the matching hookable event
30
+ * so the pending MCP tool promise resolves.
31
+ */
32
+ function createServerRpc(ctx) {
33
+ return {
34
+ getInspectorTree: () => {},
35
+ getDetailedComponentTree: () => {},
36
+ getInspectorState: () => {},
37
+ editComponentState: () => {},
38
+ highlightComponent: () => {},
39
+ scrollToComponent: () => {},
40
+ getRouterInfo: () => {},
41
+ getPiniaTree: () => {},
42
+ getPiniaState: () => {},
43
+ editPiniaState: () => {},
44
+ navigateToRoute: () => {},
45
+ getAppInfo: () => {},
46
+ reloadApp: () => {},
47
+ getComponentByFile: () => {},
48
+ onInspectorTreeUpdated(event, data) {
49
+ ctx.hooks.callHook(event, data);
50
+ },
51
+ onDetailedComponentTreeUpdated(event, data) {
52
+ ctx.hooks.callHook(event, data);
53
+ },
54
+ onInspectorStateUpdated(event, data) {
55
+ ctx.hooks.callHook(event, data);
56
+ },
57
+ onEditComponentStateDone(event, result) {
58
+ ctx.hooks.callHook(event, result);
59
+ },
60
+ onHighlightComponentDone(event, result) {
61
+ ctx.hooks.callHook(event, result);
62
+ },
63
+ onScrollToComponentDone(event, result) {
64
+ ctx.hooks.callHook(event, result);
65
+ },
66
+ onRouterInfoUpdated(event, data) {
67
+ ctx.hooks.callHook(event, data);
68
+ },
69
+ onPiniaTreeUpdated(event, data) {
70
+ ctx.hooks.callHook(event, data);
71
+ },
72
+ onPiniaInfoUpdated(event, data) {
73
+ ctx.hooks.callHook(event, data);
74
+ },
75
+ onPiniaStateEditDone(event, result) {
76
+ ctx.hooks.callHook(event, result);
77
+ },
78
+ onNavigateToRouteDone(event, result) {
79
+ ctx.hooks.callHook(event, result);
80
+ },
81
+ onAppInfoUpdated(event, data) {
82
+ ctx.hooks.callHook(event, data);
83
+ },
84
+ onReloadAppDone(event) {
85
+ ctx.hooks.callHook(event, null);
86
+ },
87
+ onComponentByFileUpdated(event, data) {
88
+ ctx.hooks.callHook(event, data);
89
+ }
90
+ };
91
+ }
92
+ //#endregion
93
+ //#region src/utils/timeout.ts
94
+ /**
95
+ * Wraps a one-shot hookable event in a timeout.
96
+ *
97
+ * 1. Registers a `hookOnce` listener for `eventName`.
98
+ * 2. Calls `trigger()` to fire the RPC call that will eventually resolve the hook.
99
+ * 3. Races the hook against a timeout.
100
+ * 4. If the timeout fires first the promise rejects with a user-friendly error.
101
+ * The dangling hook listener cleans itself up when it eventually fires
102
+ * (hookOnce auto-removes after first invocation) — the `settled` flag
103
+ * ensures the resolution is ignored.
104
+ */
105
+ function withBrowserTimeout(hooks, eventName, trigger, timeoutMs, toolName) {
106
+ return new Promise((resolve, reject) => {
107
+ let settled = false;
108
+ const timer = setTimeout(() => {
109
+ if (!settled) {
110
+ settled = true;
111
+ reject(/* @__PURE__ */ new Error(`Tool "${toolName}" timed out after ${timeoutMs}ms.\nMake sure the Vue app is open in a browser tab and @vue/devtools is loaded.`));
112
+ }
113
+ }, timeoutMs);
114
+ hooks.hookOnce(eventName, (data) => {
115
+ if (!settled) {
116
+ settled = true;
117
+ clearTimeout(timer);
118
+ resolve(data);
119
+ }
120
+ });
121
+ try {
122
+ trigger();
123
+ } catch (err) {
124
+ if (!settled) {
125
+ settled = true;
126
+ clearTimeout(timer);
127
+ reject(err);
128
+ }
129
+ }
130
+ });
131
+ }
132
+ //#endregion
133
+ //#region src/server.ts
134
+ function ok(data) {
135
+ return { content: [{
136
+ type: "text",
137
+ text: JSON.stringify(data)
138
+ }] };
139
+ }
140
+ function err(message) {
141
+ return {
142
+ content: [{
143
+ type: "text",
144
+ text: message
145
+ }],
146
+ isError: true
147
+ };
148
+ }
149
+ function errorMessage(e) {
150
+ return e instanceof Error ? e.message : String(e);
151
+ }
152
+ function isErrorResult(r) {
153
+ return r !== null && r !== void 0 && typeof r === "object" && "error" in r;
154
+ }
155
+ function createMcpServer(options, ctx, browserTimeout) {
156
+ const server = new McpServer({
157
+ name: "vue-mcp",
158
+ version: "1.0.0",
159
+ ...options.mcpServerInfo
160
+ });
161
+ let eventId = 0;
162
+ /** Wrap a browser-dependent tool: generates event ID, fires RPC, races timeout. */
163
+ function withTool(toolName, trigger) {
164
+ const event = String(++eventId);
165
+ return withBrowserTimeout(ctx.hooks, event, () => trigger(event), browserTimeout, toolName);
166
+ }
167
+ server.registerTool("get-component-tree", { description: "Get the Vue component tree in a hierarchical JSON structure." }, async () => {
168
+ try {
169
+ return ok(await withTool("get-component-tree", (e) => ctx.rpcServer.getInspectorTree({ event: e })));
170
+ } catch (e) {
171
+ return err(errorMessage(e));
172
+ }
173
+ });
174
+ server.registerTool("get-component-tree-detailed", { description: "Get all Vue components with their names, source file paths, and current state. More comprehensive than get-component-tree but slower." }, async () => {
175
+ try {
176
+ return ok(await withTool("get-component-tree-detailed", (e) => ctx.rpcServer.getDetailedComponentTree({ event: e })));
177
+ } catch (e) {
178
+ return err(errorMessage(e));
179
+ }
180
+ });
181
+ server.registerTool("get-component-state", {
182
+ description: "Get the props, data, computed properties and other state of a specific Vue component.",
183
+ inputSchema: { componentName: z.string().describe("The name of the Vue component, e.g. \"Counter\" or \"App\"") }
184
+ }, async ({ componentName }) => {
185
+ try {
186
+ const result = await withTool("get-component-state", (e) => ctx.rpcServer.getInspectorState({
187
+ event: e,
188
+ componentName
189
+ }));
190
+ if (isErrorResult(result)) return err(result.error);
191
+ return ok(result);
192
+ } catch (e) {
193
+ return err(errorMessage(e));
194
+ }
195
+ });
196
+ server.registerTool("edit-component-state", {
197
+ description: "Edit the state (props/data/computed) of a specific Vue component.",
198
+ inputSchema: {
199
+ componentName: z.string().describe("The name of the Vue component"),
200
+ path: z.array(z.string()).describe("Property path, e.g. [\"count\"] or [\"user\", \"name\"]"),
201
+ value: z.string().describe("New value as a JSON-serialisable string"),
202
+ valueType: z.enum([
203
+ "string",
204
+ "number",
205
+ "boolean",
206
+ "object",
207
+ "array"
208
+ ]).describe("The data type of the new value")
209
+ }
210
+ }, async ({ componentName, path, value, valueType }) => {
211
+ try {
212
+ const result = await withTool("edit-component-state", (e) => ctx.rpcServer.editComponentState({
213
+ componentName,
214
+ path,
215
+ value,
216
+ valueType,
217
+ event: e
218
+ }));
219
+ if (!result.success) return err(result.error ?? "Unknown error");
220
+ return ok({
221
+ success: true,
222
+ componentName,
223
+ path,
224
+ value
225
+ });
226
+ } catch (e) {
227
+ return err(errorMessage(e));
228
+ }
229
+ });
230
+ server.registerTool("highlight-component", {
231
+ description: "Visually highlight a Vue component in the browser for 5 seconds.",
232
+ inputSchema: { componentName: z.string().describe("The name of the Vue component to highlight") }
233
+ }, async ({ componentName }) => {
234
+ try {
235
+ const result = await withTool("highlight-component", (e) => ctx.rpcServer.highlightComponent({
236
+ componentName,
237
+ event: e
238
+ }));
239
+ if (!result.success) return err(result.error ?? "Unknown error");
240
+ return ok({
241
+ success: true,
242
+ componentName
243
+ });
244
+ } catch (e) {
245
+ return err(errorMessage(e));
246
+ }
247
+ });
248
+ server.registerTool("scroll-to-component", {
249
+ description: "Scroll the browser viewport to a Vue component and highlight it.",
250
+ inputSchema: { componentName: z.string().describe("The name of the Vue component to scroll to") }
251
+ }, async ({ componentName }) => {
252
+ try {
253
+ const result = await withTool("scroll-to-component", (e) => ctx.rpcServer.scrollToComponent({
254
+ componentName,
255
+ event: e
256
+ }));
257
+ if (!result.success) return err(result.error ?? "Unknown error");
258
+ return ok({
259
+ success: true,
260
+ componentName
261
+ });
262
+ } catch (e) {
263
+ return err(errorMessage(e));
264
+ }
265
+ });
266
+ server.registerTool("get-router-info", { description: "Get Vue Router information: current route, all defined routes, navigation history." }, async () => {
267
+ try {
268
+ return ok(await withTool("get-router-info", (e) => ctx.rpcServer.getRouterInfo({ event: e })));
269
+ } catch (e) {
270
+ return err(errorMessage(e));
271
+ }
272
+ });
273
+ server.registerTool("navigate-to-route", {
274
+ description: "Programmatically navigate to a Vue Router route in the browser.",
275
+ inputSchema: { path: z.string().describe("Route path to navigate to, e.g. \"/about\" or \"/users/42\"") }
276
+ }, async ({ path }) => {
277
+ try {
278
+ const result = await withTool("navigate-to-route", (e) => ctx.rpcServer.navigateToRoute({
279
+ path,
280
+ event: e
281
+ }));
282
+ if (!result.success) return err(result.error ?? "Unknown error");
283
+ return ok({
284
+ success: true,
285
+ navigatedTo: path
286
+ });
287
+ } catch (e) {
288
+ return err(errorMessage(e));
289
+ }
290
+ });
291
+ server.registerTool("get-pinia-tree", { description: "Get the list of all Pinia stores registered in the application." }, async () => {
292
+ try {
293
+ return ok(await withTool("get-pinia-tree", (e) => ctx.rpcServer.getPiniaTree({ event: e })));
294
+ } catch (e) {
295
+ return err(errorMessage(e));
296
+ }
297
+ });
298
+ server.registerTool("get-pinia-state", {
299
+ description: "Get the current state of a specific Pinia store.",
300
+ inputSchema: { storeName: z.string().describe("The Pinia store ID, e.g. \"counter\" or \"user\"") }
301
+ }, async ({ storeName }) => {
302
+ try {
303
+ const result = await withTool("get-pinia-state", (e) => ctx.rpcServer.getPiniaState({
304
+ event: e,
305
+ storeName
306
+ }));
307
+ if (isErrorResult(result)) return err(result.error);
308
+ return ok(result);
309
+ } catch (e) {
310
+ return err(errorMessage(e));
311
+ }
312
+ });
313
+ server.registerTool("edit-pinia-state", {
314
+ description: "Edit a property in a Pinia store.",
315
+ inputSchema: {
316
+ storeName: z.string().describe("The Pinia store ID, e.g. \"counter\""),
317
+ path: z.array(z.string()).describe("Property path, e.g. [\"count\"] or [\"user\", \"name\"]"),
318
+ value: z.string().describe("New value as a JSON-serialisable string"),
319
+ valueType: z.enum([
320
+ "string",
321
+ "number",
322
+ "boolean",
323
+ "object",
324
+ "array"
325
+ ]).describe("The data type of the new value")
326
+ }
327
+ }, async ({ storeName, path, value, valueType }) => {
328
+ try {
329
+ const result = await withTool("edit-pinia-state", (e) => ctx.rpcServer.editPiniaState({
330
+ storeName,
331
+ path,
332
+ value,
333
+ valueType,
334
+ event: e
335
+ }));
336
+ if (!result.success) return err(result.error ?? "Unknown error");
337
+ return ok({
338
+ success: true,
339
+ storeName,
340
+ path,
341
+ value
342
+ });
343
+ } catch (e) {
344
+ return err(errorMessage(e));
345
+ }
346
+ });
347
+ server.registerTool("get-app-info", { description: "Get general information about the Vue application: version, registered plugins, router state, devtools status." }, async () => {
348
+ try {
349
+ return ok(await withTool("get-app-info", (e) => ctx.rpcServer.getAppInfo({ event: e })));
350
+ } catch (e) {
351
+ return err(errorMessage(e));
352
+ }
353
+ });
354
+ server.registerTool("reload-app", { description: "Trigger a full page reload of the Vue application in the browser." }, async () => {
355
+ try {
356
+ const event = String(++eventId);
357
+ const reloadTimeout = Math.min(browserTimeout, 3e3);
358
+ await withBrowserTimeout(ctx.hooks, event, () => ctx.rpcServer.reloadApp({ event }), reloadTimeout, "reload-app");
359
+ return ok({
360
+ success: true,
361
+ message: "App reload triggered"
362
+ });
363
+ } catch (e) {
364
+ const msg = errorMessage(e);
365
+ if (msg.includes("timed out")) return ok({
366
+ success: true,
367
+ message: "App reload triggered (page reloaded before ack)"
368
+ });
369
+ return err(msg);
370
+ }
371
+ });
372
+ server.registerTool("get-component-by-file", {
373
+ description: "Find a Vue component by its source file path and return its name and current state.",
374
+ inputSchema: { filePath: z.string().describe("Partial or full path to the component source file, e.g. \"Counter.vue\" or \"components/Counter.vue\"") }
375
+ }, async ({ filePath }) => {
376
+ try {
377
+ const result = await withTool("get-component-by-file", (e) => ctx.rpcServer.getComponentByFile({
378
+ filePath,
379
+ event: e
380
+ }));
381
+ if (isErrorResult(result)) return err(result.error);
382
+ return ok(result);
383
+ } catch (e) {
384
+ return err(errorMessage(e));
385
+ }
386
+ });
387
+ return server;
388
+ }
389
+ //#endregion
390
+ //#region src/transport.ts
391
+ /** 1 MB — generous for JSON-RPC MCP messages. */
392
+ const MAX_BODY_SIZE = 1024 * 1024;
393
+ /** Parse the raw body of an IncomingMessage as JSON. */
394
+ async function readJsonBody(req) {
395
+ return new Promise((resolve, reject) => {
396
+ const chunks = [];
397
+ let size = 0;
398
+ req.on("data", (chunk) => {
399
+ size += chunk.length;
400
+ if (size > MAX_BODY_SIZE) {
401
+ req.destroy();
402
+ reject(/* @__PURE__ */ new Error("Request body too large"));
403
+ return;
404
+ }
405
+ chunks.push(chunk);
406
+ });
407
+ req.on("end", () => {
408
+ try {
409
+ const data = Buffer.concat(chunks, size).toString("utf8");
410
+ resolve(data ? JSON.parse(data) : void 0);
411
+ } catch (err) {
412
+ reject(err);
413
+ }
414
+ });
415
+ req.on("error", reject);
416
+ });
417
+ }
418
+ function sendJsonError(res, status, message) {
419
+ res.statusCode = status;
420
+ res.setHeader("Content-Type", "application/json");
421
+ res.end(JSON.stringify({ error: message }));
422
+ }
423
+ /**
424
+ * Register Streamable HTTP transport on the Vite dev server at `{base}/mcp`.
425
+ *
426
+ * Returns a cleanup function to call when the server closes.
427
+ */
428
+ async function setupTransports(base, createServer, vite) {
429
+ const sessions = /* @__PURE__ */ new Map();
430
+ vite.middlewares.use(`${base}/mcp`, async (req, res) => {
431
+ const sRes = res;
432
+ try {
433
+ const sessionId = req.headers["mcp-session-id"];
434
+ if (req.method === "POST") {
435
+ let body;
436
+ try {
437
+ body = await readJsonBody(req);
438
+ } catch (e) {
439
+ const message = e instanceof Error ? e.message : "Invalid JSON body";
440
+ sendJsonError(sRes, message === "Request body too large" ? 413 : 400, message);
441
+ return;
442
+ }
443
+ if (!sessionId) {
444
+ const server = await createServer();
445
+ const transport = new StreamableHTTPServerTransport({
446
+ sessionIdGenerator: () => randomUUID(),
447
+ onsessioninitialized: (id) => {
448
+ sessions.set(id, {
449
+ server,
450
+ transport
451
+ });
452
+ }
453
+ });
454
+ let closing = false;
455
+ transport.onclose = () => {
456
+ if (transport.sessionId) sessions.delete(transport.sessionId);
457
+ if (!closing) {
458
+ closing = true;
459
+ server.close().catch(() => {});
460
+ }
461
+ };
462
+ await server.connect(transport);
463
+ await transport.handleRequest(req, sRes, body);
464
+ return;
465
+ }
466
+ const entry = sessions.get(sessionId);
467
+ if (!entry) {
468
+ sendJsonError(sRes, 404, `Session "${sessionId}" not found`);
469
+ return;
470
+ }
471
+ await entry.transport.handleRequest(req, sRes, body);
472
+ return;
473
+ }
474
+ if (req.method === "GET" || req.method === "DELETE") {
475
+ if (!sessionId) {
476
+ sendJsonError(sRes, 400, "Missing mcp-session-id header");
477
+ return;
478
+ }
479
+ const entry = sessions.get(sessionId);
480
+ if (!entry) {
481
+ sendJsonError(sRes, 404, `Session "${sessionId}" not found`);
482
+ return;
483
+ }
484
+ await entry.transport.handleRequest(req, sRes);
485
+ return;
486
+ }
487
+ sendJsonError(sRes, 405, "Method Not Allowed");
488
+ } catch (err) {
489
+ console.error("[vue-mcp] Streamable HTTP error:", err);
490
+ if (!sRes.headersSent) sendJsonError(sRes, 500, "Internal server error");
491
+ }
492
+ });
493
+ return async () => {
494
+ await Promise.allSettled([...sessions.values()].flatMap((e) => [e.transport.close(), e.server.close()]));
495
+ sessions.clear();
496
+ };
497
+ }
498
+ //#endregion
499
+ //#region src/ide-config.ts
500
+ async function writeJsonConfig(filePath, update) {
501
+ let json = {};
502
+ if (existsSync(filePath)) try {
503
+ const raw = await fs.readFile(filePath, "utf-8");
504
+ json = JSON.parse(raw || "{}");
505
+ } catch {}
506
+ update(json);
507
+ await fs.writeFile(filePath, `${JSON.stringify(json, null, 2)}\n`);
508
+ }
509
+ async function updateIdeConfigs(opts) {
510
+ const { root, mcpUrl, cursor, windsurf, vscode, claudeDesktop } = opts;
511
+ if (cursor) {
512
+ const cursorDir = path.join(root, ".cursor");
513
+ if (existsSync(cursorDir)) {
514
+ const configPath = path.join(cursorDir, "mcp.json");
515
+ try {
516
+ await writeJsonConfig(configPath, (json) => {
517
+ json.mcpServers ??= {};
518
+ json.mcpServers[cursor.serverName] = { url: mcpUrl };
519
+ });
520
+ } catch (err) {
521
+ console.warn(`[vue-mcp] Failed to update .cursor/mcp.json: ${err}`);
522
+ }
523
+ }
524
+ }
525
+ if (windsurf) {
526
+ const windsurfDir = path.join(root, ".windsurf");
527
+ if (existsSync(windsurfDir)) {
528
+ const configPath = path.join(windsurfDir, "mcp.json");
529
+ try {
530
+ await writeJsonConfig(configPath, (json) => {
531
+ json.mcpServers ??= {};
532
+ json.mcpServers[windsurf.serverName] = { serverUrl: mcpUrl };
533
+ });
534
+ } catch (err) {
535
+ console.warn(`[vue-mcp] Failed to update .windsurf/mcp.json: ${err}`);
536
+ }
537
+ }
538
+ }
539
+ if (vscode) {
540
+ const vscodeDir = path.join(root, ".vscode");
541
+ if (existsSync(vscodeDir)) {
542
+ const configPath = path.join(vscodeDir, "mcp.json");
543
+ try {
544
+ await writeJsonConfig(configPath, (json) => {
545
+ json.servers ??= {};
546
+ json.servers[vscode.serverName] = {
547
+ type: "http",
548
+ url: mcpUrl
549
+ };
550
+ });
551
+ } catch (err) {
552
+ console.warn(`[vue-mcp] Failed to update .vscode/mcp.json: ${err}`);
553
+ }
554
+ }
555
+ }
556
+ if (claudeDesktop) {
557
+ const configPath = claudeDesktop.configPath || getClaudeDesktopConfigPath();
558
+ if (configPath) try {
559
+ await writeJsonConfig(configPath, (json) => {
560
+ json.mcpServers ??= {};
561
+ json.mcpServers[claudeDesktop.serverName] = {
562
+ type: "streamablehttp",
563
+ url: mcpUrl
564
+ };
565
+ });
566
+ } catch (err) {
567
+ console.warn(`[vue-mcp] Failed to update Claude Desktop config: ${err}`);
568
+ }
569
+ }
570
+ }
571
+ function getClaudeDesktopConfigPath() {
572
+ const platform = process.platform;
573
+ if (platform === "darwin") return path.join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
574
+ if (platform === "win32") {
575
+ const appData = process.env.APPDATA;
576
+ if (appData) return path.join(appData, "Claude", "claude_desktop_config.json");
577
+ }
578
+ return null;
579
+ }
580
+ //#endregion
581
+ //#region src/index.ts
582
+ const _dirname = fileURLToPath(new URL(".", import.meta.url));
583
+ const VIRTUAL_MODULE_ID = "virtual:vue-mcp-overlay";
584
+ const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_MODULE_ID}`;
585
+ function resolveIdeMcpConfig(opt, defaultEnabled) {
586
+ if (opt === false) return false;
587
+ if (opt === true || opt === void 0) return defaultEnabled ? { serverName: "vue-mcp" } : false;
588
+ if (opt.enabled === false) return false;
589
+ return { serverName: opt.serverName ?? "vue-mcp" };
590
+ }
591
+ function resolveClaudeDesktopConfig(opt) {
592
+ if (!opt) return false;
593
+ if (opt === true) return {
594
+ serverName: "vue-mcp",
595
+ configPath: ""
596
+ };
597
+ if (opt.enabled === false) return false;
598
+ return {
599
+ serverName: opt.serverName ?? "vue-mcp",
600
+ configPath: opt.configPath ?? ""
601
+ };
602
+ }
603
+ function VueMcp(options = {}) {
604
+ const { host: _host, printUrl = true, mcpPath = "/__mcp", browserTimeout = 1e4, appendTo } = options;
605
+ const ctx = createVueMcpContext();
606
+ let config;
607
+ let overlaySource = null;
608
+ return {
609
+ name: "vite-vue-mcp-inspect",
610
+ apply: "serve",
611
+ configResolved(resolvedConfig) {
612
+ config = resolvedConfig;
613
+ },
614
+ resolveId(id, importer) {
615
+ if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_ID;
616
+ if (importer === RESOLVED_VIRTUAL_ID) return this.resolve(id, path.resolve(_dirname, "_overlay_importer.js"), { skipSelf: true });
617
+ },
618
+ async load(id) {
619
+ if (id === RESOLVED_VIRTUAL_ID) {
620
+ if (!overlaySource) {
621
+ const overlayPath = path.resolve(_dirname, "../src/client/overlay.ts");
622
+ const altPath = path.resolve(_dirname, "client/overlay.ts");
623
+ overlaySource = readFileSync(existsSync(overlayPath) ? overlayPath : altPath, "utf-8");
624
+ }
625
+ return (await transformWithOxc(overlaySource, "overlay.ts")).code;
626
+ }
627
+ },
628
+ transformIndexHtml() {
629
+ if (appendTo) return [];
630
+ return [{
631
+ tag: "script",
632
+ attrs: {
633
+ type: "module",
634
+ src: "/@id/__x00__virtual:vue-mcp-overlay"
635
+ },
636
+ injectTo: "head-prepend"
637
+ }];
638
+ },
639
+ transform(code, id) {
640
+ if (!appendTo) return;
641
+ if ((appendTo instanceof RegExp ? appendTo : new RegExp(`${appendTo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)).test(id)) return {
642
+ code: `${code}\nimport "${VIRTUAL_MODULE_ID}"`,
643
+ map: null
644
+ };
645
+ },
646
+ async configureServer(vite) {
647
+ const rpcHandlers = createServerRpc(ctx);
648
+ ctx.rpcServer = createRPCServer("vite-vue-mcp-inspect", vite.ws, rpcHandlers, { timeout: -1 });
649
+ const createServer = async () => {
650
+ let mcpServer = options.mcpServer ? await options.mcpServer(vite, ctx) : createMcpServer(options, ctx, browserTimeout);
651
+ if (options.mcpServerSetup) {
652
+ const replacement = await options.mcpServerSetup(mcpServer, vite);
653
+ if (replacement) mcpServer = replacement;
654
+ }
655
+ return mcpServer;
656
+ };
657
+ const cleanup = await setupTransports(mcpPath, createServer, vite);
658
+ vite.httpServer?.on("close", () => {
659
+ cleanup().catch(() => {});
660
+ });
661
+ const resolvedHost = _host ?? "localhost";
662
+ const address = vite.httpServer?.address();
663
+ const port = typeof address === "object" && address ? address.port : 5173;
664
+ const mcpUrl = `http://${resolvedHost}:${port}${mcpPath}/mcp`;
665
+ const ideOpts = {
666
+ root: searchForWorkspaceRoot(config.root),
667
+ mcpUrl,
668
+ cursor: resolveIdeMcpConfig(options.updateCursorMcpJson, true),
669
+ windsurf: resolveIdeMcpConfig(options.updateWindsurfMcpJson, true),
670
+ vscode: resolveIdeMcpConfig(options.updateVscodeMcpJson, true),
671
+ claudeDesktop: resolveClaudeDesktopConfig(options.updateClaudeDesktopConfig)
672
+ };
673
+ if (vite.httpServer) vite.httpServer.once("listening", () => {
674
+ const actualAddress = vite.httpServer?.address();
675
+ const actualMcpUrl = `http://${resolvedHost}:${typeof actualAddress === "object" && actualAddress ? actualAddress.port : port}${mcpPath}/mcp`;
676
+ if (printUrl) setTimeout(() => {
677
+ console.log(` ${ansis.green("➜")} ${ansis.bold("MCP")}: ${ansis.cyan(actualMcpUrl)}`);
678
+ }, 0);
679
+ ideOpts.mcpUrl = actualMcpUrl;
680
+ updateIdeConfigs(ideOpts).catch((err) => {
681
+ console.warn(`[vue-mcp] IDE config update failed: ${err}`);
682
+ });
683
+ });
684
+ else updateIdeConfigs(ideOpts).catch((err) => {
685
+ console.warn(`[vue-mcp] IDE config update failed: ${err}`);
686
+ });
687
+ }
688
+ };
689
+ }
690
+ //#endregion
691
+ export { VueMcp as default };
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "vite-vue-mcp-inspect",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "description": "Vite plugin that turns a running Vue app into an MCP server",
6
+ "keywords": [
7
+ "mcp",
8
+ "model-context-protocol",
9
+ "vue",
10
+ "vite",
11
+ "vite-plugin",
12
+ "devtools",
13
+ "ai"
14
+ ],
15
+ "author": "Robonen Andrew <robonenandrew@gmail.com>",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/robonen/vue-mcp.git",
19
+ "directory": "packages/vite-vue-mcp-inspect"
20
+ },
21
+ "engines": {
22
+ "node": ">=20.0.0"
23
+ },
24
+ "type": "module",
25
+ "sideEffects": false,
26
+ "files": [
27
+ "dist",
28
+ "src/client"
29
+ ],
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.mts",
33
+ "import": "./dist/index.mjs"
34
+ }
35
+ },
36
+ "module": "./dist/index.mjs",
37
+ "types": "./dist/index.d.mts",
38
+ "peerDependencies": {
39
+ "vite": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
40
+ },
41
+ "dependencies": {
42
+ "@modelcontextprotocol/sdk": "^1.29.0",
43
+ "@vue/devtools-kit": "^8.1.1",
44
+ "ansis": "^4.2.0",
45
+ "hookable": "^6.1.0",
46
+ "vite-dev-rpc": "^1.1.0",
47
+ "vite-hot-client": "^2.1.0",
48
+ "zod": "^3.24.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^22.0.0",
52
+ "birpc": "^4.0.0",
53
+ "playwright": "^1.52.0",
54
+ "tsdown": "^0.21.7",
55
+ "vite": "^8.0.3",
56
+ "vitest": "^3.2.1"
57
+ },
58
+ "scripts": {
59
+ "test": "vitest run",
60
+ "test:unit": "vitest run --project unit",
61
+ "test:browser": "vitest run --project browser",
62
+ "typecheck": "tsc --noEmit",
63
+ "build": "tsdown",
64
+ "dev": "tsdown --watch"
65
+ }
66
+ }
@@ -0,0 +1,419 @@
1
+ import {
2
+ activeAppRecord,
3
+ devtools,
4
+ devtoolsRouterInfo,
5
+ devtoolsState,
6
+ getInspector,
7
+ stringify,
8
+ toggleHighPerfMode,
9
+ } from '@vue/devtools-kit'
10
+ import { createRPCClient } from 'vite-dev-rpc'
11
+ import { createHotContext } from 'vite-hot-client'
12
+
13
+ const base = (import.meta as any).env?.BASE_URL ?? '/'
14
+ const hot = createHotContext('', base)
15
+
16
+ const COMPONENTS_INSPECTOR_ID = 'components'
17
+ const PINIA_INSPECTOR_ID = 'pinia'
18
+
19
+ devtools.init()
20
+
21
+ // ── Helpers ──────────────────────────────────────────────────────────────
22
+
23
+ function flattenChildren(node: any): any[] {
24
+ const result: any[] = []
25
+ function traverse(n: any): void {
26
+ if (!n) return
27
+ result.push(n)
28
+ if (Array.isArray(n.children)) n.children.forEach(traverse)
29
+ }
30
+ traverse(node)
31
+ return result
32
+ }
33
+
34
+ function findNode(tree: any, name: string): { node: any; error?: never } | { node?: never; error: string } {
35
+ const all = flattenChildren(tree)
36
+ const node = all.find((n: any) => n.name === name)
37
+ if (!node) {
38
+ const available = [...new Set(all.map((n: any) => n.name))].slice(0, 20).join(', ')
39
+ return { error: `Component "${name}" not found.\nAvailable: ${available}` }
40
+ }
41
+ return { node }
42
+ }
43
+
44
+ let highlightTimer: ReturnType<typeof setTimeout> | null = null
45
+
46
+ /** Fetch the component tree, find a node by name, and call the callback with it. */
47
+ async function withComponentNode<S, E>(
48
+ name: string,
49
+ onSuccess: (node: any) => Promise<S> | S,
50
+ onError: (error: string) => E,
51
+ ): Promise<S | E> {
52
+ const tree = await devtools.api.getInspectorTree({
53
+ inspectorId: COMPONENTS_INSPECTOR_ID,
54
+ filter: '',
55
+ })
56
+ const { node, error } = findNode(tree[0], name)
57
+ if (error) return onError(error)
58
+ return onSuccess(node)
59
+ }
60
+
61
+ /** Highlight a component node for 5 seconds, clearing any previous highlight timer. */
62
+ function setHighlight(nodeId: string): void {
63
+ if (highlightTimer) clearTimeout(highlightTimer)
64
+ ;(devtools as any).ctx.hooks.callHook('componentHighlight', { uid: nodeId })
65
+ highlightTimer = setTimeout(() => {
66
+ ;(devtools as any).ctx.hooks.callHook('componentUnhighlight')
67
+ }, 5000)
68
+ }
69
+
70
+ /** Run async tasks in batches of `concurrency` to avoid freezing the browser. */
71
+ async function mapConcurrent<T, R>(
72
+ items: T[],
73
+ concurrency: number,
74
+ fn: (item: T) => Promise<R>,
75
+ ): Promise<R[]> {
76
+ const results: R[] = []
77
+ for (let i = 0; i < items.length; i += concurrency) {
78
+ const batch = items.slice(i, i + concurrency)
79
+ results.push(...await Promise.all(batch.map(fn)))
80
+ }
81
+ return results
82
+ }
83
+
84
+ // ── RPC client ───────────────────────────────────────────────────────────
85
+
86
+ const rpc = createRPCClient<any, any>(
87
+ 'vite-vue-mcp-inspect',
88
+ hot,
89
+ {
90
+ // ── Component tree ──────────────────────────────────────────────────
91
+ async getInspectorTree(query: { event: string }) {
92
+ try {
93
+ const tree = await devtools.api.getInspectorTree({
94
+ inspectorId: COMPONENTS_INSPECTOR_ID,
95
+ filter: '',
96
+ })
97
+ rpc.onInspectorTreeUpdated(query.event, tree[0])
98
+ }
99
+ catch (err) {
100
+ rpc.onInspectorTreeUpdated(query.event, { error: String(err) })
101
+ }
102
+ },
103
+
104
+ async getDetailedComponentTree(query: { event: string }) {
105
+ try {
106
+ const tree = await devtools.api.getInspectorTree({
107
+ inspectorId: COMPONENTS_INSPECTOR_ID,
108
+ filter: '',
109
+ })
110
+ const all = flattenChildren(tree[0])
111
+ const detailed = await mapConcurrent(all, 10, async (node: any) => {
112
+ try {
113
+ const state = await devtools.api.getInspectorState({
114
+ inspectorId: COMPONENTS_INSPECTOR_ID,
115
+ nodeId: node.id,
116
+ })
117
+ return { name: node.name, id: node.id, file: node.file, state: stringify(state) }
118
+ }
119
+ catch {
120
+ return { name: node.name, id: node.id, file: node.file }
121
+ }
122
+ })
123
+ rpc.onDetailedComponentTreeUpdated(query.event, detailed)
124
+ }
125
+ catch (err) {
126
+ rpc.onDetailedComponentTreeUpdated(query.event, { error: String(err) })
127
+ }
128
+ },
129
+
130
+ // ── Component state ─────────────────────────────────────────────────
131
+ async getInspectorState(query: { event: string; componentName: string }) {
132
+ try {
133
+ const result = await withComponentNode(
134
+ query.componentName,
135
+ async (node) => {
136
+ const state = await devtools.api.getInspectorState({
137
+ inspectorId: COMPONENTS_INSPECTOR_ID,
138
+ nodeId: node.id,
139
+ })
140
+ return stringify(state)
141
+ },
142
+ (error) => ({ error }),
143
+ )
144
+ rpc.onInspectorStateUpdated(query.event, result)
145
+ }
146
+ catch (err) {
147
+ rpc.onInspectorStateUpdated(query.event, { error: String(err) })
148
+ }
149
+ },
150
+
151
+ // ── Edit component state ────────────────────────────────────────────
152
+ async editComponentState(query: {
153
+ componentName: string
154
+ path: string[]
155
+ value: string
156
+ valueType: string
157
+ event: string
158
+ }) {
159
+ try {
160
+ const result = await withComponentNode(
161
+ query.componentName,
162
+ async (node) => {
163
+ await (devtools as any).ctx.api.editInspectorState({
164
+ inspectorId: COMPONENTS_INSPECTOR_ID,
165
+ nodeId: node.id,
166
+ path: query.path,
167
+ state: {
168
+ new: null,
169
+ remove: false,
170
+ type: query.valueType,
171
+ value: query.value,
172
+ },
173
+ type: undefined,
174
+ })
175
+ return { success: true as const }
176
+ },
177
+ (error) => ({ success: false as const, error }),
178
+ )
179
+ rpc.onEditComponentStateDone(query.event, result)
180
+ }
181
+ catch (err) {
182
+ rpc.onEditComponentStateDone(query.event, { success: false, error: String(err) })
183
+ }
184
+ },
185
+
186
+ // ── Highlight component ─────────────────────────────────────────────
187
+ async highlightComponent(query: { componentName: string; event: string }) {
188
+ try {
189
+ const result = await withComponentNode(
190
+ query.componentName,
191
+ (node) => {
192
+ setHighlight(node.id)
193
+ return { success: true as const }
194
+ },
195
+ (error) => ({ success: false as const, error }),
196
+ )
197
+ rpc.onHighlightComponentDone(query.event, result)
198
+ }
199
+ catch (err) {
200
+ rpc.onHighlightComponentDone(query.event, { success: false, error: String(err) })
201
+ }
202
+ },
203
+
204
+ // ── Scroll to component ─────────────────────────────────────────────
205
+ async scrollToComponent(query: { componentName: string; event: string }) {
206
+ try {
207
+ const result = await withComponentNode(
208
+ query.componentName,
209
+ async (node) => {
210
+ setHighlight(node.id)
211
+ // Wait for the overlay to paint
212
+ await new Promise(r => setTimeout(r, 50))
213
+ // Scroll to the highlight overlay if it exists in the DOM
214
+ const overlay = document.querySelector<HTMLElement>('.__vue-devtools-component-inspector__')
215
+ ?? document.querySelector<HTMLElement>('[data-vue-devtools-component-inspector]')
216
+ if (overlay) {
217
+ overlay.scrollIntoView({ behavior: 'smooth', block: 'center' })
218
+ }
219
+ return { success: true as const }
220
+ },
221
+ (error) => ({ success: false as const, error }),
222
+ )
223
+ rpc.onScrollToComponentDone(query.event, result)
224
+ }
225
+ catch (err) {
226
+ rpc.onScrollToComponentDone(query.event, { success: false, error: String(err) })
227
+ }
228
+ },
229
+
230
+ // ── Router info ─────────────────────────────────────────────────────
231
+ async getRouterInfo(query: { event: string }) {
232
+ try {
233
+ rpc.onRouterInfoUpdated(query.event, JSON.parse(JSON.stringify(devtoolsRouterInfo)))
234
+ }
235
+ catch (err) {
236
+ rpc.onRouterInfoUpdated(query.event, { error: String(err) })
237
+ }
238
+ },
239
+
240
+ // ── Navigate to route ────────────────────────────────────────────────
241
+ async navigateToRoute(query: { path: string; event: string }) {
242
+ try {
243
+ // Access the Vue Router instance via the app's globalProperties
244
+ const router = activeAppRecord.value?.app?.config?.globalProperties?.$router
245
+ if (!router) {
246
+ rpc.onNavigateToRouteDone(query.event, {
247
+ success: false,
248
+ error: 'Vue Router not detected. Make sure vue-router is installed and configured.',
249
+ })
250
+ return
251
+ }
252
+ await router.push(query.path)
253
+ rpc.onNavigateToRouteDone(query.event, { success: true })
254
+ }
255
+ catch (err) {
256
+ rpc.onNavigateToRouteDone(query.event, { success: false, error: String(err) })
257
+ }
258
+ },
259
+
260
+ // ── Pinia tree ──────────────────────────────────────────────────────
261
+ async getPiniaTree(query: { event: string }) {
262
+ const wasHighPerf = devtoolsState.highPerfModeEnabled
263
+ if (wasHighPerf) toggleHighPerfMode(false)
264
+ try {
265
+ const tree = await devtools.api.getInspectorTree({
266
+ inspectorId: PINIA_INSPECTOR_ID,
267
+ filter: '',
268
+ })
269
+ rpc.onPiniaTreeUpdated(query.event, tree)
270
+ }
271
+ catch (err) {
272
+ rpc.onPiniaTreeUpdated(query.event, { error: String(err) })
273
+ }
274
+ finally {
275
+ if (wasHighPerf) toggleHighPerfMode(true)
276
+ }
277
+ },
278
+
279
+ // ── Pinia state ─────────────────────────────────────────────────────
280
+ async getPiniaState(query: { event: string; storeName: string }) {
281
+ const wasHighPerf = devtoolsState.highPerfModeEnabled
282
+ if (wasHighPerf) toggleHighPerfMode(false)
283
+ try {
284
+ const inspector = getInspector(PINIA_INSPECTOR_ID)
285
+ const prevNodeId = inspector?.selectedNodeId
286
+ try {
287
+ if (inspector) inspector.selectedNodeId = query.storeName
288
+ const state = await (devtools as any).ctx.api.getInspectorState({
289
+ inspectorId: PINIA_INSPECTOR_ID,
290
+ nodeId: query.storeName,
291
+ })
292
+ rpc.onPiniaInfoUpdated(query.event, stringify(state))
293
+ }
294
+ finally {
295
+ if (inspector && prevNodeId !== undefined) inspector.selectedNodeId = prevNodeId
296
+ }
297
+ }
298
+ catch (err) {
299
+ rpc.onPiniaInfoUpdated(query.event, { error: String(err) })
300
+ }
301
+ finally {
302
+ if (wasHighPerf) toggleHighPerfMode(true)
303
+ }
304
+ },
305
+
306
+ // ── Edit Pinia state ────────────────────────────────────────────────
307
+ async editPiniaState(query: {
308
+ storeName: string
309
+ path: string[]
310
+ value: string
311
+ valueType: string
312
+ event: string
313
+ }) {
314
+ const wasHighPerf = devtoolsState.highPerfModeEnabled
315
+ if (wasHighPerf) toggleHighPerfMode(false)
316
+ try {
317
+ const inspector = getInspector(PINIA_INSPECTOR_ID)
318
+ if (!inspector) {
319
+ rpc.onPiniaStateEditDone(query.event, { success: false, error: 'Pinia inspector not found' })
320
+ return
321
+ }
322
+ const prevNodeId = inspector.selectedNodeId
323
+ try {
324
+ inspector.selectedNodeId = query.storeName
325
+ await (devtools as any).ctx.api.editInspectorState({
326
+ inspectorId: PINIA_INSPECTOR_ID,
327
+ nodeId: query.storeName,
328
+ path: query.path,
329
+ state: {
330
+ new: null,
331
+ remove: false,
332
+ type: query.valueType,
333
+ value: query.value,
334
+ },
335
+ type: undefined,
336
+ })
337
+ rpc.onPiniaStateEditDone(query.event, { success: true })
338
+ }
339
+ finally {
340
+ if (prevNodeId !== undefined) inspector.selectedNodeId = prevNodeId
341
+ }
342
+ }
343
+ catch (err) {
344
+ rpc.onPiniaStateEditDone(query.event, { success: false, error: String(err) })
345
+ }
346
+ finally {
347
+ if (wasHighPerf) toggleHighPerfMode(true)
348
+ }
349
+ },
350
+
351
+ // ── App info ────────────────────────────────────────────────────────
352
+ async getAppInfo(query: { event: string }) {
353
+ try {
354
+ const appRecord = activeAppRecord.value
355
+ const vueApp = appRecord?.app
356
+ const info = {
357
+ vueVersion: vueApp?.version ?? 'unknown',
358
+ plugins: Object.keys(vueApp?.config?.globalProperties ?? {}).filter((k: string) => !k.startsWith('__')),
359
+ devtoolsState: {
360
+ connected: devtoolsState.connected,
361
+ vitePluginDetected: devtoolsState.vitePluginDetected,
362
+ highPerfModeEnabled: devtoolsState.highPerfModeEnabled,
363
+ },
364
+ router: devtoolsRouterInfo
365
+ ? {
366
+ currentRoute: devtoolsRouterInfo.currentRoute,
367
+ routesCount: devtoolsRouterInfo.routes?.length ?? 0,
368
+ }
369
+ : null,
370
+ }
371
+ rpc.onAppInfoUpdated(query.event, info)
372
+ }
373
+ catch (err) {
374
+ rpc.onAppInfoUpdated(query.event, { error: String(err) })
375
+ }
376
+ },
377
+
378
+ // ── Reload app ──────────────────────────────────────────────────────
379
+ async reloadApp(query: { event: string }) {
380
+ // Fire the ack BEFORE reloading so the server receives it
381
+ rpc.onReloadAppDone(query.event)
382
+ await new Promise(r => setTimeout(r, 50))
383
+ location.reload()
384
+ },
385
+
386
+ // ── Get component by file ───────────────────────────────────────────
387
+ async getComponentByFile(query: { filePath: string; event: string }) {
388
+ try {
389
+ const tree = await devtools.api.getInspectorTree({
390
+ inspectorId: COMPONENTS_INSPECTOR_ID,
391
+ filter: '',
392
+ })
393
+ const all = flattenChildren(tree[0])
394
+ const match = all.find((n: any) => n.file?.endsWith(query.filePath))
395
+ if (!match) {
396
+ rpc.onComponentByFileUpdated(query.event, {
397
+ found: false,
398
+ error: `No component found with file path ending in "${query.filePath}"`,
399
+ })
400
+ return
401
+ }
402
+ const state = await devtools.api.getInspectorState({
403
+ inspectorId: COMPONENTS_INSPECTOR_ID,
404
+ nodeId: match.id,
405
+ })
406
+ rpc.onComponentByFileUpdated(query.event, {
407
+ found: true,
408
+ name: match.name,
409
+ file: match.file,
410
+ state: stringify(state),
411
+ })
412
+ }
413
+ catch (err) {
414
+ rpc.onComponentByFileUpdated(query.event, { found: false, error: String(err) })
415
+ }
416
+ },
417
+ },
418
+ { timeout: -1 },
419
+ )