feat: 新增 mactfs-mcp 服务(AI 改文件前自动签出/加入)
- mactfs-mcp:通过 SSE 暴露 tfs_checkout / tfs_add,转调本机 Java 后端操作 TFS - 后端 MacTfsCoreService/MacTfsServer 新增文件状态查询等接口,供 MCP 执行前校验 - Electron 随客户端拉起并回收 MCP 子进程(父死自杀、崩溃自动重拉) - tfs_checkout 默认行为:新文件自动 add、本地落后自动 get-latest 后再签出 (可分别用 autoAdd=false / getLatestIfStale=false 关闭;dryRun 无副作用) - 新增 docs/打包构建与MCP使用指南.md Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
import path from "node:path"
|
||||
|
||||
import { tfsClient } from "./tfs-client.js"
|
||||
import type {
|
||||
FileStatus,
|
||||
PendAction,
|
||||
PendItemResult,
|
||||
PendReason,
|
||||
} from "./types.js"
|
||||
|
||||
export interface PendOptions {
|
||||
dryRun?: boolean
|
||||
/** 本地落后于服务器时是否先自动获取最新再签出。默认 true,传 false 才退回仅警告。 */
|
||||
getLatestIfStale?: boolean
|
||||
/** 遇到服务端尚不存在的新文件时是否自动加入版本控制(add)。默认 true,传 false 才退回仅提示。 */
|
||||
autoAdd?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装单条结果,缺省字段从已知的 FileStatus 回填。
|
||||
*/
|
||||
function item(
|
||||
filePath: string,
|
||||
serverPath: string | null,
|
||||
action: PendAction,
|
||||
reason: PendReason,
|
||||
message: string,
|
||||
status?: FileStatus,
|
||||
): PendItemResult {
|
||||
return {
|
||||
path: filePath,
|
||||
serverPath: serverPath ?? status?.serverPath ?? null,
|
||||
action,
|
||||
reason,
|
||||
mapped: status?.mapped ?? false,
|
||||
pendingEdit: status?.pendingEdit ?? false,
|
||||
localVersion: status?.localVersion ?? 0,
|
||||
latestVersion: status?.latestVersion ?? 0,
|
||||
upToDate: status?.upToDate ?? false,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接预检:返回 null 表示后端可用,否则返回给所有路径的统一失败结果。
|
||||
*/
|
||||
async function connectionGuard(paths: string[]): Promise<PendItemResult[] | null> {
|
||||
const health = await tfsClient.health()
|
||||
if (health.ok) {
|
||||
return null
|
||||
}
|
||||
return paths.map((p) =>
|
||||
item(
|
||||
p,
|
||||
null,
|
||||
"error",
|
||||
"notConnected",
|
||||
`连不上本地 MacTFS 服务(${health.errorMessage ?? "服务未运行"})。请确认 MacTFS 客户端已打开。`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** 读取单个本地文件状态,失败时返回 null(调用方据此产出 error 结果)。 */
|
||||
async function loadStatus(localPath: string): Promise<FileStatus | { error: string }> {
|
||||
const resp = await tfsClient.fileStatus(localPath)
|
||||
if (!resp.ok || !resp.data || !resp.data.status) {
|
||||
return { error: resp.errorMessage ?? "未知错误" }
|
||||
}
|
||||
return resp.data.status
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正执行签出(pendEdit)。checkoutArg 可为本地绝对路径或服务端路径。
|
||||
*/
|
||||
async function doCheckout(
|
||||
checkoutArg: string,
|
||||
serverPath: string | null,
|
||||
status?: FileStatus,
|
||||
): Promise<PendItemResult> {
|
||||
const resp = await tfsClient.checkout([checkoutArg], false)
|
||||
if (!resp.ok || !resp.data) {
|
||||
return item(checkoutArg, serverPath, "error", "error", `签出失败:${resp.errorMessage ?? "未知错误"}`, status)
|
||||
}
|
||||
const result = resp.data.result
|
||||
if (result.failures && result.failures.length > 0) {
|
||||
return item(checkoutArg, serverPath, "error", "error", `签出未成功:${result.failures.join(";")}`, status)
|
||||
}
|
||||
return item(checkoutArg, serverPath, "checkedOut", "ok", `已签出(影响 ${result.affected} 项),可以开始修改。`, status)
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正执行加入版本控制(pendAdd),只接受本地路径。
|
||||
*/
|
||||
async function doAdd(localPath: string, status: FileStatus): Promise<PendItemResult> {
|
||||
const resp = await tfsClient.add([localPath], false)
|
||||
if (!resp.ok || !resp.data) {
|
||||
return item(localPath, status.serverPath, "error", "error", `加入版本控制失败:${resp.errorMessage ?? "未知错误"}`, status)
|
||||
}
|
||||
const result = resp.data.result
|
||||
if (result.failures && result.failures.length > 0) {
|
||||
return item(localPath, status.serverPath, "error", "error", `加入未成功:${result.failures.join(";")}`, status)
|
||||
}
|
||||
return item(localPath, status.serverPath, "added", "ok", `已加入版本控制(影响 ${result.affected} 项),签入后服务器生效。`, status)
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单个路径跑「签出」校验流水线:
|
||||
* 映射 → 已签出 → 新文件 → 未下载 → 是否落后 → 真正签出。
|
||||
*/
|
||||
async function checkoutOne(raw: string, options: PendOptions): Promise<PendItemResult> {
|
||||
if (raw.startsWith("$/")) {
|
||||
if (options.dryRun) {
|
||||
return item(raw, raw, "skipped", "ok", "服务端路径,dryRun 跳过实际签出。")
|
||||
}
|
||||
return doCheckout(raw, raw)
|
||||
}
|
||||
|
||||
const localPath = path.resolve(raw)
|
||||
const status = await loadStatus(localPath)
|
||||
if ("error" in status) {
|
||||
return item(localPath, null, "error", "error", `查询文件状态失败:${status.error}`)
|
||||
}
|
||||
|
||||
if (!status.mapped) {
|
||||
return item(localPath, status.serverPath, "skipped", "notMapped", "该文件不在任何 TFS 映射目录下,无需签出。", status)
|
||||
}
|
||||
if (status.pendingEdit) {
|
||||
return item(localPath, status.serverPath, "alreadyCheckedOut", "ok", "文件已处于签出状态,可直接修改。", status)
|
||||
}
|
||||
if (!status.serverExists) {
|
||||
if (options.autoAdd === false) {
|
||||
return item(localPath, status.serverPath, "skipped", "newFileNeedsAdd", "这是服务端尚不存在的新文件,请改用 tfs_add 加入版本控制。", status)
|
||||
}
|
||||
if (!status.exists) {
|
||||
return item(localPath, status.serverPath, "skipped", "notExist", "这是新文件但本地不存在;请先创建文件再加入版本控制。", status)
|
||||
}
|
||||
if (status.pendingChangeType === "pendingAdd") {
|
||||
return item(localPath, status.serverPath, "alreadyAdded", "ok", "文件已挂起新增,无需重复加入。", status)
|
||||
}
|
||||
if (options.dryRun) {
|
||||
return item(localPath, status.serverPath, "skipped", "ok", "校验通过:这是新文件,将自动加入版本控制(dryRun 未真正执行)。", status)
|
||||
}
|
||||
return doAdd(localPath, status)
|
||||
}
|
||||
if (!status.exists || status.status === "notDownloaded") {
|
||||
return item(localPath, status.serverPath, "skipped", "notDownloaded", "本地尚未下载该文件,请先获取最新。", status)
|
||||
}
|
||||
|
||||
const stale = !status.upToDate || status.status === "remoteChanged" || status.status === "bothChanged"
|
||||
const autoGetLatest = options.getLatestIfStale !== false
|
||||
if (stale && !autoGetLatest) {
|
||||
return item(
|
||||
localPath,
|
||||
status.serverPath,
|
||||
"skipped",
|
||||
"stale",
|
||||
`本地版本(${status.localVersion})落后于服务器最新(${status.latestVersion})。已按 getLatestIfStale=false 跳过;若要在旧版本上修改请自行处理。`,
|
||||
status,
|
||||
)
|
||||
}
|
||||
if (stale) {
|
||||
if (options.dryRun) {
|
||||
return item(
|
||||
localPath,
|
||||
status.serverPath,
|
||||
"skipped",
|
||||
"ok",
|
||||
`校验通过:本地落后(${status.localVersion}→${status.latestVersion}),将先自动获取最新再签出(dryRun 未真正执行)。`,
|
||||
status,
|
||||
)
|
||||
}
|
||||
const gl = await tfsClient.getLatest(status.serverPath ?? undefined, false)
|
||||
if (!gl.ok) {
|
||||
return item(localPath, status.serverPath, "error", "stale", `自动获取最新失败:${gl.errorMessage ?? "未知错误"}`, status)
|
||||
}
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
return item(localPath, status.serverPath, "skipped", "ok", "校验通过:可签出(dryRun 未真正执行)。", status)
|
||||
}
|
||||
return doCheckout(localPath, status.serverPath, status)
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单个路径跑「加入版本控制」校验流水线:
|
||||
* 映射 → 本地存在 → 已加入 → 是否已受控(应改签出) → 真正 add。
|
||||
*/
|
||||
async function addOne(raw: string, options: PendOptions): Promise<PendItemResult> {
|
||||
const localPath = path.resolve(raw)
|
||||
const status = await loadStatus(localPath)
|
||||
if ("error" in status) {
|
||||
return item(localPath, null, "error", "error", `查询文件状态失败:${status.error}`)
|
||||
}
|
||||
|
||||
if (!status.mapped) {
|
||||
return item(localPath, status.serverPath, "skipped", "notMapped", "该文件不在任何 TFS 映射目录下,无法加入版本控制。", status)
|
||||
}
|
||||
if (!status.exists) {
|
||||
return item(localPath, status.serverPath, "skipped", "notExist", "本地文件不存在;请先创建文件再加入版本控制。", status)
|
||||
}
|
||||
if (status.pendingChangeType === "pendingAdd") {
|
||||
return item(localPath, status.serverPath, "alreadyAdded", "ok", "文件已挂起新增,无需重复加入。", status)
|
||||
}
|
||||
if (status.serverExists) {
|
||||
return item(localPath, status.serverPath, "skipped", "alreadyTracked", "该文件已在版本控制中;若要修改请用 tfs_checkout 签出,而非加入。", status)
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
return item(localPath, status.serverPath, "skipped", "ok", "校验通过:可加入版本控制(dryRun 未真正执行)。", status)
|
||||
}
|
||||
return doAdd(localPath, status)
|
||||
}
|
||||
|
||||
/**
|
||||
* tfs_checkout 主流程。
|
||||
*/
|
||||
export async function runCheckout(rawPaths: string[], options: PendOptions): Promise<PendItemResult[]> {
|
||||
const paths = rawPaths.map((p) => p.trim()).filter((p) => p.length > 0)
|
||||
if (paths.length === 0) {
|
||||
return []
|
||||
}
|
||||
const guard = await connectionGuard(paths)
|
||||
if (guard) {
|
||||
return guard
|
||||
}
|
||||
const results: PendItemResult[] = []
|
||||
for (const raw of paths) {
|
||||
results.push(await checkoutOne(raw, options))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* tfs_add 主流程。
|
||||
*/
|
||||
export async function runAdd(rawPaths: string[], options: PendOptions): Promise<PendItemResult[]> {
|
||||
const paths = rawPaths.map((p) => p.trim()).filter((p) => p.length > 0)
|
||||
if (paths.length === 0) {
|
||||
return []
|
||||
}
|
||||
const guard = await connectionGuard(paths)
|
||||
if (guard) {
|
||||
return guard
|
||||
}
|
||||
const results: PendItemResult[] = []
|
||||
for (const raw of paths) {
|
||||
results.push(await addOne(raw, options))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
const ACTION_TAGS: Record<PendAction, string> = {
|
||||
checkedOut: "✓ 已签出",
|
||||
alreadyCheckedOut: "✓ 已签出(无需重复)",
|
||||
added: "✓ 已加入",
|
||||
alreadyAdded: "✓ 已加入(无需重复)",
|
||||
skipped: "• 跳过",
|
||||
error: "✗ 失败",
|
||||
}
|
||||
|
||||
/**
|
||||
* 把结构化结果汇总成一段人话 summary,方便 AI 与人快速读懂。
|
||||
*/
|
||||
export function summarize(results: PendItemResult[]): string {
|
||||
if (results.length === 0) {
|
||||
return "未提供任何文件路径。"
|
||||
}
|
||||
return results.map((r) => `${ACTION_TAGS[r.action]} ${r.path} — ${r.message}`).join("\n")
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
/** 读取整数环境变量,非法或缺省时回退默认值。 */
|
||||
function intEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name]
|
||||
if (!raw) {
|
||||
return fallback
|
||||
}
|
||||
const value = Number.parseInt(raw, 10)
|
||||
return Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
// MacTFS 本地 Java 服务地址,与 Electron / 前端约定一致(127.0.0.1:38765)。
|
||||
export const TFS_API_BASE_URL =
|
||||
process.env.MACTFS_API_BASE_URL ?? "http://127.0.0.1:38765"
|
||||
|
||||
// MCP SSE 监听地址,AI 客户端通过 http://127.0.0.1:38766/sse 连接。
|
||||
export const MCP_HOST = process.env.MACTFS_MCP_HOST ?? "127.0.0.1"
|
||||
export const MCP_PORT = intEnv("MACTFS_MCP_PORT", 38766)
|
||||
|
||||
// 本地 Bearer Token 文件,与 Java 服务、Electron 复用同一份。
|
||||
export const TOKEN_FILE =
|
||||
process.env.MACTFS_TOKEN_FILE ?? path.join(os.homedir(), ".mactfs", "server-token")
|
||||
|
||||
// 启动时等待 Java 后端就绪的最大轮询次数与间隔(毫秒)。
|
||||
export const HEALTH_WAIT_TRIES = intEnv("MACTFS_MCP_HEALTH_TRIES", 60)
|
||||
export const HEALTH_WAIT_INTERVAL_MS = intEnv("MACTFS_MCP_HEALTH_INTERVAL_MS", 1000)
|
||||
|
||||
// 父进程(Electron)pid:用于「父死则自杀」的看护,避免遗留孤儿进程。
|
||||
export const PARENT_PID = (() => {
|
||||
const raw = process.env.MACTFS_PARENT_PID
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
const value = Number.parseInt(raw, 10)
|
||||
return Number.isFinite(value) && value > 0 ? value : null
|
||||
})()
|
||||
|
||||
// HTTP 调用 Java 服务的默认超时(毫秒)。
|
||||
export const HTTP_TIMEOUT_MS = intEnv("MACTFS_MCP_HTTP_TIMEOUT_MS", 30000)
|
||||
|
||||
// 是否要求 AI 客户端连接 SSE 时携带 token。
|
||||
// 默认关闭:已有「仅监听 127.0.0.1 + 随客户端生命周期绑定」两道保护,AI 端无需配置 token。
|
||||
// 设 MACTFS_MCP_REQUIRE_TOKEN=1 可开启更严格的本机鉴权。
|
||||
export const MCP_REQUIRE_TOKEN = (process.env.MACTFS_MCP_REQUIRE_TOKEN ?? "").trim() === "1"
|
||||
|
||||
/**
|
||||
* 读取本地 Bearer Token,文件缺失或为空时返回 null。
|
||||
* Token 由 Java 服务在 ~/.mactfs/server-token 生成。
|
||||
*/
|
||||
export function readToken(): string | null {
|
||||
try {
|
||||
const value = fs.readFileSync(TOKEN_FILE, "utf8").trim()
|
||||
return value.length > 0 ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { startServer } from "./server.js"
|
||||
|
||||
startServer().catch((error) => {
|
||||
process.stderr.write(`[mactfs-mcp] fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,218 @@
|
||||
import express from "express"
|
||||
import type { Request } from "express"
|
||||
import { z } from "zod"
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"
|
||||
|
||||
import {
|
||||
HEALTH_WAIT_INTERVAL_MS,
|
||||
HEALTH_WAIT_TRIES,
|
||||
MCP_HOST,
|
||||
MCP_PORT,
|
||||
MCP_REQUIRE_TOKEN,
|
||||
PARENT_PID,
|
||||
readToken,
|
||||
} from "./config.js"
|
||||
import { runAdd, runCheckout, summarize } from "./checkout.js"
|
||||
import { tfsClient } from "./tfs-client.js"
|
||||
|
||||
const SERVER_NAME = "mactfs-mcp"
|
||||
const SERVER_VERSION = "0.1.0"
|
||||
|
||||
/** 简单延时。 */
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/** stderr 日志,SSE 走 HTTP,stdout 可用但统一打 stderr 更安全。 */
|
||||
function log(message: string): void {
|
||||
process.stderr.write(`[mactfs-mcp] ${message}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验请求是否允许连接。
|
||||
* 默认不强制 token(已有 127.0.0.1 + 客户端绑定两道保护);
|
||||
* 仅当 MACTFS_MCP_REQUIRE_TOKEN=1 时,才要求 Authorization: Bearer 或 ?token=。
|
||||
*/
|
||||
function authorized(req: Request): boolean {
|
||||
if (!MCP_REQUIRE_TOKEN) {
|
||||
return true
|
||||
}
|
||||
const expected = readToken()
|
||||
if (!expected) {
|
||||
return true
|
||||
}
|
||||
const auth = req.headers["authorization"]
|
||||
if (typeof auth === "string" && auth.startsWith("Bearer ") && auth.slice(7).trim() === expected) {
|
||||
return true
|
||||
}
|
||||
const queryToken = req.query.token
|
||||
if (typeof queryToken === "string" && queryToken === expected) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个 MCP server 实例并注册工具。每个 SSE 连接用独立实例,避免跨连接状态串扰。
|
||||
*/
|
||||
function createMcpServer(): McpServer {
|
||||
const mcp = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION })
|
||||
|
||||
mcp.registerTool(
|
||||
"tfs_checkout",
|
||||
{
|
||||
title: "TFS 签出文件",
|
||||
description:
|
||||
"在修改受 TFS 版本控制的文件之前调用本工具签出(checkout / pendEdit)。" +
|
||||
"会先做执行前校验:是否在映射目录内、是否已签出、是否为新文件、本地是否落后于服务器最新版本。" +
|
||||
"凡是即将编辑、且位于 MacTFS 映射目录下的已有文件,都应先调用本工具。" +
|
||||
"默认行为:遇到服务端尚不存在的新文件会自动加入版本控制(add);本地版本落后时会自动先获取最新再签出。" +
|
||||
"返回每个文件的处理结果(checkedOut / alreadyCheckedOut / added / alreadyAdded / skipped / error)。",
|
||||
inputSchema: {
|
||||
paths: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.describe("要签出的文件本地绝对路径列表(也兼容 $/ 开头的服务端路径)"),
|
||||
dryRun: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("只做执行前校验、不真正签出/加入/获取最新,用于预检"),
|
||||
getLatestIfStale: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("当本地版本落后于服务器时,自动先获取最新再签出(默认 true;传 false 则仅警告、不自动获取)"),
|
||||
autoAdd: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("遇到服务端尚不存在的新文件时,自动加入版本控制(add)(默认 true;传 false 则仅提示改用 tfs_add)"),
|
||||
},
|
||||
},
|
||||
async ({ paths, dryRun, getLatestIfStale, autoAdd }) => {
|
||||
const results = await runCheckout(paths, { dryRun, getLatestIfStale, autoAdd })
|
||||
const text = `${summarize(results)}\n\n${JSON.stringify({ results }, null, 2)}`
|
||||
return { content: [{ type: "text" as const, text }] }
|
||||
},
|
||||
)
|
||||
|
||||
mcp.registerTool(
|
||||
"tfs_add",
|
||||
{
|
||||
title: "TFS 加入版本控制",
|
||||
description:
|
||||
"为 MacTFS 映射目录下、尚未纳入版本控制的【新建文件】执行加入(add / pendAdd)。" +
|
||||
"新建文件请在写入磁盘后调用本工具(此时文件已存在于本地)。" +
|
||||
"会先做执行前校验:是否在映射目录内、本地是否存在、是否已加入、是否其实已受控(已受控应改用 tfs_checkout)。" +
|
||||
"返回每个文件的处理结果(added / alreadyAdded / skipped / error)。",
|
||||
inputSchema: {
|
||||
paths: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.describe("要加入版本控制的文件本地绝对路径列表"),
|
||||
dryRun: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("只做执行前校验、不真正加入,用于预检"),
|
||||
},
|
||||
},
|
||||
async ({ paths, dryRun }) => {
|
||||
const results = await runAdd(paths, { dryRun })
|
||||
const text = `${summarize(results)}\n\n${JSON.stringify({ results }, null, 2)}`
|
||||
return { content: [{ type: "text" as const, text }] }
|
||||
},
|
||||
)
|
||||
|
||||
return mcp
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动「父进程(Electron)看护」:父进程消失则自杀,避免遗留孤儿。
|
||||
*/
|
||||
function startParentWatchdog(): void {
|
||||
const pid = PARENT_PID
|
||||
if (pid === null) {
|
||||
return
|
||||
}
|
||||
log(`watching parent pid ${pid}`)
|
||||
setInterval(() => {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
} catch {
|
||||
log("parent process gone, exiting")
|
||||
process.exit(0)
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待 Java 后端 health 就绪;超过上限仍未就绪则降级继续(工具会返回 notConnected)。
|
||||
*/
|
||||
async function waitForBackend(): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < HEALTH_WAIT_TRIES; attempt += 1) {
|
||||
const health = await tfsClient.health()
|
||||
if (health.ok) {
|
||||
return true
|
||||
}
|
||||
await delay(HEALTH_WAIT_INTERVAL_MS)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 MCP SSE 服务:等待后端、注册路由、监听端口、挂看护。
|
||||
*/
|
||||
export async function startServer(): Promise<void> {
|
||||
startParentWatchdog()
|
||||
|
||||
log("waiting for MacTFS backend ...")
|
||||
const ready = await waitForBackend()
|
||||
if (ready) {
|
||||
log("backend ready")
|
||||
} else {
|
||||
log("backend not ready after wait, starting in degraded mode")
|
||||
}
|
||||
|
||||
const app = express()
|
||||
const transports: Record<string, SSEServerTransport> = {}
|
||||
|
||||
app.get("/sse", async (req, res) => {
|
||||
if (!authorized(req)) {
|
||||
res.status(401).json({ error: "unauthorized" })
|
||||
return
|
||||
}
|
||||
const transport = new SSEServerTransport("/messages", res)
|
||||
transports[transport.sessionId] = transport
|
||||
res.on("close", () => {
|
||||
delete transports[transport.sessionId]
|
||||
})
|
||||
const mcp = createMcpServer()
|
||||
try {
|
||||
await mcp.connect(transport)
|
||||
} catch (error) {
|
||||
log(`sse connect error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/messages", async (req, res) => {
|
||||
if (!authorized(req)) {
|
||||
res.status(401).json({ error: "unauthorized" })
|
||||
return
|
||||
}
|
||||
const sessionId = typeof req.query.sessionId === "string" ? req.query.sessionId : ""
|
||||
const transport = transports[sessionId]
|
||||
if (!transport) {
|
||||
res.status(400).json({ error: "no transport for sessionId" })
|
||||
return
|
||||
}
|
||||
await transport.handlePostMessage(req, res)
|
||||
})
|
||||
|
||||
app.get("/healthz", (_req, res) => {
|
||||
res.json({ ok: true, name: SERVER_NAME, version: SERVER_VERSION })
|
||||
})
|
||||
|
||||
app.listen(MCP_PORT, MCP_HOST, () => {
|
||||
log(`SSE listening on http://${MCP_HOST}:${MCP_PORT}/sse`)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { HTTP_TIMEOUT_MS, TFS_API_BASE_URL, readToken } from "./config.js"
|
||||
import type {
|
||||
ApiResponse,
|
||||
ApiResult,
|
||||
FileOperationResult,
|
||||
FileStatus,
|
||||
HealthData,
|
||||
} from "./types.js"
|
||||
|
||||
/**
|
||||
* 发起一次对 Java 服务的请求,自动带 Bearer Token、统一封装结果与错误。
|
||||
* 任何网络/解析失败都收敛成 { ok:false },绝不抛异常,保证 MCP 不被打断。
|
||||
*/
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
options: { query?: Record<string, string | undefined>; body?: unknown } = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
const token = readToken()
|
||||
const url = new URL(path, TFS_API_BASE_URL)
|
||||
if (options.query) {
|
||||
for (const [key, value] of Object.entries(options.query)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: "application/json" }
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
const init: RequestInit = { method, headers }
|
||||
if (options.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json;charset=utf-8"
|
||||
init.body = JSON.stringify(options.body)
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS)
|
||||
init.signal = controller.signal
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, init)
|
||||
} catch (error) {
|
||||
clearTimeout(timer)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { ok: false, status: 0, data: null, errorMessage: `无法连接本地服务:${message}` }
|
||||
}
|
||||
clearTimeout(timer)
|
||||
|
||||
let result: ApiResult<T> | null = null
|
||||
try {
|
||||
result = (await response.json()) as ApiResult<T>
|
||||
} catch {
|
||||
result = null
|
||||
}
|
||||
|
||||
const ok = response.ok && result?.success === true
|
||||
return {
|
||||
ok,
|
||||
status: response.status,
|
||||
data: result?.data ?? null,
|
||||
errorMessage: ok
|
||||
? null
|
||||
: result?.errorMessage || result?.message || `请求失败(HTTP ${response.status})`,
|
||||
}
|
||||
}
|
||||
|
||||
// 统一的 Java 服务客户端,MCP 只通过本对象访问后端。
|
||||
export const tfsClient = {
|
||||
/** 健康检查,返回 { connected } 等服务状态。 */
|
||||
health() {
|
||||
return request<HealthData>("GET", "/api/health")
|
||||
},
|
||||
/** 查询单个本地文件的 TFS 状态(依赖后端新增的 /api/files/status)。 */
|
||||
fileStatus(localPath: string) {
|
||||
return request<{ status: FileStatus }>("GET", "/api/files/status", { query: { localPath } })
|
||||
},
|
||||
/** 对给定路径执行签出(pendEdit),paths 可为本地绝对路径或服务端路径。 */
|
||||
checkout(paths: string[], recursive = false) {
|
||||
return request<{ result: FileOperationResult }>("POST", "/api/files/checkout", {
|
||||
body: { paths, recursive },
|
||||
})
|
||||
},
|
||||
/** 将本地新增文件加入 pending add,paths 为本地绝对路径。 */
|
||||
add(paths: string[], recursive = false) {
|
||||
return request<{ result: FileOperationResult }>("POST", "/api/files/add", {
|
||||
body: { paths, recursive },
|
||||
})
|
||||
},
|
||||
/** 获取最新(用于 getLatestIfStale 场景),serverPath 缺省按当前 Workspace 全量。 */
|
||||
getLatest(serverPath: string | undefined, recursive = false) {
|
||||
return request<{ result: unknown }>("POST", "/api/files/get-latest", {
|
||||
body: { serverPath, recursive, force: false },
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// 与 Java 后端 ApiResult 对齐的统一响应外壳。
|
||||
export interface ApiResult<T = Record<string, unknown>> {
|
||||
success: boolean
|
||||
message?: string
|
||||
errorMessage?: string
|
||||
operation?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// HTTP 客户端对外返回结构,统一封装网络层与业务层。
|
||||
export interface ApiResponse<T = Record<string, unknown>> {
|
||||
ok: boolean
|
||||
status: number
|
||||
data: T | null
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
// /api/health 返回的 data 字段。
|
||||
export interface HealthData {
|
||||
status?: string
|
||||
connected?: boolean
|
||||
}
|
||||
|
||||
// /api/files/status(新增)返回的单文件状态。
|
||||
export interface FileStatus {
|
||||
localPath: string
|
||||
serverPath: string | null
|
||||
mapped: boolean
|
||||
exists: boolean
|
||||
serverExists: boolean
|
||||
pendingEdit: boolean
|
||||
pendingChangeType: string | null
|
||||
localVersion: number
|
||||
latestVersion: number
|
||||
upToDate: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
// /api/files/checkout 返回的 result 字段。
|
||||
export interface FileOperationResult {
|
||||
operation: string
|
||||
affected: number
|
||||
failures: string[]
|
||||
}
|
||||
|
||||
// tfs_checkout / tfs_add 对每个路径返回的结构化结果(共用一套结构)。
|
||||
export type PendAction =
|
||||
| "checkedOut"
|
||||
| "alreadyCheckedOut"
|
||||
| "added"
|
||||
| "alreadyAdded"
|
||||
| "skipped"
|
||||
| "error"
|
||||
export type PendReason =
|
||||
| "ok"
|
||||
| "notConnected"
|
||||
| "clientNotRunning"
|
||||
| "notMapped"
|
||||
| "newFileNeedsAdd"
|
||||
| "notDownloaded"
|
||||
| "stale"
|
||||
| "notExist"
|
||||
| "alreadyTracked"
|
||||
| "error"
|
||||
|
||||
export interface PendItemResult {
|
||||
path: string
|
||||
serverPath: string | null
|
||||
action: PendAction
|
||||
reason: PendReason
|
||||
mapped: boolean
|
||||
pendingEdit: boolean
|
||||
localVersion: number
|
||||
latestVersion: number
|
||||
upToDate: boolean
|
||||
message: string
|
||||
}
|
||||
Reference in New Issue
Block a user