You need to sign in or sign up before continuing.
speckit_stdio_server.js 9.83 KB
import { spawn } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const PROTOCOL_VERSIONS = ['2024-11-05', '2025-06-18']

const server_info = {
    name: 'speckit-cli-mcp',
    title: 'Speckit CLI MCP Server',
    version: '0.1.0',
}

const module_dirname = path.dirname(fileURLToPath(import.meta.url))
const workspace_root = path.resolve(module_dirname, '..')

process.stderr.write(`[speckit-cli-mcp] cwd=${process.cwd()} workspace_root=${workspace_root}\n`)

const send_json = (payload) => {
    process.stdout.write(`${JSON.stringify(payload)}\n`)
}

const send_result = (id, result) => {
    send_json({ jsonrpc: '2.0', id, result })
}

const send_error = (id, code, message, data) => {
    const error = { code, message }
    if (data !== undefined) {
        error.data = data
    }
    send_json({ jsonrpc: '2.0', id, error })
}

const as_text_content = (obj) => {
    return {
        content: [
            {
                type: 'text',
                text: JSON.stringify(obj, null, 4),
            },
        ],
        isError: obj.code !== 1,
    }
}

const normalize_subcommand = (subcommand) => {
    if (typeof subcommand !== 'string') {
        return null
    }
    const trimmed = subcommand.trim()
    if (!trimmed) {
        return null
    }
    return trimmed
}

const validate_args = (args) => {
    if (args === undefined) {
        return { ok: true, value: [] }
    }
    if (!Array.isArray(args)) {
        return { ok: false, message: 'args 必须为字符串数组' }
    }
    for (const item of args) {
        if (typeof item !== 'string') {
            return { ok: false, message: 'args 必须为字符串数组' }
        }
        if (item.length > 200) {
            return { ok: false, message: 'args 单项长度不能超过 200' }
        }
    }
    return { ok: true, value: args }
}

const resolve_cwd = (cwd_input) => {
    if (!cwd_input) {
        return workspace_root
    }
    if (typeof cwd_input !== 'string') {
        return null
    }
    const resolved = path.resolve(workspace_root, cwd_input)
    if (!resolved.startsWith(workspace_root)) {
        return null
    }
    return resolved
}

const run_specify = async ({ cli, subcommand, args, cwd, timeout_ms }) => {
    const cli_bin = cli === 'speckit' ? 'speckit' : 'specify'
    const allowed_subcommands = new Set(['check', 'init'])
    const normalized_subcommand = normalize_subcommand(subcommand)
    if (!normalized_subcommand || !allowed_subcommands.has(normalized_subcommand)) {
        return { code: 0, data: null, msg: `不支持的子命令:${subcommand}` }
    }

    const validated_args = validate_args(args)
    if (!validated_args.ok) {
        return { code: 0, data: null, msg: validated_args.message }
    }

    const resolved_cwd = resolve_cwd(cwd)
    if (!resolved_cwd) {
        return { code: 0, data: null, msg: 'cwd 非法,必须为工作区内的相对路径' }
    }

    const final_timeout_ms = Number.isFinite(timeout_ms) ? Math.max(1000, timeout_ms) : 10 * 60 * 1000
    const final_args = [normalized_subcommand, ...validated_args.value]

    return await new Promise((resolve) => {
        const child = spawn(cli_bin, final_args, {
            cwd: resolved_cwd,
            env: process.env,
        })

        let stdout = ''
        let stderr = ''
        let timed_out = false

        const timer = setTimeout(() => {
            timed_out = true
            child.kill('SIGTERM')
        }, final_timeout_ms)

        child.stdout.on('data', (chunk) => {
            stdout += chunk.toString()
            if (stdout.length > 2_000_000) {
                stdout = `${stdout.slice(0, 2_000_000)}\n...[stdout 截断]`
            }
        })

        child.stderr.on('data', (chunk) => {
            stderr += chunk.toString()
            if (stderr.length > 2_000_000) {
                stderr = `${stderr.slice(0, 2_000_000)}\n...[stderr 截断]`
            }
        })

        child.on('error', (err) => {
            clearTimeout(timer)
            resolve({
                code: 0,
                data: {
                    cwd: resolved_cwd,
                    cli: cli_bin,
                    args: final_args,
                    stdout,
                    stderr,
                    error: String(err?.message || err),
                },
                msg: '命令启动失败(请确认已安装 specify/speckit)',
            })
        })

        child.on('close', (exit_code, signal) => {
            clearTimeout(timer)
            resolve({
                code: exit_code === 0 && !timed_out ? 1 : 0,
                data: {
                    cwd: resolved_cwd,
                    cli: cli_bin,
                    args: final_args,
                    exit_code,
                    signal,
                    timed_out,
                    stdout,
                    stderr,
                },
                msg: exit_code === 0 && !timed_out ? 'ok' : '命令执行失败',
            })
        })
    })
}

