@shaohui_jin/git-cockpit-mcp-server 0.1.23 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,12 +22,13 @@ npm install -g @shaohui_jin/git-cockpit-mcp-server
22
22
  ```bash
23
23
  git-cockpit start # 启动常驻服务:Web UI + MCP Server(Streamable HTTP),默认 http://localhost:3000
24
24
  git-cockpit mcp # 以 stdio 模式运行 MCP Server(供 Claude Desktop / Cursor 等客户端连接)
25
+ # 已有常驻服务时自动桥接过去,多客户端共用一个后端
25
26
  git-cockpit version # 输出版本号
26
27
  ```
27
28
 
28
29
  启动后浏览器访问 `http://localhost:3000` 即可使用 Web 界面;`/mcp` 端点提供 MCP Streamable HTTP 接入。
29
30
 
30
- ### 接入 MCP 客户端(stdio)
31
+ ### 接入 MCP 客户端(stdio,推荐)
31
32
 
32
33
  以 Claude Desktop / Cursor 为例,在 MCP 配置中加入:
33
34
 
@@ -42,7 +43,28 @@ git-cockpit version # 输出版本号
42
43
  }
43
44
  ```
44
45
 
45
- 网页和聊天窗口要一起用时,改跑 `git-cockpit start`,客户端连 `http://localhost:3000/mcp`。不要再开一个 `git-cockpit mcp` 抢同一仓。
46
+ **唯一 daemon 机制**:`git-cockpit mcp` 探活到已有 daemon 就转发 stdio 请求;没有 daemon 则由当前命令启动一个 daemon,再桥接到它。
47
+ 客户端 stdin 关闭时,如果 daemon 是本命令启动的,会一并关闭;桥接失败不会静默创建第二套 Runtime。多个客户端因此可以稳定共用同一个 Git Cockpit、
48
+ 任务队列与仓库锁,客户端配置里不需要写密钥。多仓库时先读取 `git-cockpit://repos`,再调用
49
+ `git_repo_select({ "repoId": 1 })` 绑定当前 MCP session;后续省略仓库参数时不会串到其他客户端的仓库。
50
+
51
+ ### 接入 MCP 客户端(Streamable HTTP)
52
+
53
+ `/mcp` 与 `/api/*` 一样受本机密钥保护,URL 方式**必须带请求头**:
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "git-cockpit-http": {
59
+ "url": "http://localhost:3000/mcp",
60
+ "headers": { "X-Git-Cockpit-Secret": "<你的 localSecret>" }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ 密钥在 `~/.git-cockpit/config.json` 的 `auth.localSecret`,也可在 Web 界面的「设置」里点 **复制本机访问密钥**。
67
+ 密钥不对会得到 **401**;手写请求时 `Accept` 必须同时含 `application/json` 与 `text/event-stream`,否则 **406**。
46
68
 
47
69
  **不要**和 Git Insight(或其它会直接改同一仓的 Git MCP)同时用于同一个仓库。
48
70
 
@@ -63,15 +85,17 @@ git-cockpit version # 输出版本号
63
85
 
64
86
  宿主可插入这四条 Prompt(`safe_commit` / `merge_preview_apply` / `workspace_continue` / `open_mr`),正文即上面四段。只读状态也可读 Resource:`git-cockpit://repos`、`git-cockpit://repo/current`、`git-cockpit://jobs`、`git-cockpit://jobs/{id}`。
65
87
 
88
+ 多仓库时先读 `git-cockpit://repos` 拿 `repoId`,再 `git_repo_select({ "repoId": 1 })` 绑定当前 MCP session;之后省略 `repoId/repoPath` 的工具都作用在这个仓。要临时操作别的仓,单次调用传 `repoId` 即可,不会改变绑定。
89
+
66
90
  默认只给摘要;要某文件正文加 `path` 或 `detail=true`。
67
91
 
68
92
  ### 环境变量
69
93
 
70
- | 变量 | 说明 | 默认 |
71
- | --- | --- | --- |
94
+ | 变量 | 说明 | 默认 |
95
+ | ---------------------- | ------------------------------------ | ---------------- |
72
96
  | `GIT_COCKPIT_DATA_DIR` | 数据目录(仓库列表、审计日志、备份) | `~/.git-cockpit` |
73
- | `GIT_COCKPIT_PORT` | Web 服务端口 | `3000` |
74
- | `GIT_COCKPIT_HOST` | 监听地址 | `localhost` |
97
+ | `GIT_COCKPIT_PORT` | Web 服务端口 | `3000` |
98
+ | `GIT_COCKPIT_HOST` | 监听地址 | `localhost` |
75
99
 
76
100
  ## 主要特性
77
101
 
@@ -96,4 +120,4 @@ git-cockpit version # 输出版本号
96
120
 
97
121
  ## License
98
122
 
99
- MIT
123
+ MIT
@@ -4,12 +4,13 @@ import {
4
4
  McpHttpHandler,
5
5
  TOOL_DEFS,
6
6
  TOOL_DEF_MAP,
7
+ collectRepoOverviewForId,
7
8
  collectRepoOverviews,
8
9
  executeTool,
9
10
  summarizeForAgent,
10
11
  toolSummaries,
11
12
  version
12
- } from "./chunk-JSX53BCR.js";
13
+ } from "./chunk-FDPM74UE.js";
13
14
 
