From 2c098d280dcdf2839cf452e3765ca69e4d239014 Mon Sep 17 00:00:00 2001 From: Ivanruii Date: Tue, 28 Apr 2026 14:09:44 +0200 Subject: [PATCH 1/2] refactor(vscode-extension): restructure mcp module --- .../vscode-extension/src/commands/index.ts | 2 + .../src/commands/new-wireframe.ts | 11 ++ .../vscode-extension/src/commands/register.ts | 10 ++ packages/vscode-extension/src/core/index.ts | 1 + .../vscode-extension/src/core/workspace.ts | 5 + packages/vscode-extension/src/index.ts | 29 +---- .../src/mcp/document-bridge/constants.ts | 2 + .../src/mcp/document-bridge/index.ts | 1 + .../src/mcp/document-bridge/server.ts | 93 ++++++++++++++++ .../external-clients/clients/claude-code.ts | 8 ++ .../src/mcp/external-clients/clients/index.ts | 4 + .../src/mcp/external-clients/index.ts | 2 + .../src/mcp/external-clients/model.ts | 15 +++ .../src/mcp/external-clients/register.ts | 65 ++++++++++++ packages/vscode-extension/src/mcp/index.ts | 10 +- .../src/mcp/invocation/constants.ts | 2 + .../src/mcp/invocation/index.ts | 3 + .../invocation.ts} | 19 ++-- .../src/mcp/invocation/model.ts | 4 + .../src/mcp/mcp-client-targets.ts | 62 ----------- .../src/mcp/mcp-config-file.ts | 26 ----- .../src/mcp/mcp-registration.ts | 100 ------------------ .../src/mcp/registry-server.ts | 96 ----------------- .../src/mcp/server-definition-provider.ts | 90 ---------------- packages/vscode-extension/src/mcp/setup.ts | 23 ++++ .../src/mcp/vscode-provider/definition.ts | 33 ++++++ .../src/mcp/vscode-provider/index.ts | 1 + .../src/mcp/vscode-provider/provider.ts | 57 ++++++++++ 28 files changed, 359 insertions(+), 415 deletions(-) create mode 100644 packages/vscode-extension/src/commands/index.ts create mode 100644 packages/vscode-extension/src/commands/new-wireframe.ts create mode 100644 packages/vscode-extension/src/commands/register.ts create mode 100644 packages/vscode-extension/src/core/workspace.ts create mode 100644 packages/vscode-extension/src/mcp/document-bridge/constants.ts create mode 100644 packages/vscode-extension/src/mcp/document-bridge/index.ts create mode 100644 packages/vscode-extension/src/mcp/document-bridge/server.ts create mode 100644 packages/vscode-extension/src/mcp/external-clients/clients/claude-code.ts create mode 100644 packages/vscode-extension/src/mcp/external-clients/clients/index.ts create mode 100644 packages/vscode-extension/src/mcp/external-clients/index.ts create mode 100644 packages/vscode-extension/src/mcp/external-clients/model.ts create mode 100644 packages/vscode-extension/src/mcp/external-clients/register.ts create mode 100644 packages/vscode-extension/src/mcp/invocation/constants.ts create mode 100644 packages/vscode-extension/src/mcp/invocation/index.ts rename packages/vscode-extension/src/mcp/{mcp-invocation.ts => invocation/invocation.ts} (57%) create mode 100644 packages/vscode-extension/src/mcp/invocation/model.ts delete mode 100644 packages/vscode-extension/src/mcp/mcp-client-targets.ts delete mode 100644 packages/vscode-extension/src/mcp/mcp-config-file.ts delete mode 100644 packages/vscode-extension/src/mcp/mcp-registration.ts delete mode 100644 packages/vscode-extension/src/mcp/registry-server.ts delete mode 100644 packages/vscode-extension/src/mcp/server-definition-provider.ts create mode 100644 packages/vscode-extension/src/mcp/setup.ts create mode 100644 packages/vscode-extension/src/mcp/vscode-provider/definition.ts create mode 100644 packages/vscode-extension/src/mcp/vscode-provider/index.ts create mode 100644 packages/vscode-extension/src/mcp/vscode-provider/provider.ts diff --git a/packages/vscode-extension/src/commands/index.ts b/packages/vscode-extension/src/commands/index.ts new file mode 100644 index 00000000..c4125238 --- /dev/null +++ b/packages/vscode-extension/src/commands/index.ts @@ -0,0 +1,2 @@ +export * from './new-wireframe'; +export * from './register'; diff --git a/packages/vscode-extension/src/commands/new-wireframe.ts b/packages/vscode-extension/src/commands/new-wireframe.ts new file mode 100644 index 00000000..c57ad69e --- /dev/null +++ b/packages/vscode-extension/src/commands/new-wireframe.ts @@ -0,0 +1,11 @@ +import * as vscode from 'vscode'; + +export const registerNewWireframeCommand = ( + context: vscode.ExtensionContext +): void => { + context.subscriptions.push( + vscode.commands.registerCommand('quickmock.newWireframe', () => { + vscode.window.showInformationMessage('New wireframe coming soon'); + }) + ); +}; diff --git a/packages/vscode-extension/src/commands/register.ts b/packages/vscode-extension/src/commands/register.ts new file mode 100644 index 00000000..c2ea7cf0 --- /dev/null +++ b/packages/vscode-extension/src/commands/register.ts @@ -0,0 +1,10 @@ +import * as vscode from 'vscode'; +import { registerNewWireframeCommand } from './new-wireframe'; + +/** + * Registers all VS Code commands exposed by the extension. + * @param context The VS Code extension context. + */ +export const registerCommands = (context: vscode.ExtensionContext): void => { + registerNewWireframeCommand(context); +}; diff --git a/packages/vscode-extension/src/core/index.ts b/packages/vscode-extension/src/core/index.ts index 0422dd83..4cbf7eb1 100644 --- a/packages/vscode-extension/src/core/index.ts +++ b/packages/vscode-extension/src/core/index.ts @@ -2,3 +2,4 @@ export * from './config'; export * from './document-registry'; export * from './logger'; export * from './paths'; +export * from './workspace'; diff --git a/packages/vscode-extension/src/core/workspace.ts b/packages/vscode-extension/src/core/workspace.ts new file mode 100644 index 00000000..8a73e99e --- /dev/null +++ b/packages/vscode-extension/src/core/workspace.ts @@ -0,0 +1,5 @@ +import * as vscode from 'vscode'; + +export const getPrimaryWorkspaceFolder = (): + | vscode.WorkspaceFolder + | undefined => vscode.workspace.workspaceFolders?.[0]; diff --git a/packages/vscode-extension/src/index.ts b/packages/vscode-extension/src/index.ts index dea690a3..233d9683 100644 --- a/packages/vscode-extension/src/index.ts +++ b/packages/vscode-extension/src/index.ts @@ -1,34 +1,15 @@ -import { logError, onAppUrlChange, syncAppUrlFile } from '#core'; +import { registerCommands } from '#commands'; +import { onAppUrlChange, syncAppUrlFile } from '#core'; import { QuickMockEditorProvider } from '#editor'; -import { - registerMcpServer, - registerQuickMockMcpServerProvider, - RegistryServer, -} from '#mcp'; +import { setupMcp } from '#mcp'; import * as vscode from 'vscode'; export const activate = (context: vscode.ExtensionContext) => { syncAppUrlFile(); context.subscriptions.push(onAppUrlChange(syncAppUrlFile)); - context.subscriptions.push(QuickMockEditorProvider.register(context)); - - const registryServer = new RegistryServer(); - registryServer - .start(context) - .catch(err => logError('Failed to start MCP registry server:', err)); - - context.subscriptions.push(registerQuickMockMcpServerProvider(context)); - - registerMcpServer(context).catch(err => - logError('Failed to register MCP server:', err) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('quickmock.newWireframe', () => { - vscode.window.showInformationMessage('New wireframe coming soon'); - }) - ); + setupMcp(context); + registerCommands(context); }; export const deactivate = () => {}; diff --git a/packages/vscode-extension/src/mcp/document-bridge/constants.ts b/packages/vscode-extension/src/mcp/document-bridge/constants.ts new file mode 100644 index 00000000..84f9c47d --- /dev/null +++ b/packages/vscode-extension/src/mcp/document-bridge/constants.ts @@ -0,0 +1,2 @@ +export const TOKEN_BYTE_LENGTH = 32; +export const PORT_FILE_MODE = 0o600; diff --git a/packages/vscode-extension/src/mcp/document-bridge/index.ts b/packages/vscode-extension/src/mcp/document-bridge/index.ts new file mode 100644 index 00000000..0ce5251a --- /dev/null +++ b/packages/vscode-extension/src/mcp/document-bridge/index.ts @@ -0,0 +1 @@ +export * from './server'; diff --git a/packages/vscode-extension/src/mcp/document-bridge/server.ts b/packages/vscode-extension/src/mcp/document-bridge/server.ts new file mode 100644 index 00000000..6fd6c3f7 --- /dev/null +++ b/packages/vscode-extension/src/mcp/document-bridge/server.ts @@ -0,0 +1,93 @@ +import { documentRegistry, getPrimaryWorkspaceFolder } from '#core'; +import { + buildPortFilePath, + DOCUMENT_ROUTE, + encodePortFile, + LOOPBACK_HOST, + TOKEN_HEADER, +} from '@lemoncode/quickmock-registry-protocol'; +import { randomBytes } from 'node:crypto'; +import { unlinkSync, writeFileSync } from 'node:fs'; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; +import * as vscode from 'vscode'; +import { PORT_FILE_MODE, TOKEN_BYTE_LENGTH } from './constants'; + +/** + * Starts the MCP document bridge server, which serves the content of documents open in the editor to the MCP server. + * @param context The VS Code extension context. + * @returns A promise that resolves when the server has started. + */ +export const startDocumentBridge = async ( + context: vscode.ExtensionContext +): Promise => { + const workspaceRoot = getPrimaryWorkspaceFolder()?.uri.fsPath; + if (!workspaceRoot) return; + + const portFile = buildPortFilePath(workspaceRoot); + const token = randomBytes(TOKEN_BYTE_LENGTH).toString('hex'); + + const handleRequest = (req: IncomingMessage, res: ServerResponse): void => { + if (req.headers[TOKEN_HEADER] !== token) { + res.writeHead(401); + res.end(); + return; + } + + const url = new URL(req.url ?? '/', 'http://localhost'); + + if (url.pathname !== DOCUMENT_ROUTE) { + res.writeHead(404); + res.end(); + return; + } + + const path = url.searchParams.get('path'); + if (!path) { + res.writeHead(400); + res.end('Missing path parameter'); + return; + } + + const content = documentRegistry.get(path); + if (content === undefined) { + res.writeHead(404); + res.end('Document not open in editor'); + return; + } + + res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end(content); + }; + + const server = createServer(handleRequest); + + await new Promise((resolve, reject) => { + server.on('error', reject); + // Get the assigned port and write it to the port file + server.listen(0, LOOPBACK_HOST, () => { + const { port } = server.address() as { port: number }; + try { + writeFileSync(portFile, encodePortFile(port, token), { + mode: PORT_FILE_MODE, + }); + } catch (err) { + reject(err); + return; + } + resolve(); + }); + }); + + context.subscriptions.push({ + dispose: () => { + server.close(); + try { + unlinkSync(portFile); + } catch {} + }, + }); +}; diff --git a/packages/vscode-extension/src/mcp/external-clients/clients/claude-code.ts b/packages/vscode-extension/src/mcp/external-clients/clients/claude-code.ts new file mode 100644 index 00000000..51ef953c --- /dev/null +++ b/packages/vscode-extension/src/mcp/external-clients/clients/claude-code.ts @@ -0,0 +1,8 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { ExternalMcpClient } from '../model'; + +export const claudeCode: ExternalMcpClient = { + label: 'Claude Code', + getConfigPath: () => join(homedir(), '.claude.json'), +}; diff --git a/packages/vscode-extension/src/mcp/external-clients/clients/index.ts b/packages/vscode-extension/src/mcp/external-clients/clients/index.ts new file mode 100644 index 00000000..a0356c68 --- /dev/null +++ b/packages/vscode-extension/src/mcp/external-clients/clients/index.ts @@ -0,0 +1,4 @@ +import type { ExternalMcpClient } from '../model'; +import { claudeCode } from './claude-code'; + +export const externalClients: ExternalMcpClient[] = [claudeCode]; diff --git a/packages/vscode-extension/src/mcp/external-clients/index.ts b/packages/vscode-extension/src/mcp/external-clients/index.ts new file mode 100644 index 00000000..62a10f04 --- /dev/null +++ b/packages/vscode-extension/src/mcp/external-clients/index.ts @@ -0,0 +1,2 @@ +export * from './model'; +export * from './register'; diff --git a/packages/vscode-extension/src/mcp/external-clients/model.ts b/packages/vscode-extension/src/mcp/external-clients/model.ts new file mode 100644 index 00000000..2dc906fe --- /dev/null +++ b/packages/vscode-extension/src/mcp/external-clients/model.ts @@ -0,0 +1,15 @@ +export interface ExternalMcpClient { + label: string; + getConfigPath: () => string; +} + +export interface McpFileConfig { + mcpServers?: Record; + [key: string]: unknown; +} + +export interface McpServerEntry { + type: 'stdio'; + command: string; + args: string[]; +} diff --git a/packages/vscode-extension/src/mcp/external-clients/register.ts b/packages/vscode-extension/src/mcp/external-clients/register.ts new file mode 100644 index 00000000..118278b0 --- /dev/null +++ b/packages/vscode-extension/src/mcp/external-clients/register.ts @@ -0,0 +1,65 @@ +import { logError, logInfo } from '#core/logger'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import * as vscode from 'vscode'; +import { + getMcpInvocation, + MCP_SERVER_ID, + type McpInvocation, +} from '../invocation'; +import { externalClients } from './clients'; +import type { ExternalMcpClient, McpFileConfig, McpServerEntry } from './model'; + +const readConfigFile = (path: string): McpFileConfig => { + try { + return JSON.parse(readFileSync(path, 'utf-8')) as McpFileConfig; + } catch { + return {}; + } +}; + +const writeConfigFile = (path: string, data: McpFileConfig): void => { + const dir = dirname(path); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(path, JSON.stringify(data, null, 2), 'utf-8'); +}; + +const buildMcpServerEntry = ({ + command, + args, +}: McpInvocation): McpServerEntry => ({ + type: 'stdio', + command, + args, +}); + +const registerInClient = ( + client: ExternalMcpClient, + entry: McpServerEntry +): void => { + const path = client.getConfigPath(); + if (!existsSync(path) && !existsSync(dirname(path))) return; + + try { + const config = readConfigFile(path); + if (!config.mcpServers) config.mcpServers = {}; + config.mcpServers[MCP_SERVER_ID] = entry; + writeConfigFile(path, config); + logInfo(`MCP registered — ${client.label}`); + } catch (err) { + logError(`MCP registration failed — ${client.label}: ${String(err)}`); + } +}; + +/** + * Registers the MCP server configuration in external clients, such as ai assistants or tools that support MCP integration. + * @param context The VS Code extension context. + */ +export const registerExternalMcpClients = ( + context: vscode.ExtensionContext +): void => { + const entry = buildMcpServerEntry(getMcpInvocation(context)); + for (const client of externalClients) { + registerInClient(client, entry); + } +}; diff --git a/packages/vscode-extension/src/mcp/index.ts b/packages/vscode-extension/src/mcp/index.ts index 6d4a5712..8b4659fb 100644 --- a/packages/vscode-extension/src/mcp/index.ts +++ b/packages/vscode-extension/src/mcp/index.ts @@ -1,6 +1,4 @@ -export * from './mcp-client-targets'; -export * from './mcp-config-file'; -export * from './mcp-invocation'; -export * from './mcp-registration'; -export * from './registry-server'; -export * from './server-definition-provider'; +export * from './document-bridge'; +export * from './external-clients'; +export * from './setup'; +export * from './vscode-provider'; diff --git a/packages/vscode-extension/src/mcp/invocation/constants.ts b/packages/vscode-extension/src/mcp/invocation/constants.ts new file mode 100644 index 00000000..4d25f3de --- /dev/null +++ b/packages/vscode-extension/src/mcp/invocation/constants.ts @@ -0,0 +1,2 @@ +export const MCP_SERVER_ID = 'quickmock'; +export const MCP_PKG = '@lemoncode/quickmock-mcp'; diff --git a/packages/vscode-extension/src/mcp/invocation/index.ts b/packages/vscode-extension/src/mcp/invocation/index.ts new file mode 100644 index 00000000..f563c7c9 --- /dev/null +++ b/packages/vscode-extension/src/mcp/invocation/index.ts @@ -0,0 +1,3 @@ +export * from './constants'; +export * from './invocation'; +export * from './model'; diff --git a/packages/vscode-extension/src/mcp/mcp-invocation.ts b/packages/vscode-extension/src/mcp/invocation/invocation.ts similarity index 57% rename from packages/vscode-extension/src/mcp/mcp-invocation.ts rename to packages/vscode-extension/src/mcp/invocation/invocation.ts index c5b07a5d..7ca6502e 100644 --- a/packages/vscode-extension/src/mcp/mcp-invocation.ts +++ b/packages/vscode-extension/src/mcp/invocation/invocation.ts @@ -1,18 +1,15 @@ import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import * as vscode from 'vscode'; +import { MCP_PKG } from './constants'; +import type { McpInvocation } from './model'; -export const MCP_SERVER_ID = 'quickmock'; - -const MCP_PKG = '@lemoncode/quickmock-mcp'; - -export interface McpInvocation { - command: string; - args: string[]; -} - -// Production: spawn the MCP from the published npm package via npx. -// Development: spawn the local workspace build so changes are picked up on rebuild. +/** + * Resolves how the MCP child process should be spawned for the current extension mode. + * In production it points to the published npm package via `npx`; in development it points to the local workspace build so changes are picked up on rebuild. + * @param context The VS Code extension context. + * @returns The command and arguments to spawn the MCP process. + */ export const getMcpInvocation = ( context: vscode.ExtensionContext ): McpInvocation => diff --git a/packages/vscode-extension/src/mcp/invocation/model.ts b/packages/vscode-extension/src/mcp/invocation/model.ts new file mode 100644 index 00000000..63765112 --- /dev/null +++ b/packages/vscode-extension/src/mcp/invocation/model.ts @@ -0,0 +1,4 @@ +export interface McpInvocation { + command: string; + args: string[]; +} diff --git a/packages/vscode-extension/src/mcp/mcp-client-targets.ts b/packages/vscode-extension/src/mcp/mcp-client-targets.ts deleted file mode 100644 index 00d08902..00000000 --- a/packages/vscode-extension/src/mcp/mcp-client-targets.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { homedir, platform } from 'node:os'; -import { join } from 'node:path'; - -export interface McpClientTarget { - label: string; - path: string; -} - -const CLAUDE_CODE: McpClientTarget = { - label: 'Claude Code', - path: join(homedir(), '.claude.json'), -}; - -const CURSOR: McpClientTarget = { - label: 'Cursor', - path: join(homedir(), '.cursor', 'mcp.json'), -}; - -const WINDSURF: McpClientTarget = { - label: 'Windsurf', - path: join(homedir(), '.codeium', 'windsurf', 'mcp_config.json'), -}; - -const CLAUDE_DESKTOP_FILE = 'claude_desktop_config.json'; - -const getClaudeDesktopTarget = (): McpClientTarget => { - const home = homedir(); - const os = platform(); - - if (os === 'darwin') { - return { - label: 'Claude Desktop', - path: join( - home, - 'Library', - 'Application Support', - 'Claude', - CLAUDE_DESKTOP_FILE - ), - }; - } - - if (os === 'win32') { - const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming'); - return { - label: 'Claude Desktop', - path: join(appData, 'Claude', CLAUDE_DESKTOP_FILE), - }; - } - - return { - label: 'Claude Desktop', - path: join(home, '.config', 'Claude', CLAUDE_DESKTOP_FILE), - }; -}; - -export const getMcpClientTargets = (): McpClientTarget[] => [ - CLAUDE_CODE, - CURSOR, - WINDSURF, - getClaudeDesktopTarget(), -]; diff --git a/packages/vscode-extension/src/mcp/mcp-config-file.ts b/packages/vscode-extension/src/mcp/mcp-config-file.ts deleted file mode 100644 index 1d7f0edf..00000000 --- a/packages/vscode-extension/src/mcp/mcp-config-file.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname } from 'node:path'; - -export interface McpFileConfig { - mcpServers?: Record; - [key: string]: unknown; -} - -export const readMcpFileConfig = (filePath: string): McpFileConfig => { - try { - return JSON.parse(readFileSync(filePath, 'utf-8')) as McpFileConfig; - } catch { - return {}; - } -}; - -export const writeMcpFileConfig = ( - filePath: string, - data: McpFileConfig -): void => { - const dir = dirname(filePath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); -}; diff --git a/packages/vscode-extension/src/mcp/mcp-registration.ts b/packages/vscode-extension/src/mcp/mcp-registration.ts deleted file mode 100644 index c3888998..00000000 --- a/packages/vscode-extension/src/mcp/mcp-registration.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { logError, logInfo } from '#core/logger'; -import { existsSync } from 'node:fs'; -import { dirname } from 'node:path'; -import * as vscode from 'vscode'; -import { - getMcpClientTargets, - type McpClientTarget, -} from './mcp-client-targets'; -import { readMcpFileConfig, writeMcpFileConfig } from './mcp-config-file'; -import type { McpInvocation } from './mcp-invocation'; -import { getMcpInvocation, MCP_SERVER_ID } from './mcp-invocation'; - -const VSCODE_CLIENT_LABEL = 'VS Code / GitHub Copilot'; -const MCP_CONFIG_SECTION = 'mcp'; -const MCP_SERVERS_KEY = 'servers'; - -export type RegistrationStatus = 'registered' | 'skipped' | 'error'; - -export interface RegistrationResult { - label: string; - status: RegistrationStatus; - detail?: string; -} - -interface McpServerEntry { - type: 'stdio'; - command: string; - args: string[]; -} - -const buildMcpServerEntry = ({ - command, - args, -}: McpInvocation): McpServerEntry => ({ - type: 'stdio', - command, - args, -}); - -const registerInVSCode = async ( - entry: McpServerEntry -): Promise => { - try { - const config = vscode.workspace.getConfiguration(MCP_CONFIG_SECTION); - const servers = config.get>(MCP_SERVERS_KEY) ?? {}; - servers[MCP_SERVER_ID] = entry; - await config.update( - MCP_SERVERS_KEY, - servers, - vscode.ConfigurationTarget.Global - ); - return { label: VSCODE_CLIENT_LABEL, status: 'registered' }; - } catch (err) { - return { - label: VSCODE_CLIENT_LABEL, - status: 'error', - detail: String(err), - }; - } -}; - -const registerInClientTarget = ( - target: McpClientTarget, - entry: McpServerEntry -): RegistrationResult => { - if (!existsSync(target.path) && !existsSync(dirname(target.path))) { - return { label: target.label, status: 'skipped', detail: 'Not installed' }; - } - - try { - const config = readMcpFileConfig(target.path); - if (!config.mcpServers) config.mcpServers = {}; - config.mcpServers[MCP_SERVER_ID] = entry; - writeMcpFileConfig(target.path, config); - return { label: target.label, status: 'registered' }; - } catch (err) { - return { label: target.label, status: 'error', detail: String(err) }; - } -}; - -export const registerMcpServer = async ( - context: vscode.ExtensionContext -): Promise => { - const entry = buildMcpServerEntry(getMcpInvocation(context)); - - const results: RegistrationResult[] = [ - await registerInVSCode(entry), - ...getMcpClientTargets().map(t => registerInClientTarget(t, entry)), - ]; - - for (const r of results) { - if (r.status === 'registered') { - logInfo(`MCP registered — ${r.label}`); - } else if (r.status === 'error') { - logError(`MCP registration failed — ${r.label}: ${r.detail}`); - } - } - - return results; -}; diff --git a/packages/vscode-extension/src/mcp/registry-server.ts b/packages/vscode-extension/src/mcp/registry-server.ts deleted file mode 100644 index c7f0eea2..00000000 --- a/packages/vscode-extension/src/mcp/registry-server.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { documentRegistry } from '#core'; -import { - buildPortFilePath, - DOCUMENT_ROUTE, - encodePortFile, - LOOPBACK_HOST, - TOKEN_HEADER, -} from '@lemoncode/quickmock-registry-protocol'; -import { randomBytes } from 'node:crypto'; -import { unlinkSync, writeFileSync } from 'node:fs'; -import { - createServer, - type IncomingMessage, - type ServerResponse, -} from 'node:http'; -import * as vscode from 'vscode'; - -const TOKEN_BYTE_LENGTH = 32; -const PORT_FILE_MODE = 0o600; - -export class RegistryServer { - private portFile: string | null = null; - private token = ''; - - async start(context: vscode.ExtensionContext): Promise { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!workspaceRoot) { - return; - } - - this.portFile = buildPortFilePath(workspaceRoot); - this.token = randomBytes(TOKEN_BYTE_LENGTH).toString('hex'); - - const server = createServer((req, res) => this.handleRequest(req, res)); - - await new Promise((resolve, reject) => { - server.on('error', reject); - server.listen(0, LOOPBACK_HOST, () => { - const { port } = server.address() as { port: number }; - try { - writeFileSync(this.portFile!, encodePortFile(port, this.token), { - mode: PORT_FILE_MODE, - }); - } catch (err) { - reject(err); - return; - } - resolve(); - }); - }); - - context.subscriptions.push({ - dispose: () => { - server.close(); - if (this.portFile) { - try { - unlinkSync(this.portFile); - } catch {} - } - }, - }); - } - - private handleRequest(req: IncomingMessage, res: ServerResponse): void { - if (req.headers[TOKEN_HEADER] !== this.token) { - res.writeHead(401); - res.end(); - return; - } - - const url = new URL(req.url ?? '/', 'http://localhost'); - - if (url.pathname !== DOCUMENT_ROUTE) { - res.writeHead(404); - res.end(); - return; - } - - const path = url.searchParams.get('path'); - if (!path) { - res.writeHead(400); - res.end('Missing path parameter'); - return; - } - - const content = documentRegistry.get(path); - if (content === undefined) { - res.writeHead(404); - res.end('Document not open in editor'); - return; - } - - res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); - res.end(content); - } -} diff --git a/packages/vscode-extension/src/mcp/server-definition-provider.ts b/packages/vscode-extension/src/mcp/server-definition-provider.ts deleted file mode 100644 index 4d282263..00000000 --- a/packages/vscode-extension/src/mcp/server-definition-provider.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { getHeadlessAppUrl, logInfo, onAppUrlChange } from '#core'; -import { getMcpInvocation, MCP_SERVER_ID } from '#mcp/mcp-invocation'; -import { createHash } from 'node:crypto'; -import * as vscode from 'vscode'; -import { version as EXTENSION_VERSION } from '../../package.json'; - -const SERVER_LABEL = 'QuickMock Wireframe Tools'; -const VERSION_HASH_ALGO = 'sha1'; -const VERSION_HASH_LENGTH = 8; - -const buildDefinition = ( - context: vscode.ExtensionContext, - workspaceFolder: vscode.WorkspaceFolder -): vscode.McpStdioServerDefinition => { - const versionSuffix = createHash(VERSION_HASH_ALGO) - .update(getHeadlessAppUrl()) - .digest('hex') - .slice(0, VERSION_HASH_LENGTH); - - const { command, args } = getMcpInvocation(context); - - return new vscode.McpStdioServerDefinition( - SERVER_LABEL, - command, - args, - { QM_WORKSPACE_ROOT: workspaceFolder.uri.fsPath }, - `${EXTENSION_VERSION}+${versionSuffix}` - ); -}; - -export const registerQuickMockMcpServerProvider = ( - context: vscode.ExtensionContext -): vscode.Disposable => { - const didChangeDefinitions = new vscode.EventEmitter(); - logInfo('Registering MCP server definition provider'); - - let providerRegistration: vscode.Disposable | undefined; - - const register = () => { - providerRegistration?.dispose(); - providerRegistration = vscode.lm.registerMcpServerDefinitionProvider( - MCP_SERVER_ID, - { - onDidChangeMcpServerDefinitions: didChangeDefinitions.event, - provideMcpServerDefinitions: async _token => { - logInfo('Providing MCP server definitions'); - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - logInfo('No workspace folder available for MCP server'); - return []; - } - return [buildDefinition(context, workspaceFolder)]; - }, - resolveMcpServerDefinition: async (server, _token) => { - logInfo('Resolving MCP server definition'); - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if ( - !workspaceFolder || - !(server instanceof vscode.McpStdioServerDefinition) - ) { - return server; - } - const fresh = buildDefinition(context, workspaceFolder); - server.command = fresh.command; - server.args = fresh.args; - server.env = fresh.env; - server.version = fresh.version; - return server; - }, - } - ); - }; - - register(); - - const subscriptions: vscode.Disposable[] = [ - didChangeDefinitions, - vscode.workspace.onDidChangeWorkspaceFolders(() => - didChangeDefinitions.fire() - ), - onAppUrlChange(() => { - logInfo('appUrl changed, re-registering MCP provider'); - register(); - didChangeDefinitions.fire(); - }), - { dispose: () => providerRegistration?.dispose() }, - ]; - - return vscode.Disposable.from(...subscriptions); -}; diff --git a/packages/vscode-extension/src/mcp/setup.ts b/packages/vscode-extension/src/mcp/setup.ts new file mode 100644 index 00000000..ced1fb5a --- /dev/null +++ b/packages/vscode-extension/src/mcp/setup.ts @@ -0,0 +1,23 @@ +import { logError } from '#core'; +import * as vscode from 'vscode'; +import { startDocumentBridge } from './document-bridge'; +import { registerExternalMcpClients } from './external-clients'; +import { registerQuickMockMcpServerProvider } from './vscode-provider'; + +/** + * Sets up all MCP integrations for the extension: starts the document bridge, registers the VS Code/Copilot provider, and writes the server config to external MCP clients. + * @param context The VS Code extension context. + */ +export const setupMcp = (context: vscode.ExtensionContext): void => { + startDocumentBridge(context).catch(err => + logError('Failed to start MCP document bridge:', err) + ); + + context.subscriptions.push(registerQuickMockMcpServerProvider(context)); + + try { + registerExternalMcpClients(context); + } catch (err) { + logError('Failed to register external MCP clients:', err); + } +}; diff --git a/packages/vscode-extension/src/mcp/vscode-provider/definition.ts b/packages/vscode-extension/src/mcp/vscode-provider/definition.ts new file mode 100644 index 00000000..8b88723a --- /dev/null +++ b/packages/vscode-extension/src/mcp/vscode-provider/definition.ts @@ -0,0 +1,33 @@ +import { getHeadlessAppUrl } from '#core'; +import { createHash } from 'node:crypto'; +import * as vscode from 'vscode'; +import { version as EXTENSION_VERSION } from '../../../package.json'; +import { getMcpInvocation } from '../invocation'; + +const SERVER_LABEL = 'QuickMock Wireframe Tools'; +const VERSION_HASH_ALGO = 'sha1'; +const VERSION_HASH_LENGTH = 8; + +// Suffix the extension version with a hash of the headless URL so VS Code +// re-launches the MCP whenever the user points at a different editor URL. +const buildVersion = (): string => { + const suffix = createHash(VERSION_HASH_ALGO) + .update(getHeadlessAppUrl()) + .digest('hex') + .slice(0, VERSION_HASH_LENGTH); + return `${EXTENSION_VERSION}+${suffix}`; +}; + +export const buildMcpDefinition = ( + context: vscode.ExtensionContext, + workspaceFolder: vscode.WorkspaceFolder +): vscode.McpStdioServerDefinition => { + const { command, args } = getMcpInvocation(context); + return new vscode.McpStdioServerDefinition( + SERVER_LABEL, + command, + args, + { QM_WORKSPACE_ROOT: workspaceFolder.uri.fsPath }, + buildVersion() + ); +}; diff --git a/packages/vscode-extension/src/mcp/vscode-provider/index.ts b/packages/vscode-extension/src/mcp/vscode-provider/index.ts new file mode 100644 index 00000000..03be03e5 --- /dev/null +++ b/packages/vscode-extension/src/mcp/vscode-provider/index.ts @@ -0,0 +1 @@ +export * from './provider'; diff --git a/packages/vscode-extension/src/mcp/vscode-provider/provider.ts b/packages/vscode-extension/src/mcp/vscode-provider/provider.ts new file mode 100644 index 00000000..56f9e053 --- /dev/null +++ b/packages/vscode-extension/src/mcp/vscode-provider/provider.ts @@ -0,0 +1,57 @@ +import { getPrimaryWorkspaceFolder, logInfo, onAppUrlChange } from '#core'; +import * as vscode from 'vscode'; +import { MCP_SERVER_ID } from '../invocation'; +import { buildMcpDefinition } from './definition'; + +/** + * Registers QuickMock as an MCP server definition provider for VS Code, so Copilot (and any other consumer of the official VS Code MCP API) can discover and launch it. + * Re-emits the change event when the workspace folders or the configured editor URL change, so VS Code re-queries the definition with fresh values. + * @param context The VS Code extension context. + * @returns A Disposable that unregisters the provider and detaches the listeners. + */ +export const registerQuickMockMcpServerProvider = ( + context: vscode.ExtensionContext +): vscode.Disposable => { + logInfo('Registering MCP server definition provider'); + + const didChangeDefinitions = new vscode.EventEmitter(); + + const providerRegistration = vscode.lm.registerMcpServerDefinitionProvider( + MCP_SERVER_ID, + { + onDidChangeMcpServerDefinitions: didChangeDefinitions.event, + provideMcpServerDefinitions: async () => { + const workspaceFolder = getPrimaryWorkspaceFolder(); + if (!workspaceFolder) { + logInfo('No workspace folder available for MCP server'); + return []; + } + return [buildMcpDefinition(context, workspaceFolder)]; + }, + resolveMcpServerDefinition: async server => { + const workspaceFolder = getPrimaryWorkspaceFolder(); + if ( + !workspaceFolder || + !(server instanceof vscode.McpStdioServerDefinition) + ) { + return server; + } + const fresh = buildMcpDefinition(context, workspaceFolder); + server.command = fresh.command; + server.args = fresh.args; + server.env = fresh.env; + server.version = fresh.version; + return server; + }, + } + ); + + return vscode.Disposable.from( + providerRegistration, + didChangeDefinitions, + vscode.workspace.onDidChangeWorkspaceFolders(() => + didChangeDefinitions.fire() + ), + onAppUrlChange(() => didChangeDefinitions.fire()) + ); +}; From 4f6612ebdbe7361402f044b339bbb6e95d51acf7 Mon Sep 17 00:00:00 2001 From: Ivanruii Date: Wed, 29 Apr 2026 09:34:50 +0200 Subject: [PATCH 2/2] fix(vscode-extension): add TODO comment for implementing new wireframe functionality --- packages/vscode-extension/src/commands/new-wireframe.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode-extension/src/commands/new-wireframe.ts b/packages/vscode-extension/src/commands/new-wireframe.ts index c57ad69e..628f537c 100644 --- a/packages/vscode-extension/src/commands/new-wireframe.ts +++ b/packages/vscode-extension/src/commands/new-wireframe.ts @@ -5,7 +5,7 @@ export const registerNewWireframeCommand = ( ): void => { context.subscriptions.push( vscode.commands.registerCommand('quickmock.newWireframe', () => { - vscode.window.showInformationMessage('New wireframe coming soon'); + vscode.window.showInformationMessage('New wireframe coming soon'); // TODO: Implement the actual functionality for creating a new wireframe }) ); };