const list_tools = () => {
    return [
        {
            name: 'speckit_check',
            description: '运行 specify check(检查工具链是否齐全)',
            inputSchema: {
                type: 'object',
                properties: {
                    cli: { type: 'string', enum: ['specify', 'speckit'], default: 'specify' },
                    cwd: { type: 'string', description: '相对工作区路径(可选)' },
                    timeout_ms: { type: 'number', description: '超时时间(毫秒,可选)' },
                },
                required: [],
            },
        },
        {
            name: 'speckit_init_here',
            description: '运行 specify init --here(初始化/更新 Spec Kit 文件)',
            inputSchema: {
                type: 'object',
                properties: {
                    cli: { type: 'string', enum: ['specify', 'speckit'], default: 'specify' },
                    ai: { type: 'string', default: 'copilot' },
                    force: { type: 'boolean', default: false },
                    cwd: { type: 'string', description: '相对工作区路径(可选)' },
                    timeout_ms: { type: 'number', description: '超时时间(毫秒,可选)' },
                },
                required: [],
            },
        },
        {
            name: 'speckit_run',
            description: '运行 allowlist 内的 specify 子命令(check/init)',
            inputSchema: {
                type: 'object',
                properties: {
                    cli: { type: 'string', enum: ['specify', 'speckit'], default: 'specify' },
                    subcommand: { type: 'string', enum: ['check', 'init'] },
                    args: { type: 'array', items: { type: 'string' } },
                    cwd: { type: 'string', description: '相对工作区路径(可选)' },
                    timeout_ms: { type: 'number', description: '超时时间(毫秒,可选)' },
                },
                required: ['subcommand'],
            },
        },
    ]
}

const handle_request = async (msg) => {
    const { id, method, params } = msg || {}

    if (method === 'initialize') {
        const requested = params?.protocolVersion
        const negotiated = PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0]
        send_result(id, {
            protocolVersion: negotiated,
            capabilities: {
                tools: { listChanged: false },
            },
            serverInfo: server_info,
            instructions: '仅提供 Speckit/Spec Kit CLI 的最小封装工具(check/init)。',
        })
        return
    }

    if (method === 'ping') {
        send_result(id, {})
        return
    }

    if (method === 'tools/list') {
        send_result(id, { tools: list_tools() })
        return
    }

    if (method === 'tools/call') {
        const name = params?.name
        const args = params?.arguments || {}

        if (name === 'speckit_check') {
            const res = await run_specify({
                cli: args.cli,
                subcommand: 'check',
                args: [],
                cwd: args.cwd,
                timeout_ms: args.timeout_ms,
            })
            send_result(id, as_text_content(res))
            return
        }

        if (name === 'speckit_init_here') {
            const force_flag = args.force ? ['--force'] : []
            const ai_value = typeof args.ai === 'string' && args.ai.trim() ? args.ai.trim() : 'copilot'
            const res = await run_specify({
                cli: args.cli,
                subcommand: 'init',
                args: ['--here', '--ai', ai_value, ...force_flag],
                cwd: args.cwd,
                timeout_ms: args.timeout_ms,
            })
            send_result(id, as_text_content(res))
            return
        }

        if (name === 'speckit_run') {
            const res = await run_specify({
                cli: args.cli,
                subcommand: args.subcommand,
                args: args.args,
                cwd: args.cwd,
                timeout_ms: args.timeout_ms,
            })
            send_result(id, as_text_content(res))
            return
        }

        send_error(id, -32602, `Unknown tool: ${name}`)
        return
    }

    if (id !== undefined) {
        send_error(id, -32601, `Method not found: ${method}`)
    }
}

let buffer = ''
process.stdin.setEncoding('utf8')
process.stdin.on('data', async (chunk) => {
    buffer += chunk
    while (buffer.indexOf('\n') !== -1) {
        const idx = buffer.indexOf('\n')
        const line = buffer.slice(0, idx).trim()
        buffer = buffer.slice(idx + 1)
        if (!line) {
            continue
        }
        let msg
        try {
            msg = JSON.parse(line)
        } catch (e) {
            continue
        }
        await handle_request(msg)
    }
})

process.stdin.on('end', () => {
    process.exit(0)
})