* fix(mcp): close orphaned servers that no app ever claims - Add ServerOptions.appAttachTimeoutMs: if no app registers within this window after startup, the server closes itself and removes its discovery file, instead of squatting the port indefinitely. - Wire it through the openpencil-mcp-http CLI as OPENPENCIL_MCP_APP_TIMEOUT_MS (opt-in, unset/0 disables it — a bare CLI invocation for manual testing should not self-terminate). - The desktop app opts in with a 30s timeout when it spawns the server. Without this, a server that outlives its spawning app (renderer crash, forced reload) keeps holding its port with a stale discovery file. The app's liveness check only asks whether something answers /health, not whether an app has ever registered (see /health's no_app status) — so every later launch finds the orphan already listening and defers to it, and MCP tool calls fail with "app is not connected" until someone manually kills the orphaned process. Closing self-caused orphans at the source means the next launch finds no discovery file and takes the normal fresh-spawn path. Fixes #488 * fix(mcp): clean up servers after app disconnects - Re-arm the orphan watchdog when the registered app disconnects - Cancel pending shutdown when the app reconnects within the grace period - Reject timeout values that overflow the runtime timer range * test(mcp): make watchdog reconnect coverage deterministic - Wait for the disconnected health state before reconnecting - Report distinct safe-integer and timer-range validation errors --------- Co-authored-by: swe-sanad <sanad.arousi@export119.com>
182 lines
6.6 KiB
TypeScript
182 lines
6.6 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from 'bun:test'
|
|
|
|
import { getAutomationAuthToken, spawnMCPIfNeeded } from '@/app/automation/mcp/spawn'
|
|
|
|
import { clearTauriMocks, installTauriMockWindow, mockTauriIPC } from '#tests/helpers/tauri/mocks'
|
|
|
|
const DISCOVERY_PATH = '/mock/home/.openpencil/mcp.json'
|
|
const DISCOVERY_JSON = JSON.stringify({
|
|
pid: 1234,
|
|
socketPath: '/mock/home/.openpencil/mcp.sock',
|
|
httpPort: 7600,
|
|
authRequired: true,
|
|
authToken: 'discovery-token',
|
|
version: '0.0.0-test',
|
|
startedAt: new Date().toISOString()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await clearTauriMocks()
|
|
vi.restoreAllMocks()
|
|
Reflect.deleteProperty(globalThis, 'window')
|
|
Reflect.deleteProperty(globalThis, 'navigator')
|
|
Reflect.deleteProperty(globalThis, 'location')
|
|
})
|
|
|
|
describe('Tauri MCP spawning', () => {
|
|
test('retains an early startup failure for MCP-dependent features', async () => {
|
|
installTauriMockWindow()
|
|
Object.assign(globalThis.window, { location: { origin: 'tauri://localhost' } })
|
|
Object.defineProperty(globalThis, 'navigator', {
|
|
configurable: true,
|
|
value: { platform: 'Win32', userAgent: 'Windows' }
|
|
})
|
|
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 404 }))
|
|
await mockTauriIPC((cmd, args) => {
|
|
if (cmd === 'mcp_executable_available') return true
|
|
if (cmd === 'plugin:path|resolve_directory') return '/mock/home'
|
|
if (cmd === 'plugin:fs|exists') return false
|
|
if (cmd === 'plugin:shell|spawn') {
|
|
const onEvent = (args as { onEvent: { onmessage: (event: unknown) => void } }).onEvent
|
|
.onmessage
|
|
queueMicrotask(() => {
|
|
onEvent({
|
|
event: 'Stderr',
|
|
payload: Array.from(
|
|
new TextEncoder().encode('El sistema no puede encontrar el archivo especificado.')
|
|
)
|
|
})
|
|
onEvent({ event: 'Terminated', payload: { code: 1, signal: null } })
|
|
})
|
|
return 88
|
|
}
|
|
return null
|
|
})
|
|
|
|
expect(await spawnMCPIfNeeded()).toBeNull()
|
|
expect(getAutomationAuthToken()).rejects.toThrow('MCP server exited before startup completed')
|
|
})
|
|
|
|
test('retains unexpected spawn errors for MCP-dependent features', async () => {
|
|
installTauriMockWindow()
|
|
Object.assign(globalThis.window, { location: { origin: 'tauri://localhost' } })
|
|
Object.defineProperty(globalThis, 'navigator', {
|
|
configurable: true,
|
|
value: { platform: 'MacIntel', userAgent: 'Macintosh' }
|
|
})
|
|
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 404 }))
|
|
await mockTauriIPC((cmd) => {
|
|
if (cmd === 'mcp_executable_available') return true
|
|
if (cmd === 'plugin:path|resolve_directory') return '/mock/home'
|
|
if (cmd === 'plugin:fs|exists') return false
|
|
if (cmd === 'plugin:shell|spawn') throw new Error('shell permission denied')
|
|
return null
|
|
})
|
|
|
|
expect(await spawnMCPIfNeeded()).toBeNull()
|
|
expect(getAutomationAuthToken()).rejects.toThrow('shell permission denied')
|
|
})
|
|
|
|
test('retains a language-independent missing executable diagnostic', async () => {
|
|
installTauriMockWindow()
|
|
Object.assign(globalThis.window, { location: { origin: 'tauri://localhost' } })
|
|
Object.defineProperty(globalThis, 'navigator', {
|
|
configurable: true,
|
|
value: { platform: 'MacIntel', userAgent: 'Macintosh' }
|
|
})
|
|
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 404 }))
|
|
await mockTauriIPC((cmd) => {
|
|
if (cmd === 'plugin:path|resolve_directory') return '/mock/home'
|
|
if (cmd === 'plugin:fs|exists') return false
|
|
if (cmd === 'mcp_executable_available') return false
|
|
return null
|
|
})
|
|
|
|
expect(await spawnMCPIfNeeded()).toBeNull()
|
|
expect(getAutomationAuthToken()).rejects.toThrow('MCP automation is not installed')
|
|
})
|
|
|
|
test('spawns MCP server with shell plugin when health check is missing', async () => {
|
|
installTauriMockWindow()
|
|
Object.assign(globalThis.window, { location: { origin: 'tauri://localhost' } })
|
|
Object.defineProperty(globalThis, 'navigator', {
|
|
configurable: true,
|
|
value: { platform: 'MacIntel' }
|
|
})
|
|
|
|
let healthChecks = 0
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
|
healthChecks += 1
|
|
if (healthChecks === 1) return new Response('', { status: 404 })
|
|
return new Response(
|
|
JSON.stringify({
|
|
status: 'ok',
|
|
version: '0.0.0-test',
|
|
authRequired: true,
|
|
discoveryPath: DISCOVERY_PATH
|
|
}),
|
|
{ status: 200 }
|
|
)
|
|
})
|
|
|
|
let onEvent: ((event: unknown) => void) | null = null
|
|
const calls: Array<{ cmd: string; args: unknown }> = []
|
|
await mockTauriIPC((cmd, args) => {
|
|
calls.push({ cmd, args })
|
|
if (cmd === 'mcp_executable_available') return true
|
|
if (cmd === 'plugin:shell|spawn') {
|
|
expect(args).toMatchObject({
|
|
program: 'openpencil-mcp-http',
|
|
args: [],
|
|
options: {
|
|
env: {
|
|
PORT: '7600',
|
|
OPENPENCIL_MCP_AUTH_TOKEN: expect.any(String),
|
|
OPENPENCIL_MCP_CORS_ORIGIN: 'tauri://localhost',
|
|
OPENPENCIL_MCP_TCP: '1',
|
|
OPENPENCIL_MCP_ROOT: '/mock/home',
|
|
OPENPENCIL_MCP_APP_TIMEOUT_MS: '30000'
|
|
}
|
|
}
|
|
})
|
|
onEvent = (args as { onEvent: { onmessage: (event: unknown) => void } }).onEvent.onmessage
|
|
return 77
|
|
}
|
|
const pathArg = (args as { path?: string })?.path
|
|
if (cmd === 'plugin:fs|exists') {
|
|
return pathArg === DISCOVERY_PATH
|
|
}
|
|
if (cmd === 'plugin:fs|read_text_file') {
|
|
// Only return discovery data when reading the expected discovery path
|
|
if (pathArg === DISCOVERY_PATH) return new TextEncoder().encode(DISCOVERY_JSON)
|
|
return null
|
|
}
|
|
// Handle homeDir() IPC call from resolveTauriHomeDir
|
|
if (cmd === 'plugin:path|resolve_directory') {
|
|
return '/mock/home'
|
|
}
|
|
return null
|
|
})
|
|
|
|
const handle = await spawnMCPIfNeeded()
|
|
await expect(getAutomationAuthToken()).resolves.toBe('discovery-token')
|
|
onEvent?.({ event: 'Stderr', payload: [119, 97, 114, 110] })
|
|
handle?.disconnect()
|
|
await Promise.resolve()
|
|
|
|
expect(handle?.authToken).toBe('discovery-token')
|
|
expect(calls.some((c) => c.cmd === 'plugin:shell|spawn')).toBe(true)
|
|
expect(
|
|
calls.some(
|
|
(c) =>
|
|
c.cmd === 'plugin:fs|read_text_file' &&
|
|
(c.args as { path?: string })?.path === DISCOVERY_PATH
|
|
)
|
|
).toBe(true)
|
|
expect(calls.at(-1)).toEqual({ cmd: 'plugin:shell|kill', args: { cmd: 'killChild', pid: 77 } })
|
|
})
|
|
})
|