fix(mcp): report version mismatches

This commit is contained in:
Danila Poyarkov 2026-05-19 11:12:01 +03:00
parent c11d65fe55
commit f4454aeb44
8 changed files with 69 additions and 13 deletions

View file

@ -98,7 +98,7 @@
},
"packages/cli": {
"name": "@open-pencil/cli",
"version": "0.11.8",
"version": "0.12.0",
"bin": {
"openpencil": "./src/index.ts",
},
@ -114,7 +114,7 @@
},
"packages/core": {
"name": "@open-pencil/core",
"version": "0.11.8",
"version": "0.12.0",
"dependencies": {
"@iconify/utils": "^3.1.0",
"acorn": "^8.16.0",
@ -152,15 +152,17 @@
},
"packages/mcp": {
"name": "@open-pencil/mcp",
"version": "0.11.8",
"version": "0.12.0",
"bin": {
"openpencil-mcp": "./dist/index.js",
"openpencil-mcp": "./dist/stdio.js",
"openpencil-mcp-http": "./dist/index.js",
},
"dependencies": {
"@hono/node-server": "^1.19.9",
"@modelcontextprotocol/sdk": "^1.25.2",
"@open-pencil/core": "workspace:*",
"hono": "^4.11.4",
"package-manager-detector": "^1.6.0",
"ws": "^8.19.0",
"zod": "^4.3.6",
},
@ -171,7 +173,7 @@
},
"packages/vue": {
"name": "@open-pencil/vue",
"version": "0.11.8",
"version": "0.12.0",
"dependencies": {
"@nanostores/i18n": "^1.2.2",
"@nanostores/vue": "^1.1.0",

View file

@ -45,6 +45,13 @@
{
"name": "@/types",
"message": "Import types directly from @open-pencil/core."
},
{
"name": "node:module",
"importNames": [
"createRequire"
],
"message": "Use ESM imports or import.meta.resolve instead of createRequire/require."
}
],
"patterns": [

View file

@ -215,9 +215,9 @@ export class FontManager {
}
const { readFile } = await import(/* @vite-ignore */ 'node:fs/promises')
const { resolve, dirname } = await import(/* @vite-ignore */ 'node:path')
const { createRequire } = await import(/* @vite-ignore */ 'node:module')
const require = createRequire(import.meta.url)
const packageRoot = dirname(require.resolve('@open-pencil/core/package.json'))
const { fileURLToPath } = await import(/* @vite-ignore */ 'node:url')
const packageJsonUrl = import.meta.resolve('@open-pencil/core/package.json')
const packageRoot = dirname(fileURLToPath(packageJsonUrl))
const assetPath = resolve(packageRoot, `assets${url}`)
const buf = await readFile(assetPath)
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)

View file

@ -36,6 +36,7 @@
"@modelcontextprotocol/sdk": "^1.25.2",
"@open-pencil/core": "workspace:*",
"hono": "^4.11.4",
"package-manager-detector": "^1.6.0",
"zod": "^4.3.6",
"ws": "^8.19.0"
},

View file

@ -1,6 +1,7 @@
import { createRequire } from 'node:module'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { resolveCommand } from 'package-manager-detector/commands'
import { detect, getUserAgent } from 'package-manager-detector/detect'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { WebSocketServer } from 'ws'
@ -10,10 +11,31 @@ import { createBrowserRpcBridge } from './browser-rpc'
import { MCP_CORS_HEADERS, MCP_CORS_METHODS, MCP_EXPOSED_HEADERS } from './http-options'
import { preprocessRpc } from './jsx-preprocess'
import { createMcpSessionManager } from './mcp-sessions'
import packageJson from '../package.json'
import { registerTools } from './tool/registration'
const require = createRequire(import.meta.url)
export const MCP_VERSION: string = (require('../package.json') as { version: string }).version
export const MCP_VERSION: string = packageJson.version
let installCommandPromise: Promise<string> | null = null
async function resolveMcpInstallCommand(): Promise<string> {
const agent =
getUserAgent() ??
(
await detect({
strategies: ['install-metadata', 'lockfile', 'packageManager-field', 'devEngines-field']
})
)?.agent ??
'npm'
const resolved = resolveCommand(agent, 'global', [`@open-pencil/mcp@${MCP_VERSION}`])
if (!resolved) return `npm install -g @open-pencil/mcp@${MCP_VERSION}`
return [resolved.command, ...resolved.args].join(' ')
}
function mcpInstallCommand(): Promise<string> {
installCommandPromise ??= resolveMcpInstallCommand()
return installCommandPromise
}
export { fail, ok, type MCPContent, type MCPResult } from './result'
@ -84,9 +106,11 @@ export function startServer(options: ServerOptions = {}) {
)
}
app.get('/health', (c) =>
app.get('/health', async (c) =>
c.json({
status: browserRpc.isConnected() ? 'ok' : 'no_app',
version: MCP_VERSION,
installCommand: await mcpInstallCommand(),
authRequired: authToken !== null,
...(browserRpc.currentRpcToken() ? { token: browserRpc.currentRpcToken() } : {})
})

View file

@ -8,6 +8,8 @@ import { isTauri } from '@/app/tauri/env'
interface AutomationHealth {
status: 'ok' | 'no_app'
version?: string
installCommand?: string
authRequired?: boolean
token?: string
}
@ -18,6 +20,7 @@ export interface AutomationServerHandle {
}
const DEV_AUTOMATION_AUTH_TOKEN = import.meta.env.DEV ? __OPENPENCIL_LOCAL_AUTOMATION_TOKEN__ : null
const APP_VERSION = __OPENPENCIL_APP_VERSION__
const noop = () => undefined
let runtimeAutomationAuthToken: string | null = DEV_AUTOMATION_AUTH_TOKEN
@ -35,6 +38,18 @@ async function readHealth(): Promise<AutomationHealth | null> {
}
}
function assertCompatibleMcpVersion(health: AutomationHealth): void {
if (health.version === APP_VERSION) return
const runningVersion = health.version ? `v${health.version}` : 'an older version'
const updateHint = health.installCommand
? `Run: ${health.installCommand}, then restart OpenPencil.`
: `Update the global @open-pencil/mcp package to v${APP_VERSION} with your package manager, then restart OpenPencil.`
throw new Error(
`OpenPencil desktop v${APP_VERSION} requires @open-pencil/mcp v${APP_VERSION}, ` +
`but the running MCP server is ${runningVersion}. ${updateHint}`
)
}
async function pollHealth(retries: number, delayMs: number): Promise<AutomationHealth | null> {
for (let i = 0; i < retries; i++) {
await promiseTimeout(delayMs)
@ -47,6 +62,7 @@ async function pollHealth(retries: number, delayMs: number): Promise<AutomationH
export async function getAutomationAuthToken(): Promise<string | null> {
if (runtimeAutomationAuthToken) return runtimeAutomationAuthToken
const health = await readHealth()
if (health) assertCompatibleMcpVersion(health)
runtimeAutomationAuthToken = health?.token ?? null
return runtimeAutomationAuthToken
}
@ -60,6 +76,7 @@ export async function spawnMCPIfNeeded(): Promise<AutomationServerHandle | null>
const existing = await readHealth()
if (existing) {
assertCompatibleMcpVersion(existing)
runtimeAutomationAuthToken = existing.token ?? null
return {
disconnect: noop,
@ -98,6 +115,7 @@ export async function spawnMCPIfNeeded(): Promise<AutomationServerHandle | null>
const health = await pollHealth(5, 1000)
if (health) {
assertCompatibleMcpVersion(health)
runtimeAutomationAuthToken = health.token ?? authToken
return {
disconnect: () => {
@ -109,6 +127,6 @@ export async function spawnMCPIfNeeded(): Promise<AutomationServerHandle | null>
await child.kill()
throw new Error(
'Failed to start MCP server. Is openpencil-mcp-http installed? Run: npm i -g @open-pencil/mcp'
`Failed to start MCP server. Install @open-pencil/mcp@${APP_VERSION} globally with your package manager, then restart OpenPencil.`
)
}

1
src/env.d.ts vendored
View file

@ -2,6 +2,7 @@
/// <reference types="vite-plugin-pwa/vanillajs" />
/// <reference types="unplugin-icons/types/vue" />
declare const __OPENPENCIL_APP_VERSION__: string
declare const __OPENPENCIL_LOCAL_AUTOMATION_TOKEN__: string | null
declare module '*.vue' {

View file

@ -1,5 +1,7 @@
import process from 'node:process'
import packageJson from './package.json'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import IconsResolver from 'unplugin-icons/resolver'
@ -21,6 +23,7 @@ export default defineConfig(async ({ command }) => ({
alias: createOpenPencilAliases(__dirname)
},
define: {
__OPENPENCIL_APP_VERSION__: JSON.stringify(packageJson.version),
__OPENPENCIL_LOCAL_AUTOMATION_TOKEN__: JSON.stringify(localAutomationToken(command))
},
plugins: [