14
15
  // src/webServer.ts
15
16
  import * as path3 from "path";
@@ -66,7 +67,13 @@ import {
66
67
  import * as fs from "fs";
67
68
  import * as path from "path";
68
69
  import { randomBytes } from "crypto";
69
- import { DEFAULT_CONFIG, expandHome, normalizeLlmConfig, normalizeMrConfig, normalizePermissions } from "@shaohui_jin/git-cockpit-core";
70
+ import {
71
+ DEFAULT_CONFIG,
72
+ expandHome,
73
+ normalizeLlmConfig,
74
+ normalizeMrConfig,
75
+ normalizePermissions
76
+ } from "@shaohui_jin/git-cockpit-core";
70
77
  function deepMerge(target, source) {
71
78
  if (source === null || source === void 0) return target;
72
79
  if (Array.isArray(source)) {
@@ -168,11 +175,7 @@ var ConfigStore = class {
168
175
  // src/repoManager.ts
169
176
  import "events";
170
177
  import * as path2 from "path";
171
- import {
172
- assertRepoAllowed,
173
- GitService,
174
- RepoNotFoundError
175
- } from "@shaohui_jin/git-cockpit-core";
178
+ import { assertRepoAllowed, GitService, RepoNotFoundError } from "@shaohui_jin/git-cockpit-core";
176
179
  var RepoManager = class {
177
180
  constructor(options) {
178
181
  this.options = options;
@@ -185,6 +188,7 @@ var RepoManager = class {
185
188
  /** repo 根路径(规范化绝对路径) -> repoId */
186
189
  pathToId = /* @__PURE__ */ new Map();
187
190
  idToService = /* @__PURE__ */ new Map();
191
+ opening = /* @__PURE__ */ new Map();
188
192
  eventBus;
189
193
  list() {
190
194
  return this.options.repoStore.list().map((r) => ({
@@ -213,20 +217,38 @@ var RepoManager = class {
213
217
  throw err;
214
218
  }
215
219
  }
216
- /** 打开仓库:校验并记录;重复打开返回既有实例 */
220
+ /** 打开仓库:同一路径并发请求共享同一个 GitService 实例。 */
217
221
  async open(repoPath) {
222
+ const requestedKey = normalizePath(repoPath);
223
+ const inFlight = this.opening.get(requestedKey);
224
+ if (inFlight) return inFlight;
225
+ const task = this.openInternal(repoPath, requestedKey);
226
+ this.opening.set(requestedKey, task);
227
+ try {
228
+ return await task;
229
+ } finally {
230
+ this.opening.delete(requestedKey);
231
+ }
232
+ }
233
+ async openInternal(repoPath, requestedKey) {
234
+ const knownId = this.pathToId.get(requestedKey);
235
+ if (knownId !== void 0 && knownId !== 0) {
236
+ const known = await this.getById(knownId);
237
+ if (known) {
238
+ this.options.repoStore.open(known.service.repoPath);
239
+ return { service: known.service, record: this.options.repoStore.getById(knownId) ?? known.record };
240
+ }
241
+ }
218
242
  const service = await GitService.open(repoPath);
219
243
  assertRepoAllowed(service.repoPath, this.options.getConfig().git.allowedRepos);
220
244
  const root = service.repoPath;
221
245
  const key = normalizePath(root);
222
246
  const existingId = this.pathToId.get(key);
223
247
  if (existingId !== void 0 && existingId !== 0) {
224
- const record2 = this.options.repoStore.getById(existingId);
225
- if (record2) {
248
+ const existing = await this.getById(existingId);
249
+ if (existing) {
226
250
  this.options.repoStore.open(root);
227
- this.idToService.set(record2.id, service);
228
- this.attach(service);
229
- return { service, record: this.options.repoStore.getById(record2.id) ?? record2 };
251
+ return { service: existing.service, record: this.options.repoStore.getById(existingId) ?? existing.record };
230
252
  }
231
253
  }
232
254
  const record = this.options.repoStore.open(root);
@@ -356,8 +378,10 @@ import swaggerUi from "@fastify/swagger-ui";
356
378
  import { zodToJsonSchema } from "zod-to-json-schema";
357
379
  var REST_META = {
358
380
  "get /api/health": { summary: "\u63A2\u6D3B", tags: ["\u7CFB\u7EDF"] },
381
+ "post /api/shutdown": { summary: "\u4F18\u96C5\u9000\u51FA daemon\uFF08\u672C\u673A\u5BC6\u94A5\uFF1B\u6709 running \u4EFB\u52A1\u65F6 409\uFF09", tags: ["\u7CFB\u7EDF"] },
359
382
  "get /api/repos": { summary: "\u5DF2\u6253\u5F00\u4ED3\u5E93\u5217\u8868", tags: ["\u4ED3\u5E93"] },
360
- "get /api/repos/overview": { summary: "\u5DF2\u6253\u5F00\u4ED3\u5E93\u8109\u640F\uFF08\u5DE5\u4F5C\u53F0\uFF09", tags: ["\u4ED3\u5E93"] },
383
+ "get /api/repos/overview": { summary: "\u5DF2\u6253\u5F00\u4ED3\u5E93\u8109\u640F\uFF08\u5DE5\u4F5C\u53F0\uFF0C\u5168\u91CF\uFF09", tags: ["\u4ED3\u5E93"] },
384
+ "get /api/repos/{id}/overview": { summary: "\u5355\u4E2A\u4ED3\u5E93\u8109\u640F\uFF08\u5DE5\u4F5C\u53F0\u589E\u91CF\uFF09", tags: ["\u4ED3\u5E93"] },
361
385
  "post /api/jobs": { summary: "\u63D0\u4EA4\u540E\u53F0\u4EFB\u52A1\uFF08clone / survey / fetch\uFF09", tags: ["\u4ED3\u5E93"] },
362
386
  "post /api/repos/open": { summary: "\u6253\u5F00\u672C\u5730\u4ED3\u5E93", tags: ["\u4ED3\u5E93"] },
363
387
  "delete /api/repos/{id}": { summary: "\u5173\u95ED\u5E76\u79FB\u9664\u4ED3\u5E93", tags: ["\u4ED3\u5E93"] },
@@ -736,11 +760,15 @@ async function confirmChatWrite(runtime, gate, token) {
736
760
  if (!gate.previewsMatch(ticket.preview, fresh)) {
737
761
  return { ok: false, error: "\u4ED3\u5E93\u5DF2\u53D8\u5316\uFF0C\u8BF7\u91CD\u65B0\u9884\u89C8\u540E\u518D\u786E\u8BA4", tool: ticket.tool, preview: fresh };
738
762
  }
739
- const exec = await executeTool(def, { ...ticket.args, dryRun: false, repoPath: ticket.repoPath }, {
740
- runtime,
741
- source: "chat",
742
- repoPath: ticket.repoPath
743
- });
763
+ const exec = await executeTool(
764
+ def,
765
+ { ...ticket.args, dryRun: false, repoPath: ticket.repoPath },
766
+ {
767
+ runtime,
768
+ source: "chat",
769
+ repoPath: ticket.repoPath
770
+ }
771
+ );
744
772
  if (!exec.success) {
745
773
  return { ok: false, error: exec.error?.message ?? "\u6267\u884C\u5931\u8D25", tool: ticket.tool };
746
774
  }
@@ -967,6 +995,20 @@ async function createWebServer(runtime, options = {}) {
967
995
  }
968
996
  return { secret: runtime.config.auth.localSecret };
969
997
  });
998
+ app.post("/api/shutdown", async (_req, reply) => {
999
+ if (jobs.list().some((j) => j.status === "running")) {
1000
+ return reply.code(409).send({ error: "\u6709\u4EFB\u52A1\u8FDB\u884C\u4E2D\uFF0C\u8BF7\u5148\u7B49\u5F85\u7ED3\u675F" });
1001
+ }
1002
+ await reply.code(204).send();
1003
+ setImmediate(() => {
1004
+ void (async () => {
1005
+ mcpHttp.dispose();
1006
+ await app.close();
1007
+ disposeRuntime(runtime);
1008
+ if (!process.env.VITEST) process.exit(0);
1009
+ })();
1010
+ });
1011
+ });
970
1012
  app.get("/api/jobs", async () => ({
971
1013
  jobs: jobs.list().map((j) => jobs.summary(j))
972
1014
  }));
@@ -1070,6 +1112,17 @@ async function createWebServer(runtime, options = {}) {
1070
1112
  app.get("/api/repos/overview", async () => ({
1071
1113
  repos: await collectRepoOverviews(runtime)
1072
1114
  }));
1115
+ app.get("/api/repos/:id/overview", async (req, reply) => {
1116
+ const id = Number(req.params.id);
1117
+ if (!Number.isInteger(id)) {
1118
+ return reply.code(400).send({ error: "\u975E\u6CD5\u4ED3\u5E93 id" });
1119
+ }
1120
+ const overview = await collectRepoOverviewForId(runtime, id);
1121
+ if (!overview) {
1122
+ return reply.code(404).send({ error: "\u4ED3\u5E93\u4E0D\u5B58\u5728\u6216\u5DF2\u5931\u6548" });
1123
+ }
1124
+ return { overview };
1125
+ });
1073
1126
  app.get("/api/repos", async () => ({
1074
1127
  repos: runtime.repoManager.list()
1075
1128
  }));
@@ -1198,7 +1251,11 @@ async function createWebServer(runtime, options = {}) {
1198
1251
  );
1199
1252
  app.get(
1200
1253
  "/api/repos/:id/graph",
1201
- async (req, reply) => withRepo(req, reply, async ({ service }) => service.getGraph(req.query.maxCount ? Number(req.query.maxCount) : 500))
1254
+ async (req, reply) => withRepo(
1255
+ req,
1256
+ reply,
1257
+ async ({ service }) => service.getGraph(req.query.maxCount ? Number(req.query.maxCount) : 500)
1258
+ )
1202
1259
  );
1203
1260
  app.get(
1204
1261
  "/api/repos/:id/branch-graph",
@@ -1214,7 +1271,11 @@ async function createWebServer(runtime, options = {}) {
1214
1271
  );
1215
1272
  app.get(
1216
1273
  "/api/repos/:id/reflog",
1217
- async (req, reply) => withRepo(req, reply, async ({ service }) => service.getReflog(req.query.maxCount ? Number(req.query.maxCount) : 50))
1274
+ async (req, reply) => withRepo(
1275
+ req,
1276
+ reply,
1277
+ async ({ service }) => service.getReflog(req.query.maxCount ? Number(req.query.maxCount) : 50)
1278
+ )
1218
1279
  );
1219
1280
  app.get(
1220
1281
  "/api/repos/:id/backups",
@@ -1362,17 +1423,20 @@ async function createWebServer(runtime, options = {}) {
1362
1423
  })
1363
1424
  };
1364
1425
  });
1365
- app.post("/api/settings/mr-template/parse", async (req, reply) => {
1366
- const markdown = typeof req.body?.markdown === "string" ? req.body.markdown : "";
1367
- if (!markdown.trim()) return reply.code(400).send({ error: "\u8BF7\u63D0\u4F9B Markdown \u6587\u672C" });
1368
- try {
1369
- const template = parseMrTemplateMarkdown(markdown, req.body?.filename ?? "");
1370
- return { template, preview: renderMrTemplate(template) };
1371
- } catch (err) {
1372
- const message = err instanceof Error ? err.message : String(err);
1373
- return reply.code(400).send({ error: message });
1426
+ app.post(
1427
+ "/api/settings/mr-template/parse",
1428
+ async (req, reply) => {
1429
+ const markdown = typeof req.body?.markdown === "string" ? req.body.markdown : "";
1430
+ if (!markdown.trim()) return reply.code(400).send({ error: "\u8BF7\u63D0\u4F9B Markdown \u6587\u672C" });
1431
+ try {
1432
+ const template = parseMrTemplateMarkdown(markdown, req.body?.filename ?? "");
1433
+ return { template, preview: renderMrTemplate(template) };
1434
+ } catch (err) {
1435
+ const message = err instanceof Error ? err.message : String(err);
1436
+ return reply.code(400).send({ error: message });
1437
+ }
1374
1438
  }
1375
- });
1439
+ );
1376
1440
  app.post(
1377
1441
  "/api/settings/mr-template/preview",
1378
1442
  async (req, reply) => {
@@ -1546,6 +1610,10 @@ data: ${JSON.stringify(payload)}
1546
1610
  reply.hijack();
1547
1611
  await mcpHttp.handle(req.raw, reply.raw, req.body);
1548
1612
  });
1613
+ app.delete("/mcp", { schema: { hide: true } }, async (req, reply) => {
1614
+ reply.hijack();
1615
+ await mcpHttp.handle(req.raw, reply.raw, void 0);
1616
+ });
1549
1617
  if (staticDir) {
1550
1618
  app.setNotFoundHandler(async (req, reply) => {
1551
1619
  if (req.url.startsWith("/api") || req.url.startsWith("/mcp") || req.url.startsWith("/docs")) {
@@ -1726,4 +1794,4 @@ export {
1726
1794
  disposeRuntime,
1727
1795
  createWebServer
1728
1796
  };
1729
- //# sourceMappingURL=chunk-S7EIN5TE.js.map
1797
+ //# sourceMappingURL=chunk-563C4WZJ.js.map