Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": [
"packages/desktop/src/renderer/src/plugin-sandbox/genUiTweakRuntime.js",
"apps/ccbuddy-cli/packages/visualize-plugin/skills/visualize/assets/**",
"THIRD-PARTY-NOTICES.md",
"third-party/inventory.json",
"third-party/upstream/**",
Expand Down
2 changes: 1 addition & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ pnpm dev:desktop:test

Open the read-only history window from the desktop application menu. The original CCbuddy Agent workspace remains available. For web development run `pnpm dev:web`. After `pnpm bootstrap`, run `pnpm ccbuddy --version` at the repository root; the CLI package in [apps/ccbuddy-cli](apps/ccbuddy-cli) also declares a `ccbuddy` executable alias.

Validation: `pnpm typecheck`, `pnpm lint`, and `pnpm architecture:check --changed`. See the [history spec](docs/specs/history-review.md) for behavior and ownership.
Validation: `pnpm typecheck`, `pnpm lint`, and `pnpm architecture:check --changed`. See the [history spec](docs/specs/history-review.md) for behavior and ownership, and the [plugin UI / Gen UI spec](docs/specs/plugin-ui-and-gen-ui.md) (developer guide: [docs/ui-plugin.md](docs/ui-plugin.md), Chinese).

This derivative retains CCbuddy's Apache-2.0 [license](LICENSE), [notices](NOTICE.md), and [third-party notices](THIRD-PARTY-NOTICES.md). The prior CCbuddy Swift implementation was not copied into this application. Codex and Grok Build source code was not copied.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ pnpm lint
pnpm architecture:check --changed
```

会话行为、数据所有权和验收场景见 [历史查看规范](docs/specs/history-review.md)。四仓库关系及取舍见 [项目关系](docs/project-relationship.md)。
会话行为、数据所有权和验收场景见 [历史查看规范](docs/specs/history-review.md)。四仓库关系及取舍见 [项目关系](docs/project-relationship.md)。插件交互页面与 Gen UI 见 [UI Plugin 指南](docs/ui-plugin.md) 和 [规范](docs/specs/plugin-ui-and-gen-ui.md)。

## 来源与许可

Expand Down
1 change: 1 addition & 0 deletions apps/ccbuddy-cli/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ packages/**/tests/**/*.js.map
packages/**/tests/**/*.d.ts.map
.VSCodeCounter
vendor
!packages/visualize-plugin/skills/visualize/assets/vendor/
54 changes: 54 additions & 0 deletions apps/ccbuddy-cli/packages/adapters/src/mcp/appConnections.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { McpPort, McpServerConfig } from "@ccbuddy/contracts";
import { describe, expect, it, vi } from "vitest";
import { createMcpConnectionPool } from "./pool.js";

describe("MCP App pooled connection lifetime", () => {
it("forwards the trusted snapshot and invalidates one lease without closing another owner", async () => {
const listeners = new Set<() => void>();
const snapshot = { identity: "trusted", generation: 7 };
const adapter = {
connectServer: async () => ({ status: "connected" }),
listTools: async () => [],
status: async () => ({ fixture: { status: "connected" } }),
close: vi.fn(async () => {}),
appConnectionSnapshot: () => snapshot,
onAppConnectionInvalidated: (_name: string, listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
callTool: vi.fn(async () => {
throw new Error("Not connected");
}),
};
const createAdapter = vi.fn(() => adapter as unknown as McpPort);
const pool = createMcpConnectionPool({ createAdapter, idleGraceMs: 0 });
const a = pool.acquireLease({ sessionId: "a" }),
b = pool.acquireLease({ sessionId: "b" });
const config: McpServerConfig = { type: "stdio", command: "fixture", isolation: "workspace" };
await a.connectServer("fixture", config, { workingDirectory: "/fixture" });
await b.connectServer("fixture", config, { workingDirectory: "/fixture" });
expect(createAdapter).toHaveBeenCalledOnce();
expect(a.appConnectionSnapshot?.("fixture")).toEqual(snapshot);
const revokeA = vi.fn(),
revokeB = vi.fn();
a.onAppConnectionInvalidated?.("fixture", revokeA);
b.onAppConnectionInvalidated?.("fixture", revokeB);
await expect(
b.callTool({ serverName: "fixture", toolName: "increment" }, { appConnection: snapshot }),
).rejects.toThrow("Not connected");
expect(adapter.callTool).toHaveBeenCalledTimes(1);
await a.close();
expect(revokeA).toHaveBeenCalledOnce();
expect(revokeB).not.toHaveBeenCalled();
expect(a.appConnectionSnapshot?.("fixture")).toBeNull();
expect(b.appConnectionSnapshot?.("fixture")).toEqual(snapshot);
expect(listeners.size).toBe(1);
expect(adapter.close).not.toHaveBeenCalled();
await b.close();
expect(revokeB).toHaveBeenCalledOnce();
expect(listeners.size).toBe(0);
await pool.close();
});
});
27 changes: 22 additions & 5 deletions apps/ccbuddy-cli/packages/adapters/src/mcp/descriptor.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type {
JsonSchema,
McpToolAnnotations,
McpToolDescriptor,
import {
MCP_TOOL_META_TIMEOUT_MAX_MS,
MCP_TOOL_META_TIMEOUT_MIN_MS,
type JsonSchema,
type McpToolAnnotations,
type McpToolDescriptor,
} from "@ccbuddy/contracts";
import { normalizeMcpToolUiMeta, readMcpToolVisibility } from "@ccbuddy/shared/mcp-apps";

export function normalizeMcpToolDescriptor(
serverName: string,
Expand All @@ -12,21 +15,35 @@ export function normalizeMcpToolDescriptor(
): McpToolDescriptor {
const record = isRecord(tool) ? tool : {};
const toolName = typeof record.name === "string" ? record.name : "unknown";
// 插件 UI:tools/list 的 `_meta` 只在这里读一次,归一化成 descriptor.ui;下游不再碰原始 _meta。
const ui = normalizeMcpToolUiMeta(record._meta);
return {
serverName,
toolName,
name: `mcp__${sanitizeMcpName(serverName)}__${sanitizeMcpName(toolName)}`,
description: typeof record.description === "string" ? record.description : undefined,
timeoutMs,
// 工具级 `_meta.timeoutMs`(A8)覆盖服务器级超时:长任务(下载引擎、渲染)按工具声明,上限 30 分钟。
timeoutMs: readToolMetaTimeoutMs(record._meta) ?? timeoutMs,
inputSchema: normalizeInputSchema(record.inputSchema),
outputSchema: isRecord(record.outputSchema) ? (record.outputSchema as JsonSchema) : undefined,
annotations: normalizeAnnotations(record.annotations),
// 只有 http 官方 MCP 的 tool error 标识才被信任——那种形态的响应来自已校验 origin 的
// CCbuddy 后端,插件伪造不了(判据与代价见 mcp.port.ts 的 official 字段说明)。
...(official ? { official: true } : {}),
// 可见性独立于 UI 呈现——没有 resourceUri 的 app-only 数据工具也不能进模型工具表。
visibility: readMcpToolVisibility(record._meta),
...(ui ? { ui } : {}),
};
}

function readToolMetaTimeoutMs(meta: unknown): number | undefined {
if (!isRecord(meta)) return undefined;
const value = meta.timeoutMs;
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
if (value < MCP_TOOL_META_TIMEOUT_MIN_MS) return undefined;
return Math.min(Math.floor(value), MCP_TOOL_META_TIMEOUT_MAX_MS);
}

function normalizeInputSchema(schema: unknown): JsonSchema {
if (!isRecord(schema)) {
return {
Expand Down
Loading
Loading