diff --git a/packages/coc-client/src/contracts/workspaces.ts b/packages/coc-client/src/contracts/workspaces.ts index a81fb4974..a4d3749a5 100644 --- a/packages/coc-client/src/contracts/workspaces.ts +++ b/packages/coc-client/src/contracts/workspaces.ts @@ -99,10 +99,45 @@ export interface WorkspaceMcpConfigResponse { availableServers: WorkspaceMcpServerEntry[]; enabledMcpServers: string[] | null; sources: WorkspaceMcpSources; + /** + * Per-repo allow-list of enabled tools, keyed by server name. Allow-list + * semantics: a server with no entry has all tools enabled; an entry lists the + * tool names that remain enabled (any tool not listed — including newly + * discovered ones — is disabled). `null`/absent means no allow-list at all. + */ + enabledMcpTools?: Record | null; } export interface UpdateWorkspaceMcpConfigRequest { enabledMcpServers: string[] | null; + /** + * Optional per-repo enabled-tools allow-list. Omit to leave unchanged; pass + * `null` to clear it; pass a `Record` to replace it. + */ + enabledMcpTools?: Record | null; +} + +/** A single tool reported by an MCP server's live `tools/list`. */ +export interface McpDiscoveredTool { + name: string; + description?: string; + /** JSON Schema describing the tool's input (display-only). */ + inputSchema?: unknown; +} + +/** Per-server result of live MCP tool discovery. */ +export interface McpServerToolsResult { + status: 'ok' | 'error'; + tools: McpDiscoveredTool[]; + /** Present when `status === 'error'`. */ + error?: string; + /** Server's self-reported name, when known. */ + serverName?: string; +} + +/** Response of `GET /workspaces/:id/mcp-config/tools`. */ +export interface WorkspaceMcpToolsResponse { + servers: Record; } export type WorkspaceInstructionMode = 'base' | 'ask' | 'autopilot'; diff --git a/packages/coc-client/src/domains/workspaces.ts b/packages/coc-client/src/domains/workspaces.ts index 0e1933bb0..d63de2d10 100644 --- a/packages/coc-client/src/domains/workspaces.ts +++ b/packages/coc-client/src/domains/workspaces.ts @@ -36,6 +36,7 @@ import type { WorkspaceInstructionResponse, WorkspaceInstructionsResponse, WorkspaceMcpConfigResponse, + WorkspaceMcpToolsResponse, WorkspaceSummaryOptions, WorkspaceSummaryResponse, WorkspacesResponse, @@ -144,12 +145,25 @@ export class WorkspacesClient { } updateMcpConfig(workspaceId: string, request: UpdateWorkspaceMcpConfigRequest): Promise<{ workspace: WorkspaceInfo }> { + const body: Record = { + enabledMcpServers: request.enabledMcpServers === null ? null : [...request.enabledMcpServers], + }; + if (Object.prototype.hasOwnProperty.call(request, 'enabledMcpTools')) { + body.enabledMcpTools = request.enabledMcpTools ?? null; + } return this.transport.request<{ workspace: WorkspaceInfo }>(`/workspaces/${encodePathSegment(workspaceId)}/mcp-config`, { method: 'PUT', - body: { enabledMcpServers: request.enabledMcpServers === null ? null : [...request.enabledMcpServers] }, + body, }); } + discoverMcpTools(workspaceId: string, options?: { forceReload?: boolean }): Promise { + return this.transport.request( + `/workspaces/${encodePathSegment(workspaceId)}/mcp-config/tools`, + { query: options?.forceReload ? { forceReload: true } : undefined }, + ); + } + getMcpServerDetail(workspaceId: string, serverName: string): Promise { return this.transport.request( `/workspaces/${encodePathSegment(workspaceId)}/mcp-config/${encodePathSegment(serverName)}/detail`, diff --git a/packages/coc/src/server/executors/chat-base-executor.ts b/packages/coc/src/server/executors/chat-base-executor.ts index 1a0249ea3..e8d118210 100644 --- a/packages/coc/src/server/executors/chat-base-executor.ts +++ b/packages/coc/src/server/executors/chat-base-executor.ts @@ -66,6 +66,7 @@ import type { MemoryV2Addon } from './memory-v2-addon'; import { resolveAutoFolderContext } from './auto-folder-utils'; import { systemMessageBuilder } from './system-message-builder'; import { buildChatTurnContext } from './chat-turn-context-builder'; +import { resolveChatMcpServersForWorkspace } from './mcp-tool-enforcement'; import { attachRalphGrillMetadataToAskUserPayloads, buildRalphGrillPlanningCompletedProgress, buildRalphGrillPlanningStartedProgress, buildRalphGrillProcessStateFromPlan, buildRalphMultiAgentGrillDirective, formatRalphGrillQuestionPlanForPrompt, planRalphGrillCandidateQuestions } from '../ralph/grill-planning'; import type { RalphGrillPlanningProgress, RalphGrillQuestionPlanningResult, RalphGrillSetup } from '../ralph/grill-planning'; // ============================================================================ @@ -743,6 +744,18 @@ export abstract class ChatBaseExecutor extends BaseExecutor { // The SDK's native onUserInputRequest must NOT be set at the same time. assertNoAskUserConflict({ tools: sendTools }); + // AC-04 — Apply the per-repo MCP allow-lists (server-level + // `enabledMcpServers` + per-tool `enabledMcpTools`) to the + // dashboard chat/session path. When resolved, the explicit map is + // sent with `loadDefaultMcpConfig: false` so disabled tools/servers + // never reach the agent. `undefined` preserves the SDK default load. + const resolvedMcpServers = await resolveChatMcpServersForWorkspace({ + store: this.store, + dataDir: this.dataDir, + workspaceId: payload.workspaceId, + workingDirectory, + }); + const sendOptions = { prompt: effectivePrompt, mode: agentMode, @@ -758,6 +771,7 @@ export abstract class ChatBaseExecutor extends BaseExecutor { skillDirectories, disabledSkills, ...(excludedTools && excludedTools.length > 0 ? { excludedTools } : {}), + ...(resolvedMcpServers ? { mcpServers: resolvedMcpServers, loadDefaultMcpConfig: false } : {}), onPermissionRequest: this.approvePermissions ? approveAllPermissions : undefined, onSessionCreated: (sessionId: string) => { this.store.updateProcess(processId, { sdkSessionId: sessionId }).catch(() => { diff --git a/packages/coc/src/server/executors/follow-up-executor.ts b/packages/coc/src/server/executors/follow-up-executor.ts index e938a8b56..4163aa641 100644 --- a/packages/coc/src/server/executors/follow-up-executor.ts +++ b/packages/coc/src/server/executors/follow-up-executor.ts @@ -53,6 +53,7 @@ import { ChatBaseExecutor } from './chat-base-executor'; import type { ProcessWebSocketServer } from '../streaming/websocket'; import { buildChatTurnContext } from './chat-turn-context-builder'; import type { ChatTurnContext } from './chat-turn-context-builder'; +import { resolveChatMcpServersForWorkspace } from './mcp-tool-enforcement'; import { updateForEachGenerationMetadataFromAssistantTurn } from '../for-each/for-each-generation-metadata'; import { updateMapReduceGenerationMetadataFromAssistantTurn } from '../map-reduce/map-reduce-generation-metadata'; // ============================================================================ @@ -448,6 +449,18 @@ export class FollowUpExecutor extends ChatBaseExecutor { ? getCopilotContextTierForModel(reasoningModelMetadata) : undefined; + // AC-04 — Apply the per-repo MCP allow-lists (server-level + // `enabledMcpServers` + per-tool `enabledMcpTools`) to the + // dashboard chat/session follow-up path. When resolved, the explicit + // map is sent with `loadDefaultMcpConfig: false` so disabled + // tools/servers never reach the agent on a follow-up turn. + const resolvedMcpServers = await resolveChatMcpServersForWorkspace({ + store: this.store, + dataDir: this.dataDir, + workspaceId: wsId, + workingDirectory, + }); + const sendOptions = { prompt: followUpMessage, sessionId: process.sdkSessionId, @@ -465,6 +478,7 @@ export class FollowUpExecutor extends ChatBaseExecutor { ...(chatCtx.excludedTools.length > 0 ? { excludedTools: chatCtx.excludedTools } : {}), + ...(resolvedMcpServers ? { mcpServers: resolvedMcpServers, loadDefaultMcpConfig: false } : {}), skillDirectories, disabledSkills, onSessionCreated: (sessionId: string) => { diff --git a/packages/coc/src/server/executors/mcp-tool-enforcement.ts b/packages/coc/src/server/executors/mcp-tool-enforcement.ts new file mode 100644 index 000000000..2e472110e --- /dev/null +++ b/packages/coc/src/server/executors/mcp-tool-enforcement.ts @@ -0,0 +1,146 @@ +/** + * MCP per-tool runtime enforcement for the dashboard chat/session path. + * + * The repo settings MCP page lets users toggle individual MCP tools off. Those + * toggles are persisted in the per-repo `enabledMcpTools` allow-list (server → + * list of ENABLED tool names) with these semantics: + * + * - A server with **no entry** has *all* its tools enabled. + * - Once an entry exists, only the listed tools are enabled — any tool not in + * the list (including a newly discovered tool) is disabled. + * + * This module turns that allow-list into a concrete `mcpServers` map for an SDK + * `sendMessage` call. Because `MCPServerConfig.tools` is itself a per-server + * whitelist ("list of tools to enable; `['*']` = all"), the allow-list maps + * directly onto it — no runtime discovery of the full tool set is required. + * + * Enforcement is intentionally scoped to the chat/session executors. The + * workflow path resolves its own `mcpServers` in `workflows-write-handler.ts` + * and the CLI path (`ai-invoker.ts`) is out of scope. + * + * Side effect (accepted): resolving `mcpServers` here and sending it with + * `loadDefaultMcpConfig: false` also makes chat honor the server-level + * `enabledMcpServers` allow-list. In the common no-toggle case the resolved map + * is identical to the SDK's default global+workspace load, so behavior is + * preserved; it only narrows the set when a server or tool is actually disabled. + * + * No VS Code dependencies — uses only Node.js built-in modules. + * Cross-platform compatible (Linux/Mac/Windows). + */ + +import type { MCPServerConfig, ProcessStore, WorkspaceInfo } from '@plusplusoneplusplus/forge'; +import { loadEffectiveMcpConfig } from '@plusplusoneplusplus/forge'; +import { readRepoPreferences } from '../preferences-handler'; + +/** Per-server allow-list of ENABLED tool names. */ +export type EnabledMcpToolsMap = Record; + +/** + * Apply the server-level (`enabledMcpServers`) and tool-level + * (`enabledMcpTools`) allow-lists to a resolved MCP server map. + * + * Pure — does no I/O — so the allow-list semantics can be unit-tested directly. + * + * @param allServers the effective (global + workspace) server configs + * @param enabledMcpServers server-level allow-list; `null`/`undefined` = all on + * @param enabledMcpTools per-server tool allow-list; `null`/`undefined` = all on + * @returns the filtered server map to send explicitly, or `undefined` when there + * are no servers configured at all (caller should then fall back to the + * SDK's default load to preserve behavior). An empty `{}` is returned + * when servers exist but every one is disabled — `{}` tells the SDK to + * disable all MCP servers, which is the intended outcome. + */ +export function applyMcpAllowList( + allServers: Record, + enabledMcpServers: string[] | null | undefined, + enabledMcpTools: EnabledMcpToolsMap | null | undefined, +): Record | undefined { + const serverNames = Object.keys(allServers); + if (serverNames.length === 0) { + // Nothing configured — let the caller preserve the SDK default behavior + // rather than sending `{}` (which would mean "disable all MCP servers"). + return undefined; + } + + const toolsMap = enabledMcpTools ?? {}; + const result: Record = {}; + + for (const name of serverNames) { + const serverEnabled = + enabledMcpServers === null || enabledMcpServers === undefined || enabledMcpServers.includes(name); + if (!serverEnabled) continue; + + const config = allServers[name]; + const toolEntry = toolsMap[name]; + // No entry → keep the server's existing tools (already defaulted to + // ['*'] by the config loaders). Entry present → enable exactly those + // tool names; an empty entry ([]) disables every tool on the server. + const tools = toolEntry === undefined ? (config.tools ?? ['*']) : [...toolEntry]; + result[name] = { ...config, tools }; + } + + return result; +} + +/** + * Resolve the effective MCP config for a working directory and apply the + * allow-lists. Reads (cached) global + workspace MCP config from disk. + */ +export function resolveChatMcpServers(input: { + rootPath: string | undefined; + enabledMcpServers: string[] | null | undefined; + enabledMcpTools: EnabledMcpToolsMap | null | undefined; + forceReload?: boolean; +}): Record | undefined { + if (!input.rootPath) return undefined; + const effective = loadEffectiveMcpConfig({ + workingDirectory: input.rootPath, + forceReload: input.forceReload, + }); + return applyMcpAllowList(effective.mcpServers, input.enabledMcpServers, input.enabledMcpTools); +} + +/** + * Look up a workspace's server-level + tool-level allow-lists and resolve the + * `mcpServers` map for a chat/session turn. + * + * Returns `undefined` (caller falls back to the SDK default load) when there is + * no workspace context or no MCP servers are configured. Never throws — any + * lookup failure degrades to `undefined` so a chat turn is never blocked by + * allow-list resolution. + */ +export async function resolveChatMcpServersForWorkspace(opts: { + store: ProcessStore; + dataDir: string | undefined; + workspaceId: string | undefined; + workingDirectory: string | undefined; + forceReload?: boolean; +}): Promise | undefined> { + if (!opts.workspaceId) return undefined; + try { + let workspace: WorkspaceInfo | undefined; + try { + const workspaces = await opts.store.getWorkspaces(); + workspace = workspaces.find(ws => ws.id === opts.workspaceId); + } catch { + workspace = undefined; + } + + const rootPath = workspace?.rootPath ?? opts.workingDirectory; + if (!rootPath) return undefined; + + const enabledMcpTools = opts.dataDir + ? readRepoPreferences(opts.dataDir, opts.workspaceId).enabledMcpTools ?? undefined + : undefined; + + return resolveChatMcpServers({ + rootPath, + enabledMcpServers: workspace?.enabledMcpServers, + enabledMcpTools, + forceReload: opts.forceReload, + }); + } catch { + // Resolution must never break a chat turn — fall back to default load. + return undefined; + } +} diff --git a/packages/coc/src/server/routes/api-workspace-routes.ts b/packages/coc/src/server/routes/api-workspace-routes.ts index f3d49daaa..3b1e99982 100644 --- a/packages/coc/src/server/routes/api-workspace-routes.ts +++ b/packages/coc/src/server/routes/api-workspace-routes.ts @@ -40,6 +40,7 @@ import { type McpConfigScope, } from './mcp-config-writer'; import { testMcpConnection } from './mcp-connection-tester'; +import { discoverWorkspaceMcpTools } from './mcp-tools-discovery'; import { readMcpServerAuthInfo, type McpServerAuthStatus } from '../mcp-oauth'; // Lazy singleton service @@ -432,7 +433,26 @@ export function registerApiWorkspaceRoutes(ctx: ApiRouteContext): void { if (!ws) return; const parsed = url.parse(req.url || '/', true); const forceReload = parsed.query.forceReload === 'true' || parsed.query.refresh === 'true'; - sendJSON(res, 200, buildMcpConfigResponse(ws, forceReload)); + // Surface the per-repo enabled-tools allow-list so the UI can render + // and round-trip per-tool toggles (AC-03 allow-list semantics). + const enabledMcpTools = ctx.dataDir + ? readRepoPreferences(ctx.dataDir, ws.id).enabledMcpTools ?? null + : null; + sendJSON(res, 200, { ...buildMcpConfigResponse(ws, forceReload), enabledMcpTools }); + }, + }); + + // GET /api/workspaces/:id/mcp-config/tools — Live-discover tools for all enabled MCP servers + routes.push({ + method: 'GET', + pattern: /^\/api\/workspaces\/([^/]+)\/mcp-config\/tools$/, + handler: async (req, res, match) => { + const ws = await resolveWorkspaceOrFail(store, match!, res); + if (!ws) return; + const parsed = url.parse(req.url || '/', true); + const forceReload = parsed.query.forceReload === 'true' || parsed.query.refresh === 'true'; + const servers = await discoverWorkspaceMcpTools(ws.rootPath, ws.enabledMcpServers, { forceReload }); + sendJSON(res, 200, { servers }); }, }); diff --git a/packages/coc/src/server/routes/mcp-connection-tester.ts b/packages/coc/src/server/routes/mcp-connection-tester.ts index e32804674..45cf6eceb 100644 --- a/packages/coc/src/server/routes/mcp-connection-tester.ts +++ b/packages/coc/src/server/routes/mcp-connection-tester.ts @@ -21,6 +21,8 @@ export interface McpTestRequest { url?: string; args?: string[]; env?: Record; + /** Optional HTTP headers for http/sse transports (e.g. auth). */ + headers?: Record; } export interface McpTestResult { @@ -32,6 +34,25 @@ export interface McpTestResult { serverName?: string; } +/** A single tool as reported by an MCP server's `tools/list`. */ +export interface McpToolInfo { + name: string; + description?: string; + /** JSON Schema describing the tool's input (display-only). */ + inputSchema?: unknown; +} + +/** Result of a live `tools/list` discovery against one MCP server. */ +export interface McpListToolsResult { + success: boolean; + message: string; + tools: McpToolInfo[]; + /** Server's declared protocolVersion, if returned. */ + protocolVersion?: string; + /** Server's declared name, if returned. */ + serverName?: string; +} + // ============================================================================ // Public entry point // ============================================================================ @@ -248,3 +269,336 @@ function testHttpMcpServer(req: McpTestRequest): Promise { clientReq.end(); }); } + +// ============================================================================ +// Live tool discovery (`tools/list`) +// ============================================================================ + +/** Default per-server timeout for a full `initialize` + `tools/list` handshake. */ +const LIST_TOOLS_TIMEOUT_MS = 10_000; + +/** + * Connect to an MCP server and list its tools. + * - stdio: spawns the process, performs the `initialize` handshake, sends the + * `notifications/initialized` notification, then issues `tools/list`. + * - http/sse: performs the same JSON-RPC handshake over Streamable HTTP POSTs, + * honoring any `Mcp-Session-Id` the server assigns at initialize time. + * + * The process (stdio) is always killed after the call. Errors are returned as + * `{ success: false, message, tools: [] }` rather than thrown so callers can + * isolate per-server failures. + */ +export async function listMcpTools( + req: McpTestRequest, + timeoutMs: number = LIST_TOOLS_TIMEOUT_MS, +): Promise { + if (req.type === 'stdio') { + return listStdioMcpTools(req, timeoutMs); + } + return listHttpMcpTools(req, timeoutMs); +} + +/** Normalize a raw MCP tool object into `McpToolInfo`, or `null` if invalid. */ +function normalizeToolEntry(raw: unknown): McpToolInfo | null { + if (typeof raw !== 'object' || raw === null) return null; + const obj = raw as Record; + if (typeof obj.name !== 'string' || !obj.name) return null; + const tool: McpToolInfo = { name: obj.name }; + if (typeof obj.description === 'string') tool.description = obj.description; + if (obj.inputSchema !== undefined) tool.inputSchema = obj.inputSchema; + return tool; +} + +const INITIALIZE_PARAMS = { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'coc-discovery', version: '1.0.0' }, +}; + +// ---------------------------------------------------------------------------- +// stdio transport +// ---------------------------------------------------------------------------- + +function listStdioMcpTools(req: McpTestRequest, timeoutMs: number): Promise { + return new Promise((resolve) => { + const command = req.command; + if (!command) { + resolve({ success: false, message: '`command` is required for stdio transport', tools: [] }); + return; + } + + const args = req.args ?? []; + const mergedEnv = { ...process.env, ...(req.env ?? {}) }; + + let child: ReturnType; + try { + child = spawn(command, args, { + env: mergedEnv, + stdio: ['pipe', 'pipe', 'pipe'], + shell: false, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + resolve({ success: false, message: `Failed to spawn process: ${msg}`, tools: [] }); + return; + } + + let settled = false; + let stdout = ''; + let protocolVersion: string | undefined; + let serverName: string | undefined; + + const done = (result: McpListToolsResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { child.kill('SIGKILL'); } catch { /* ignore */ } + resolve(result); + }; + const fail = (message: string) => done({ success: false, message, tools: [] }); + + const timer = setTimeout(() => { + fail(`Timed out waiting for MCP tools/list response (${Math.round(timeoutMs / 1000)} s)`); + }, timeoutMs); + + const send = (obj: unknown) => { + try { + child.stdin?.write(JSON.stringify(obj) + '\n'); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + fail(`Failed to write to process stdin: ${msg}`); + } + }; + + child.on('error', (err) => fail(`Process error: ${err.message}`)); + child.on('close', (code) => { + if (!settled) fail(`Process exited with code ${code} before responding`); + }); + + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf-8'); + const lines = stdout.split('\n'); + stdout = lines.pop() ?? ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + let parsed: unknown; + try { parsed = JSON.parse(trimmed); } catch { continue; } + if (typeof parsed !== 'object' || parsed === null) continue; + const obj = parsed as Record; + if (obj.jsonrpc !== '2.0') continue; + + if (obj.id === 1) { + if ('error' in obj) { + fail(`MCP server returned error during initialize: ${describeRpcError(obj.error)}`); + return; + } + const result = obj.result as Record | undefined; + if (typeof result?.protocolVersion === 'string') protocolVersion = result.protocolVersion; + const serverInfo = result?.serverInfo as Record | undefined; + if (typeof serverInfo?.name === 'string') serverName = serverInfo.name; + // Complete the handshake, then ask for the tool list. + send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }); + send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + } else if (obj.id === 2) { + if ('error' in obj) { + fail(`MCP server returned error listing tools: ${describeRpcError(obj.error)}`); + return; + } + const result = obj.result as Record | undefined; + const rawTools = Array.isArray(result?.tools) ? result.tools : []; + const tools = rawTools.map(normalizeToolEntry).filter((t): t is McpToolInfo => t !== null); + done({ + success: true, + message: `Discovered ${tools.length} tool(s)`, + tools, + ...(protocolVersion ? { protocolVersion } : {}), + ...(serverName ? { serverName } : {}), + }); + } + } + }); + + send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: INITIALIZE_PARAMS }); + }); +} + +function describeRpcError(error: unknown): string { + const errObj = error as Record | null | undefined; + return typeof errObj?.message === 'string' ? errObj.message : JSON.stringify(errObj); +} + +// ---------------------------------------------------------------------------- +// HTTP / SSE transport (Streamable HTTP) +// ---------------------------------------------------------------------------- + +interface JsonRpcPostResult { + statusCode: number; + sessionId?: string; + messages: Array>; +} + +/** Parse newline/blank-delimited SSE `data:` lines into JSON-RPC message objects. */ +function parseSseMessages(raw: string): Array> { + const messages: Array> = []; + const blocks = raw.split(/\r?\n\r?\n/); + for (const block of blocks) { + const dataLines = block + .split(/\r?\n/) + .filter((l) => l.startsWith('data:')) + .map((l) => l.slice(5).replace(/^ /, '')); + if (dataLines.length === 0) continue; + try { + const parsed = JSON.parse(dataLines.join('\n')); + if (Array.isArray(parsed)) { + for (const m of parsed) if (m && typeof m === 'object') messages.push(m as Record); + } else if (parsed && typeof parsed === 'object') { + messages.push(parsed as Record); + } + } catch { /* ignore malformed event */ } + } + return messages; +} + +function postMcpJsonRpc( + parsedUrl: URL, + transport: typeof http | typeof https, + body: unknown, + extraHeaders: Record, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(body); + const options: http.RequestOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? '443' : '80'), + path: parsedUrl.pathname + parsedUrl.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'Content-Length': Buffer.byteLength(payload), + ...extraHeaders, + }, + timeout: timeoutMs, + }; + + const clientReq = transport.request(options, (incomingRes) => { + let data = ''; + incomingRes.on('data', (chunk: Buffer) => { data += chunk.toString('utf-8'); }); + incomingRes.on('end', () => { + const contentType = String(incomingRes.headers['content-type'] ?? ''); + const sessionHeader = incomingRes.headers['mcp-session-id']; + let messages: Array> = []; + if (contentType.includes('text/event-stream')) { + messages = parseSseMessages(data); + } else if (data.trim()) { + try { + const parsed = JSON.parse(data); + if (Array.isArray(parsed)) { + messages = parsed.filter((m) => m && typeof m === 'object'); + } else if (parsed && typeof parsed === 'object') { + messages = [parsed]; + } + } catch { /* ignore non-JSON body */ } + } + resolve({ + statusCode: incomingRes.statusCode ?? 0, + sessionId: typeof sessionHeader === 'string' ? sessionHeader : undefined, + messages, + }); + }); + }); + + clientReq.on('error', (err) => reject(err)); + clientReq.on('timeout', () => { + clientReq.destroy(); + reject(new Error(`HTTP connection timed out (${Math.round(timeoutMs / 1000)} s)`)); + }); + clientReq.write(payload); + clientReq.end(); + }); +} + +async function listHttpMcpTools(req: McpTestRequest, timeoutMs: number): Promise { + const rawUrl = req.url; + if (!rawUrl) { + return { success: false, message: '`url` is required for http/sse transport', tools: [] }; + } + let parsedUrl: URL; + try { + parsedUrl = new URL(rawUrl); + } catch { + return { success: false, message: `Invalid URL: ${rawUrl}`, tools: [] }; + } + + const transport = parsedUrl.protocol === 'https:' ? https : http; + const baseHeaders = req.headers ?? {}; + + try { + const initRes = await postMcpJsonRpc( + parsedUrl, + transport, + { jsonrpc: '2.0', id: 1, method: 'initialize', params: INITIALIZE_PARAMS }, + baseHeaders, + timeoutMs, + ); + if (initRes.statusCode >= 400) { + return { success: false, message: `Server responded with HTTP ${initRes.statusCode} during initialize`, tools: [] }; + } + const initMsg = initRes.messages.find((m) => m.id === 1); + if (initMsg && 'error' in initMsg) { + return { success: false, message: `MCP server returned error during initialize: ${describeRpcError(initMsg.error)}`, tools: [] }; + } + const initResult = initMsg?.result as Record | undefined; + const protocolVersion = typeof initResult?.protocolVersion === 'string' ? initResult.protocolVersion : undefined; + const serverInfo = initResult?.serverInfo as Record | undefined; + const serverName = typeof serverInfo?.name === 'string' ? serverInfo.name : undefined; + + const sessionHeaders = initRes.sessionId + ? { ...baseHeaders, 'Mcp-Session-Id': initRes.sessionId } + : baseHeaders; + + // Best-effort handshake completion; some servers require it before tools/list. + try { + await postMcpJsonRpc( + parsedUrl, + transport, + { jsonrpc: '2.0', method: 'notifications/initialized', params: {} }, + sessionHeaders, + timeoutMs, + ); + } catch { /* notification failures are non-fatal */ } + + const toolsRes = await postMcpJsonRpc( + parsedUrl, + transport, + { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, + sessionHeaders, + timeoutMs, + ); + if (toolsRes.statusCode >= 400) { + return { success: false, message: `Server responded with HTTP ${toolsRes.statusCode} during tools/list`, tools: [] }; + } + const toolsMsg = toolsRes.messages.find((m) => m.id === 2); + if (!toolsMsg) { + return { success: false, message: 'No tools/list response received from server', tools: [] }; + } + if ('error' in toolsMsg) { + return { success: false, message: `MCP server returned error listing tools: ${describeRpcError(toolsMsg.error)}`, tools: [] }; + } + const result = toolsMsg.result as Record | undefined; + const rawTools = Array.isArray(result?.tools) ? result.tools : []; + const tools = rawTools.map(normalizeToolEntry).filter((t): t is McpToolInfo => t !== null); + return { + success: true, + message: `Discovered ${tools.length} tool(s)`, + tools, + ...(protocolVersion ? { protocolVersion } : {}), + ...(serverName ? { serverName } : {}), + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { success: false, message: `Connection failed: ${msg}`, tools: [] }; + } +} diff --git a/packages/coc/src/server/routes/mcp-tools-discovery.ts b/packages/coc/src/server/routes/mcp-tools-discovery.ts new file mode 100644 index 000000000..ceeac8e93 --- /dev/null +++ b/packages/coc/src/server/routes/mcp-tools-discovery.ts @@ -0,0 +1,142 @@ +/** + * MCP Tools Discovery + * + * Eager, live discovery of the tools exposed by a workspace's *enabled* MCP + * servers. Resolves the effective (global + workspace) MCP config, filters it + * by the workspace `enabledMcpServers` allow-list, then connects to each server + * concurrently — reusing the JSON-RPC handshake in `mcp-connection-tester.ts` — + * and returns a per-server result. + * + * Failures are isolated per server: one slow or unreachable server yields a + * `{ status: 'error' }` entry instead of failing the whole batch. Each + * connection is bounded by a per-server timeout. + */ + +import type { MCPServerConfig } from '@plusplusoneplusplus/forge'; +import { loadDefaultMcpConfig, loadWorkspaceMcpConfig } from '@plusplusoneplusplus/forge'; +import { listMcpTools, type McpTestRequest, type McpToolInfo } from './mcp-connection-tester'; + +/** Per-server discovery result returned to the client. */ +export interface McpServerToolsResult { + status: 'ok' | 'error'; + tools: McpToolInfo[]; + /** Error message when `status === 'error'`. */ + error?: string; + /** Server's self-reported name, when known. */ + serverName?: string; +} + +/** Default per-server connect/list timeout. */ +const DEFAULT_DISCOVERY_TIMEOUT_MS = 10_000; +/** How many servers to probe at once. */ +const DEFAULT_DISCOVERY_CONCURRENCY = 4; + +export interface DiscoverOptions { + /** Per-server timeout in ms. */ + timeoutMs?: number; + /** Max concurrent server probes. */ + concurrency?: number; + /** Bypass the MCP config file cache when resolving servers. */ + forceReload?: boolean; +} + +/** + * Convert a resolved `MCPServerConfig` into a connection request for the tester. + * Returns `null` for configs missing the fields needed to connect. + */ +export function configToTestRequest(config: MCPServerConfig): McpTestRequest | null { + const type = config.type === 'http' || config.type === 'sse' ? config.type : 'stdio'; + if (type === 'stdio') { + const command = 'command' in config ? config.command : undefined; + if (!command) return null; + const req: McpTestRequest = { type: 'stdio', command }; + if ('args' in config && config.args) req.args = config.args; + if ('env' in config && config.env) req.env = config.env; + return req; + } + const url = 'url' in config ? config.url : undefined; + if (!url) return null; + const req: McpTestRequest = { type, url }; + if ('headers' in config && config.headers) req.headers = config.headers; + return req; +} + +/** + * Resolve the workspace's *enabled* MCP servers to connection requests. + * Mirrors the effective merge used elsewhere: workspace entries override global + * entries with the same name. `enabledMcpServers === null/undefined` means all + * servers are enabled. + */ +export function resolveEnabledMcpServers( + rootPath: string, + enabledMcpServers: string[] | null | undefined, + forceReload = false, +): Record { + const globalConfig = loadDefaultMcpConfig(forceReload); + const workspaceConfig = loadWorkspaceMcpConfig(rootPath, forceReload); + const merged: Record = { + ...globalConfig.mcpServers, + ...workspaceConfig.mcpServers, + }; + + const result: Record = {}; + for (const [name, config] of Object.entries(merged)) { + const isEnabled = + enabledMcpServers === null || enabledMcpServers === undefined || enabledMcpServers.includes(name); + if (!isEnabled) continue; + const req = configToTestRequest(config); + if (req) result[name] = req; + } + return result; +} + +/** + * Probe a map of named MCP servers concurrently and return per-server results. + * Each probe is isolated — a thrown/failed connection becomes a + * `{ status: 'error' }` entry, never rejecting the batch. + */ +export async function discoverMcpToolsForServers( + servers: Record, + opts: DiscoverOptions = {}, +): Promise> { + const timeoutMs = opts.timeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS; + const concurrency = Math.max(1, opts.concurrency ?? DEFAULT_DISCOVERY_CONCURRENCY); + + const names = Object.keys(servers); + const results: Record = {}; + + for (let i = 0; i < names.length; i += concurrency) { + const batch = names.slice(i, i + concurrency); + await Promise.all(batch.map(async (name) => { + try { + const res = await listMcpTools(servers[name], timeoutMs); + if (res.success) { + results[name] = { + status: 'ok', + tools: res.tools, + ...(res.serverName ? { serverName: res.serverName } : {}), + }; + } else { + results[name] = { status: 'error', tools: [], error: res.message }; + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + results[name] = { status: 'error', tools: [], error: msg }; + } + })); + } + + return results; +} + +/** + * Resolve and probe all *enabled* MCP servers for a workspace. + */ +export async function discoverWorkspaceMcpTools( + rootPath: string, + enabledMcpServers: string[] | null | undefined, + opts: DiscoverOptions = {}, +): Promise> { + const servers = resolveEnabledMcpServers(rootPath, enabledMcpServers, opts.forceReload); + return discoverMcpToolsForServers(servers, opts); +} diff --git a/packages/coc/src/server/spa/client/react/features/chat/ChatHeader.tsx b/packages/coc/src/server/spa/client/react/features/chat/ChatHeader.tsx index 2ce4d4356..a31d28dfc 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/ChatHeader.tsx +++ b/packages/coc/src/server/spa/client/react/features/chat/ChatHeader.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { ReferencesDropdown, ReferenceList, deduplicateReferenceFiles } from '../../ui/ReferencesDropdown'; import { BottomSheet } from '../../ui/BottomSheet'; -import { ConversationMetadataPopover } from './conversation/ConversationMetadataPopover'; +import { ConversationMetadataPopover, type MetaRow } from './conversation/ConversationMetadataPopover'; import { ContextWindowIndicator } from '../../ui/ContextWindowIndicator'; import { copyToClipboard, copyHtmlToClipboard, formatConversationAsText, formatConversationAsHtml, formatDuration } from '../../utils/format'; import { ChatStatusPill } from './ChatStatusPill'; @@ -105,6 +105,13 @@ export interface ChatHeaderProps { startingFreshSameContext?: boolean; /** Optional control rendered at the start of the right-side action area (e.g. the Thread/Agents view toggle). */ viewToggle?: ReactNode; + /** + * Extra metadata rows forwarded to the conversation-metadata popover, shown + * after its standard rows. Used by read-only surfaces (e.g. native CLI + * sessions) to surface fields with no built-in slot — repository, branch, + * cwd, host, created/updated, stored summary. Omitted for CoC chats. + */ + metadataExtraRows?: MetaRow[]; } /** Build overflow menu items based on what's hidden at the current container tier */ @@ -147,6 +154,7 @@ function buildOverflowItems( forking?: boolean; onStartFreshSameContext?: () => Promise | boolean | void; startingFreshSameContext?: boolean; + metadataExtraRows?: MetaRow[]; }, ): OverflowMenuItem[] { if (tier === 'wide') return []; @@ -187,7 +195,7 @@ function buildOverflowItems( icon: i, onClick: () => { /* handled via render */ }, render: () => ( - + ), }); } @@ -349,6 +357,7 @@ export function ChatHeader({ onStartFreshSameContext, startingFreshSameContext = false, viewToggle, + metadataExtraRows, }: ChatHeaderProps) { const { isMobile } = useBreakpoint(); const { isFloating } = useFloatingChats(); @@ -438,7 +447,8 @@ export function ChatHeader({ forking, onStartFreshSameContext, startingFreshSameContext, - }), [tier, task, loading, turns, isPending, resumeSessionId, resumeLaunching, metadataProcess, planPath, createdFiles, sessionTokenLimit, sessionCurrentTokens, sessionModel, sessionSystemTokens, sessionToolTokens, sessionConversationTokens, variant, isPopOut, isMobile, taskId, copiedHtml, onFloat, onPopOut, onLaunchInteractiveResume, isFloating, wsId, onToggleSelecting, isSelecting, showScratchpadButton, onOpenScratchpad, onFork, forking, onStartFreshSameContext, startingFreshSameContext]); // eslint-disable-line react-hooks/exhaustive-deps + metadataExtraRows, + }), [tier, task, loading, turns, isPending, resumeSessionId, resumeLaunching, metadataProcess, planPath, createdFiles, sessionTokenLimit, sessionCurrentTokens, sessionModel, sessionSystemTokens, sessionToolTokens, sessionConversationTokens, variant, isPopOut, isMobile, taskId, copiedHtml, onFloat, onPopOut, onLaunchInteractiveResume, isFloating, wsId, onToggleSelecting, isSelecting, showScratchpadButton, onOpenScratchpad, onFork, forking, onStartFreshSameContext, startingFreshSameContext, metadataExtraRows]); // eslint-disable-line react-hooks/exhaustive-deps return (
Promise | boolean | void; startingFreshSameContext?: boolean; + /** + * Extra metadata rows appended after the standard compact rows. Used by + * read-only surfaces (e.g. native CLI sessions) to surface fields that have + * no built-in slot in {@link buildRows} — repository, branch, working + * directory, host, created/updated, stored summary — without forking the + * popover. Absent for CoC chats, which keep their existing rows unchanged. + */ + extraRows?: MetaRow[]; } -export function ConversationMetadataPopover({ process, turnsCount, resumeSessionId, resumeLaunching, onLaunchInteractiveResume, onFork, forking, onStartFreshSameContext, startingFreshSameContext }: ConversationMetadataPopoverProps) { +export function ConversationMetadataPopover({ process, turnsCount, resumeSessionId, resumeLaunching, onLaunchInteractiveResume, onFork, forking, onStartFreshSameContext, startingFreshSameContext, extraRows }: ConversationMetadataPopoverProps) { const [open, setOpen] = useState(false); const [systemPromptOpen, setSystemPromptOpen] = useState(false); const [menuPos, setMenuPos] = useState({ top: 0, left: 0 }); @@ -538,7 +546,10 @@ export function ConversationMetadataPopover({ process, turnsCount, resumeSession const popoverRef = useRef(null); const rows = useMemo(() => buildRows(process, turnsCount), [process, turnsCount]); const summaryItems = useMemo(() => buildSummaryItems(rows), [rows]); - const compactRows = useMemo(() => buildCompactRows(rows), [rows]); + const compactRows = useMemo(() => { + const base = buildCompactRows(rows); + return extraRows && extraRows.length > 0 ? [...base, ...extraRows] : base; + }, [rows, extraRows]); const { isMobile } = useBreakpoint(); const handleToggle = useCallback(() => { @@ -605,7 +616,7 @@ export function ConversationMetadataPopover({ process, turnsCount, resumeSession return () => document.removeEventListener('keydown', handler); }, [open]); - if (rows.length === 0) return null; + if (rows.length === 0 && (!extraRows || extraRows.length === 0)) return null; const popoverContent = ( <> diff --git a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx index d13cae007..f34d4936d 100644 --- a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx +++ b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx @@ -10,7 +10,7 @@ * execute. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ListNativeCliSessionsResponse, NativeCliSessionDetail, @@ -22,9 +22,24 @@ import { getSpaCocClient } from '../../api/cocClient'; import { Button, Spinner, cn } from '../../ui'; import { useNativeCliSessionsEnabled } from '../../hooks/feature-flags/useNativeCliSessionsEnabled'; import { buildNativeCliSessionHash, parseNativeCliSessionDeepLink } from '../../layout/Router'; -import { ConversationTurnBubble } from '../chat/conversation/ConversationTurnBubble'; import { ProviderBadge } from '../chat/ProviderBadge'; +import { ChatHeader } from '../chat/ChatHeader'; +import { ConversationArea } from '../chat/ConversationArea'; +import { ConversationMiniMap } from '../chat/conversation/ConversationMiniMap'; +import { FollowUpInputArea } from '../chat/FollowUpInputArea'; +import { useConversationSelection } from '../chat/hooks/useConversationSelection'; +import type { RichTextInputHandle } from '../../shared/RichTextInput'; +import type { ChatMode } from '../../repos/modeConfig'; +import { copyHtmlToClipboard } from '../../utils/format'; +import { snapshotConversation } from '../../utils/snapshot-copy-utils'; import { toClientConversationTurns } from './nativeConversationTurns'; +import { + buildNativeSessionMetadataExtraRows, + deriveNativeSessionModel, + nativeSessionTitle, + toNativeSessionHeaderTask, + toNativeSessionMetadataProcess, +} from './nativeSessionChatAdapter'; const PROVIDERS: NativeCliSessionProviderId[] = ['copilot', 'codex', 'claude']; @@ -504,67 +519,193 @@ function SessionRow({ item, selected, onSelect }: { } function SessionDetailView({ detail, workspaceId, onBack }: { detail: NativeCliSessionDetail; workspaceId: string; onBack: () => void }) { - // Reconstructed transcript (rich `events.jsonl` parse, else flat DB - // fallback) mapped into the SPA chat shape so we can reuse the existing - // read-only chat bubble — no input box, streaming, or resume actions. - const conversation = toClientConversationTurns(detail.conversation); + // A native CLI session is an external, static transcript. We present it with + // the SAME UI as a CoC chat activity — chat's own ChatHeader + + // ConversationArea + ConversationMiniMap + a (blocked) FollowUpInputArea — + // by composing those sub-components over adapters of the read-only detail, + // mirroring the WorkItemExecutionSession read-only reuse pattern. There is + // no SSE, follow-up, streaming, resume, or turn mutation: native sessions + // are not CoC processes, so the dependent UI is fed inert stubs and the + // composer is rendered disabled. + const conversation = useMemo(() => toClientConversationTurns(detail.conversation), [detail.conversation]); + + const headerTask = useMemo(() => toNativeSessionHeaderTask(detail), [detail]); + const metadataProcess = useMemo(() => toNativeSessionMetadataProcess(detail), [detail]); + const metadataExtraRows = useMemo(() => buildNativeSessionMetadataExtraRows(detail), [detail]); + const sessionModel = useMemo(() => deriveNativeSessionModel(detail), [detail]); + const title = nativeSessionTitle(detail); + + const scrollRef = useRef(null); + const turnsContainerRef = useRef(null); + const [isScrolledUp, setIsScrolledUp] = useState(false); + const [copied, setCopied] = useState(false); + + // Disabled composer state (AC-05). FollowUpInputArea is rendered for visual + // parity but blocked: `inputDisabled` greys it out and every send/stream/ + // resume handler is an inert stub. + const richTextRef = useRef(null); + const [followUpInput, setFollowUpInput] = useState(''); + const [selectedMode, setSelectedMode] = useState('ask'); + const inertSlashCommands = useMemo(() => ({ + handleInputChange: () => {}, + handleKeyDown: () => false, + selectSkill: () => {}, + dismissMenu: () => {}, + menuVisible: false, + menuFilter: '', + filteredSkills: [], + highlightIndex: 0, + activeCommandHint: null, + }), []); + + // Per-turn actions are visual-only (AC-06): select-to-copy and attach-context + // stay; pin/archive/delete are omitted so ConversationArea/ + // ConversationTurnBubble hide them — there is no CoC processId to mutate. + const selection = useConversationSelection(); + const handleCopySelected = useCallback(async () => { + if (!turnsContainerRef.current || selection.selectedTurns.size === 0) return; + try { + const html = snapshotConversation(turnsContainerRef.current, { + selectedIndices: selection.selectedTurns, + }); + await copyHtmlToClipboard(html); + selection.stopSelecting(); + } catch (e) { + console.error('Copy selected HTML failed:', e); + } + }, [selection]); + + const scrollToBottom = useCallback(() => { + if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + }, []); + + // Track scroll position so ConversationArea can surface the scroll-to-bottom + // affordance, exactly as chat does. + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const onScroll = () => setIsScrolledUp(el.scrollHeight - el.scrollTop - el.clientHeight > 100); + el.addEventListener('scroll', onScroll); + return () => el.removeEventListener('scroll', onScroll); + }, []); + + // Snap to the latest turn when a session loads (matches chat's initial view). + useEffect(() => { + if (conversation.length > 0 && scrollRef.current) { + const el = scrollRef.current; + requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; }); + } + }, [conversation]); + return ( -
-
-
- - - - -
-

{detail.id}

-

- {readOnlyTooltip(detail.provider, detail.storePath)} -

-
-
Repository
{detail.repository || '—'}
-
Branch
{detail.branch || 'Unknown branch'}
-
Working dir
{detail.cwd || '—'}
-
Host
{detail.hostType || '—'}
-
Created
{formatTimestamp(detail.createdAt)}
-
Updated
{formatTimestamp(detail.updatedAt)}
-
- {detail.summary && ( -
-

Stored summary

-
{detail.summary}
+
+ {}} + onPopOut={() => {}} + onFloat={() => {}} + title={title} + wsId={workspaceId} + turnsContainerRef={turnsContainerRef} + isSelecting={selection.isSelecting} + onToggleSelecting={selection.toggleSelecting} + viewToggle={( +
+ +
)} + /> +
+ {}} + onMoveToTop={() => {}} + variant="inline" + taskId={detail.id} + wsId={workspaceId} + provider={detail.provider} + isSelecting={selection.isSelecting} + selectedTurns={selection.selectedTurns} + onTurnClick={selection.handleTurnClick} + onCopySelected={handleCopySelected} + onCancelSelection={selection.stopSelecting} + onAttachContext={() => {}} + /> +
- -
-

Conversation ({conversation.length})

-

- Read-only reconstruction of the native {PROVIDER_META[detail.provider].label} CLI transcript. No follow-up, streaming, or resume. -

- {conversation.length === 0 ? ( -

This native session has no stored turns.

- ) : ( -
- {conversation.map((turn, index) => ( - - ))} -
- )} +
+ {readOnlyTooltip(detail.provider, detail.storePath)} Follow-up, streaming, and resume are disabled.
+ {}} + onRetry={() => {}} + skills={[]} + attachments={[]} + onAttachmentPaste={() => {}} + onAttachmentRemove={() => {}} + onAttachmentFiles={() => {}} + attachmentError={null} + pastePreview={null} + task={headerTask} + slashCommands={inertSlashCommands} + hideModeSelector={true} + sessionContextAttachmentsEnabled={false} + canRetrieveConversations={false} + />
); } diff --git a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/nativeSessionChatAdapter.ts b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/nativeSessionChatAdapter.ts new file mode 100644 index 000000000..2706bdab0 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/nativeSessionChatAdapter.ts @@ -0,0 +1,143 @@ +/** + * Adapters mapping a read-only {@link NativeCliSessionDetail} into the minimal + * `task` / `metadataProcess` shapes the existing chat sub-components + * (`ChatHeader`, `ConversationMetadataPopover`) consume, so the CLI Sessions + * detail view can be presented with the same UI as a CoC chat activity without + * forking those components. + * + * Native sessions are external, static transcripts: they have no CoC process + * lifecycle (no run status, queue task, session-id link, token usage, or + * mutable turns). The shapes produced here therefore carry only the fields the + * chat components read for presentation, and deliberately leave the rest + * undefined so the dependent UI (resume link, fork, live token gauge, etc.) + * hides itself gracefully. + * + * Fields with no natural home in the chat metadata popover's built-in row set + * (repository, branch, cwd, host, created/updated, stored summary) are surfaced + * through {@link buildNativeSessionMetadataExtraRows} as explicit extra rows. + */ + +import type { + NativeCliSessionDetail, + NativeCliSessionProviderId, +} from '@plusplusoneplusplus/coc-client'; + +/** Minimal `task` shape consumed by `ChatHeader` for the provider badge. */ +export interface NativeSessionHeaderTask { + type: 'chat'; + metadata: { provider: NativeCliSessionProviderId }; +} + +/** Minimal `process` shape consumed by `ConversationMetadataPopover`/`buildRows`. */ +export interface NativeSessionMetadataProcess { + metadata: { provider: NativeCliSessionProviderId; model?: string }; +} + +/** + * One extra metadata row for the conversation metadata popover. Structurally a + * subset of the popover's internal `MetaRow` (no `link`), so it can be appended + * to the standard rows without a popover-internal change beyond an optional + * pass-through prop. + */ +export interface NativeSessionMetaRow { + label: string; + value: string; + breakAll?: boolean; + mono?: boolean; +} + +/** + * Header title for a native session. The session id is the only stable, + * human-addressable identifier these external transcripts carry, so it doubles + * as the chat-header title (mirroring how a CoC chat falls back to an id-like + * title when no custom title is set). + */ +export function nativeSessionTitle(detail: NativeCliSessionDetail): string { + return detail.id; +} + +/** + * Best-effort model label for the session: the model of the most recent + * assistant turn that recorded one. Returns `undefined` when no assistant turn + * carries a model, so the popover falls back to chat's default-model copy. + */ +export function deriveNativeSessionModel(detail: NativeCliSessionDetail): string | undefined { + const conversation = detail.conversation; + if (!Array.isArray(conversation)) { + return undefined; + } + for (let i = conversation.length - 1; i >= 0; i--) { + const turn = conversation[i]; + if (turn && turn.role === 'assistant' && typeof turn.model === 'string') { + const model = turn.model.trim(); + if (model) { + return model; + } + } + } + return undefined; +} + +/** Build the `ChatHeader` `task` prop (drives the provider badge). */ +export function toNativeSessionHeaderTask(detail: NativeCliSessionDetail): NativeSessionHeaderTask { + return { type: 'chat', metadata: { provider: detail.provider } }; +} + +/** Build the `ConversationMetadataPopover` `process` prop (Agent Provider + Model rows). */ +export function toNativeSessionMetadataProcess(detail: NativeCliSessionDetail): NativeSessionMetadataProcess { + const model = deriveNativeSessionModel(detail); + return { metadata: { provider: detail.provider, ...(model ? { model } : {}) } }; +} + +/** + * Format a stored ISO timestamp for display, mirroring the panel's existing + * `formatTimestamp`: `null`/blank → `null` (row omitted), unparseable → the raw + * value, otherwise a locale string. + */ +export function formatNativeSessionTimestamp(value: string | null | undefined): string | null { + if (value === null || value === undefined) { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const parsed = Date.parse(trimmed); + if (Number.isNaN(parsed)) { + return trimmed; + } + return new Date(parsed).toLocaleString(); +} + +function pushRow( + rows: NativeSessionMetaRow[], + label: string, + value: string | null | undefined, + opts?: { breakAll?: boolean; mono?: boolean }, +): void { + if (value === null || value === undefined) { + return; + } + const str = value.trim(); + if (!str) { + return; + } + rows.push({ label, value: str, ...(opts ?? {}) }); +} + +/** + * Native-session metadata that has no built-in chat-popover row: repository, + * branch, working directory, host, created/updated timestamps, and the stored + * summary. Empty/absent fields are skipped so the popover stays clean. + */ +export function buildNativeSessionMetadataExtraRows(detail: NativeCliSessionDetail): NativeSessionMetaRow[] { + const rows: NativeSessionMetaRow[] = []; + pushRow(rows, 'Repository', detail.repository, { breakAll: true }); + pushRow(rows, 'Branch', detail.branch); + pushRow(rows, 'Working Directory', detail.cwd, { breakAll: true }); + pushRow(rows, 'Host', detail.hostType); + pushRow(rows, 'Created', formatNativeSessionTimestamp(detail.createdAt)); + pushRow(rows, 'Updated', formatNativeSessionTimestamp(detail.updatedAt)); + pushRow(rows, 'Summary', detail.summary, { breakAll: true }); + return rows; +} diff --git a/packages/coc/src/server/spa/client/react/features/repo-detail/RepoCopilotTab.tsx b/packages/coc/src/server/spa/client/react/features/repo-detail/RepoCopilotTab.tsx index 37778e773..fc317d556 100644 --- a/packages/coc/src/server/spa/client/react/features/repo-detail/RepoCopilotTab.tsx +++ b/packages/coc/src/server/spa/client/react/features/repo-detail/RepoCopilotTab.tsx @@ -39,6 +39,7 @@ export function RepoCopilotTab({ workspaceId }: RepoCopilotTabProps) { const [availableServers, setAvailableServers] = useState([]); const [mcpSources, setMcpSources] = useState(undefined); const [enabledMcpServers, setEnabledMcpServers] = useState(null); + const [enabledMcpTools, setEnabledMcpTools] = useState | null>(null); useEffect(() => { setLoading(true); @@ -49,6 +50,7 @@ export function RepoCopilotTab({ workspaceId }: RepoCopilotTabProps) { setAvailableServers(data.availableServers ?? []); setMcpSources(data.sources); setEnabledMcpServers(data.enabledMcpServers ?? null); + setEnabledMcpTools(data.enabledMcpTools ?? null); }) .catch((e: unknown) => setError(getSpaCocClientErrorMessage(e, 'Failed to load MCP config'))) .finally(() => setLoading(false)); @@ -62,6 +64,7 @@ export function RepoCopilotTab({ workspaceId }: RepoCopilotTabProps) { setAvailableServers(data.availableServers ?? []); setMcpSources(data.sources); setEnabledMcpServers(data.enabledMcpServers ?? null); + setEnabledMcpTools(data.enabledMcpTools ?? null); }) .catch((e: unknown) => setError(getSpaCocClientErrorMessage(e, 'Failed to load MCP config'))) .finally(() => setLoading(false)); @@ -319,6 +322,8 @@ export function RepoCopilotTab({ workspaceId }: RepoCopilotTabProps) { saving={saving} availableServers={availableServers} sources={mcpSources} + enabledMcpServers={enabledMcpServers} + enabledMcpTools={enabledMcpTools} isEnabled={isEnabled} onToggle={handleToggle} onRefresh={handleRefreshMcp} diff --git a/packages/coc/src/server/spa/client/react/features/repo-settings/RepoSettingsTab.tsx b/packages/coc/src/server/spa/client/react/features/repo-settings/RepoSettingsTab.tsx index a7d9db698..736436f88 100644 --- a/packages/coc/src/server/spa/client/react/features/repo-settings/RepoSettingsTab.tsx +++ b/packages/coc/src/server/spa/client/react/features/repo-settings/RepoSettingsTab.tsx @@ -299,6 +299,7 @@ export function RepoSettingsTab({ workspaceId, repo }: RepoSettingsTabProps) { const [availableServers, setAvailableServers] = useState([]); const [mcpSources, setMcpSources] = useState(undefined); const [enabledMcpServers, setEnabledMcpServers] = useState(null); + const [enabledMcpTools, setEnabledMcpTools] = useState | null>(null); const fetchMcpConfig = useCallback((forceReload = false) => { setLoading(true); @@ -309,6 +310,7 @@ export function RepoSettingsTab({ workspaceId, repo }: RepoSettingsTabProps) { setAvailableServers(data.availableServers ?? []); setMcpSources(data.sources); setEnabledMcpServers(data.enabledMcpServers ?? null); + setEnabledMcpTools(data.enabledMcpTools ?? null); }) .catch((e: any) => setError(e.message ?? 'Failed to load MCP config')) .finally(() => setLoading(false)); @@ -902,6 +904,8 @@ export function RepoSettingsTab({ workspaceId, repo }: RepoSettingsTabProps) { saving={saving} availableServers={availableServers} sources={mcpSources} + enabledMcpServers={enabledMcpServers} + enabledMcpTools={enabledMcpTools} isEnabled={isEnabled} onToggle={handleToggle} onRefresh={() => fetchMcpConfig(true)} diff --git a/packages/coc/src/server/spa/client/react/features/skills/McpServersPanel.tsx b/packages/coc/src/server/spa/client/react/features/skills/McpServersPanel.tsx index a54822ebc..8a2ded6f4 100644 --- a/packages/coc/src/server/spa/client/react/features/skills/McpServersPanel.tsx +++ b/packages/coc/src/server/spa/client/react/features/skills/McpServersPanel.tsx @@ -2,7 +2,23 @@ import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react' import './mcp-servers-redesign.css'; import { getSpaCocClient, getSpaCocClientErrorMessage } from '../../api/cocClient'; import { getApiBase } from '../../utils/config'; -import type { McpServerDetail as ClientMcpServerDetail, McpConfigScope, McpServerAuthStatus } from '@plusplusoneplusplus/coc-client'; +import type { + McpServerDetail as ClientMcpServerDetail, + McpConfigScope, + McpServerAuthStatus, + McpServerToolsResult, + McpDiscoveredTool, +} from '@plusplusoneplusplus/coc-client'; +import { + isMcpToolEnabled, + applyMcpToolToggle, + enableAllMcpTools, + disableAllMcpTools, + normalizeEnabledMcpTools, + type EnabledMcpToolsMap, +} from './mcpToolsAllowList'; + +type DiscoveryState = 'idle' | 'loading' | 'loaded' | 'error'; export type McpServerSource = 'global' | 'workspace'; export type McpServerEntry = { @@ -43,6 +59,13 @@ interface McpServersPanelProps { saving: boolean; availableServers: McpServerEntry[]; sources?: McpServerSources; + /** + * Raw enabled-server allow-list. Needed so per-tool toggles can be persisted + * through the same `PUT /mcp-config` call without clobbering the server list. + */ + enabledMcpServers?: string[] | null; + /** Initial per-repo enabled-tools allow-list (server → enabled tool names). */ + enabledMcpTools?: Record | null; isEnabled: (name: string) => boolean; onToggle: (serverName: string, checked: boolean) => void; onRefresh?: () => void; @@ -251,10 +274,181 @@ function InspectorOverviewPane({ server, detail }: { server: McpServerEntry; det ); } -function InspectorToolsPane() { +/** Collapsible JSON view of a tool's input schema (display-only). */ +function ToolSchema({ schema }: { schema: unknown }) { + const [open, setOpen] = useState(false); + if (schema === undefined || schema === null) return null; + let json: string; + try { + json = JSON.stringify(schema, null, 2); + } catch { + json = String(schema); + } return ( -
- Connect to view tools +
+ + {open &&
{json}
} +
+ ); +} + +function ToolRow({ tool, enabled, disabled, onToggle }: { + tool: McpDiscoveredTool; + enabled: boolean; + disabled: boolean; + onToggle: (enabled: boolean) => void; +}) { + return ( +
+
+ {tool.name} + +
+ {tool.description &&

{tool.description}

} + +
+ ); +} + +function InspectorToolsPane({ + enabled, + result, + discoveryState, + discoveryError, + allowEntry, + saving, + onToggleTool, + onEnableAll, + onDisableAll, + onRefresh, +}: { + enabled: boolean; + result: McpServerToolsResult | undefined; + discoveryState: DiscoveryState; + discoveryError: string | null; + allowEntry: string[] | undefined; + saving: boolean; + onToggleTool: (toolName: string, enabled: boolean) => void; + onEnableAll: () => void; + onDisableAll: () => void; + onRefresh: () => void; +}) { + const [query, setQuery] = useState(''); + + if (!enabled) { + return ( +
+ Enable this server to discover its tools. +
+ ); + } + + const loading = (discoveryState === 'loading' || discoveryState === 'idle') && !result; + if (loading) { + return ( +
+ Discovering tools… +
+ ); + } + + const errorMsg = result?.status === 'error' + ? (result.error || 'Connection failed') + : (!result && discoveryState === 'error' ? (discoveryError || 'Discovery failed') : undefined); + if (errorMsg) { + return ( +
+
+ Couldn’t connect: {errorMsg} +
+
+ +
+
+ ); + } + + const tools = result?.status === 'ok' ? result.tools : []; + const q = query.trim().toLowerCase(); + const filtered = q + ? tools.filter(t => t.name.toLowerCase().includes(q) || (t.description ?? '').toLowerCase().includes(q)) + : tools; + const enabledCount = tools.filter(t => isMcpToolEnabled(allowEntry, t.name)).length; + + return ( +
+
+
+ + setQuery(e.target.value)} + data-testid="mcp-tools-search" + /> +
+ {enabledCount}/{tools.length} enabled +
+ + + +
+ {tools.length === 0 ? ( +
+ This server exposes no tools. +
+ ) : filtered.length === 0 ? ( +
+ No tools matching “{query}”. +
+ ) : ( +
+ {filtered.map(tool => ( + onToggleTool(tool.name, on)} + /> + ))} +
+ )}
); } @@ -587,7 +781,7 @@ function InspectorActivityPane() { ); } -function ServerInspector({ server, activeTab, onTabChange, detail, workspaceId, onSaved, onDeleted }: { +function ServerInspector({ server, activeTab, onTabChange, detail, workspaceId, onSaved, onDeleted, tools }: { server: McpServerEntry; activeTab: InspectorTab; onTabChange: (tab: InspectorTab) => void; @@ -595,6 +789,18 @@ function ServerInspector({ server, activeTab, onTabChange, detail, workspaceId, workspaceId: string; onSaved: () => void; onDeleted: () => void; + tools: { + enabled: boolean; + result: McpServerToolsResult | undefined; + discoveryState: DiscoveryState; + discoveryError: string | null; + allowEntry: string[] | undefined; + saving: boolean; + onToggleTool: (toolName: string, enabled: boolean) => void; + onEnableAll: () => void; + onDisableAll: () => void; + onRefresh: () => void; + }; }) { const tabs: { id: InspectorTab; label: string }[] = [ { id: 'overview', label: 'Overview' }, @@ -620,7 +826,20 @@ function ServerInspector({ server, activeTab, onTabChange, detail, workspaceId,
{activeTab === 'overview' && } - {activeTab === 'tools' && } + {activeTab === 'tools' && ( + + )} {activeTab === 'configuration' && ( (null); const [inspectorTab, setInspectorTab] = useState('overview'); const [detailCache, setDetailCache] = useState>({}); + + // ── Live tool discovery (AC-02) ────────────────────────────────────────── + const [discovery, setDiscovery] = useState>({}); + const [discoveryState, setDiscoveryState] = useState('idle'); + const [discoveryError, setDiscoveryError] = useState(null); + + // ── Per-tool allow-list (AC-03) ────────────────────────────────────────── + const [toolsAllowList, setToolsAllowList] = useState(() => ({ ...(enabledMcpTools ?? {}) })); + const [toolsSaving, setToolsSaving] = useState(false); + // Keep local allow-list in sync when the parent reloads the config. + useEffect(() => { + setToolsAllowList({ ...(enabledMcpTools ?? {}) }); + }, [enabledMcpTools]); + + const fetchTools = useCallback(async (forceReload = false) => { + if (!workspaceId) return; + setDiscoveryState('loading'); + setDiscoveryError(null); + try { + const resp = await getSpaCocClient().workspaces.discoverMcpTools( + workspaceId, + forceReload ? { forceReload: true } : undefined, + ); + setDiscovery(resp.servers ?? {}); + setDiscoveryState('loaded'); + } catch (e) { + setDiscoveryError(getSpaCocClientErrorMessage(e, 'Failed to discover tools')); + setDiscoveryState('error'); + } + }, [workspaceId]); + + // Eager discovery on mount / workspace change. + useEffect(() => { void fetchTools(); }, [fetchTools]); + + const persistToolsAllowList = useCallback(async (nextMap: EnabledMcpToolsMap) => { + if (!workspaceId) return; + let prev: EnabledMcpToolsMap = {}; + setToolsAllowList(curr => { prev = curr; return nextMap; }); // optimistic + setToolsSaving(true); + try { + await getSpaCocClient().workspaces.updateMcpConfig(workspaceId, { + enabledMcpServers: enabledMcpServers ?? null, + enabledMcpTools: normalizeEnabledMcpTools(nextMap), + }); + } catch (e) { + setToolsAllowList(prev); // revert + setDiscoveryError(getSpaCocClientErrorMessage(e, 'Failed to save tool settings')); + } finally { + setToolsSaving(false); + } + }, [workspaceId, enabledMcpServers]); + + const discoveredToolNames = useCallback((serverName: string): string[] => { + const r = discovery[serverName]; + return r && r.status === 'ok' ? r.tools.map(t => t.name) : []; + }, [discovery]); + + const handleToolToggle = useCallback((serverName: string, toolName: string, on: boolean) => { + void persistToolsAllowList( + applyMcpToolToggle(toolsAllowList, serverName, discoveredToolNames(serverName), toolName, on), + ); + }, [persistToolsAllowList, toolsAllowList, discoveredToolNames]); + + const handleEnableAllTools = useCallback((serverName: string) => { + void persistToolsAllowList(enableAllMcpTools(toolsAllowList, serverName)); + }, [persistToolsAllowList, toolsAllowList]); + + const handleDisableAllTools = useCallback((serverName: string) => { + void persistToolsAllowList(disableAllMcpTools(toolsAllowList, serverName)); + }, [persistToolsAllowList, toolsAllowList]); + + /** Row-level tool count label, e.g. "12", "8/12", "…", "!", or "—". */ + const toolCountFor = useCallback((server: McpServerEntry): { text: string; title?: string } => { + if (!isEnabled(server.name) || server.effective === false) return { text: '—' }; + const r = discovery[server.name]; + if (!r) { + return discoveryState === 'loading' || discoveryState === 'idle' + ? { text: '…' } + : { text: '—' }; + } + if (r.status === 'error') return { text: '!', title: r.error }; + const total = r.tools.length; + const enabledCount = r.tools.filter(t => isMcpToolEnabled(toolsAllowList[server.name], t.name)).length; + return { text: enabledCount === total ? String(total) : `${enabledCount}/${total}`, title: `${enabledCount} of ${total} tools enabled` }; + }, [discovery, discoveryState, isEnabled, toolsAllowList]); /** Per-server OAuth flow state — drives the Authenticate button label and spinner. */ const [authFlow, setAuthFlow] = useState>({}); const authPollersRef = useRef>>({}); @@ -1232,7 +1538,12 @@ export function McpServersPanel({
{onRefresh && ( - )} @@ -1258,6 +1569,7 @@ export function McpServersPanel({ const isExpanded = expandedServer === server.name; const flow = authFlow[server.name]; const showAuthBtn = isRemote(server) && enabled && (needsAuth(server) || (flow && flow.phase !== 'completed')); + const toolCount = toolCountFor(server); return ( @@ -1290,7 +1602,7 @@ export function McpServersPanel({
{server.type}
{sourcePill.label}
-
+
{toolCount.text}
handleDetailSaved(server.name)} onDeleted={handleServerDeleted} + tools={{ + enabled: enabled && !isOverridden, + result: discovery[server.name], + discoveryState, + discoveryError, + allowEntry: toolsAllowList[server.name], + saving: toolsSaving, + onToggleTool: (toolName, on) => handleToolToggle(server.name, toolName, on), + onEnableAll: () => handleEnableAllTools(server.name), + onDisableAll: () => handleDisableAllTools(server.name), + onRefresh: () => void fetchTools(true), + }} /> )} diff --git a/packages/coc/src/server/spa/client/react/features/skills/mcp-servers-redesign.css b/packages/coc/src/server/spa/client/react/features/skills/mcp-servers-redesign.css index a0a401511..2d164499d 100644 --- a/packages/coc/src/server/spa/client/react/features/skills/mcp-servers-redesign.css +++ b/packages/coc/src/server/spa/client/react/features/skills/mcp-servers-redesign.css @@ -872,3 +872,81 @@ color: var(--mcp-fg-muted); font-size: 13px; } + +/* ---- Tools tab (live discovery + per-tool toggles) ---- */ +.mcp-servers-redesign .mcp-tools-pane { + display: flex; + flex-direction: column; + gap: 10px; +} +.mcp-servers-redesign .mcp-tools-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.mcp-servers-redesign .mcp-tools-toolbar .mcp-search-wrap { + flex: 1 1 180px; + min-width: 140px; +} +.mcp-servers-redesign .mcp-tool-list { + display: flex; + flex-direction: column; + border: 1px solid var(--mcp-border-subtle); + border-radius: 6px; + overflow: hidden; +} +.mcp-servers-redesign .mcp-tool-row { + padding: 10px 12px; + border-top: 1px solid var(--mcp-border-subtle); +} +.mcp-servers-redesign .mcp-tool-row:first-child { + border-top: 0; +} +.mcp-servers-redesign .mcp-tool-row.off { + opacity: 0.6; +} +.mcp-servers-redesign .mcp-tool-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.mcp-servers-redesign .mcp-tool-name { + font-family: var(--mcp-font-mono); + font-size: 12px; + color: var(--mcp-fg); + font-weight: 500; +} +.mcp-servers-redesign .mcp-tool-desc { + color: var(--mcp-fg-muted); + font-size: 12px; + margin: 4px 0 0; + line-height: 1.4; +} +.mcp-servers-redesign .mcp-tool-schema { + margin-top: 6px; +} +.mcp-servers-redesign .mcp-tool-schema-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + background: none; + border: 0; + padding: 0; + cursor: pointer; + color: var(--mcp-accent, #0078d4); + font-size: 11px; + font: inherit; + font-size: 11px; +} +.mcp-servers-redesign .mcp-tool-schema-chev { + display: inline-flex; + transition: transform 0.12s ease; +} +.mcp-servers-redesign .mcp-tool-schema-chev.open { + transform: rotate(90deg); +} +.mcp-servers-redesign .mcp-tool-schema-pre { + margin-top: 6px; +} diff --git a/packages/coc/src/server/spa/client/react/features/skills/mcpToolsAllowList.ts b/packages/coc/src/server/spa/client/react/features/skills/mcpToolsAllowList.ts new file mode 100644 index 000000000..a1c4c2f12 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/skills/mcpToolsAllowList.ts @@ -0,0 +1,94 @@ +/** + * Allow-list write logic for per-tool MCP toggles. + * + * The per-repo `enabledMcpTools` preference maps a server name to the list of + * tool names that remain ENABLED. The semantics are an allow-list: + * + * - A server with **no entry** has *all* tools enabled (the common case). + * - Once an entry exists, only the listed tools are enabled — any tool not in + * the list (including a newly discovered tool) is disabled. New/undiscovered + * tools therefore default OFF once an entry exists. + * - The first time a user toggles a tool OFF, we materialize the entry as the + * complement of that tool within the currently discovered tools (every + * discovered tool stays enabled except the one just turned off). + * + * These helpers are pure so the toggle logic can be unit-tested without React. + */ + +export type EnabledMcpToolsMap = Record; + +/** + * Whether a tool is enabled given its server's allow-list entry. + * `undefined` entry → all tools enabled. + */ +export function isMcpToolEnabled(entry: string[] | undefined, toolName: string): boolean { + return entry === undefined ? true : entry.includes(toolName); +} + +/** + * Compute the next allow-list entry for one server after toggling one tool. + * + * @param entry current entry (or `undefined` for "no entry / all on") + * @param discoveredTools tool names currently discovered for the server — the + * universe used to materialize the complement on the + * first toggle-off + * @returns the new entry array, or `undefined` to mean "no entry" (all on) + */ +export function toggleMcpToolEntry( + entry: string[] | undefined, + discoveredTools: string[], + toolName: string, + enabled: boolean, +): string[] | undefined { + if (entry === undefined) { + // No entry yet → every discovered tool is currently on. + if (enabled) return undefined; // turning on an already-on tool: no-op + // First toggle-off → materialize the complement of {toolName}. + return discoveredTools.filter(t => t !== toolName); + } + if (enabled) { + return entry.includes(toolName) ? entry : [...entry, toolName]; + } + return entry.filter(t => t !== toolName); +} + +/** Apply a single-tool toggle to the whole map, returning a new map. */ +export function applyMcpToolToggle( + map: EnabledMcpToolsMap, + serverName: string, + discoveredTools: string[], + toolName: string, + enabled: boolean, +): EnabledMcpToolsMap { + const next = { ...map }; + const updated = toggleMcpToolEntry(next[serverName], discoveredTools, toolName, enabled); + if (updated === undefined) { + delete next[serverName]; + } else { + next[serverName] = updated; + } + return next; +} + +/** + * Enable every tool for a server → drop its entry entirely (no entry = all on, + * including any tools discovered later). + */ +export function enableAllMcpTools(map: EnabledMcpToolsMap, serverName: string): EnabledMcpToolsMap { + const next = { ...map }; + delete next[serverName]; + return next; +} + +/** Disable every tool for a server → an empty allow-list. */ +export function disableAllMcpTools(map: EnabledMcpToolsMap, serverName: string): EnabledMcpToolsMap { + return { ...map, [serverName]: [] }; +} + +/** + * Normalize a map for persistence: drop empty objects to `null` so the stored + * preference round-trips cleanly (the schema treats an empty record as absent). + */ +export function normalizeEnabledMcpTools(map: EnabledMcpToolsMap): EnabledMcpToolsMap | null { + return Object.keys(map).length > 0 ? map : null; +} diff --git a/packages/coc/test/server/executors/chat-mode-executors.test.ts b/packages/coc/test/server/executors/chat-mode-executors.test.ts index 7f22b55ab..c7a338bb8 100644 --- a/packages/coc/test/server/executors/chat-mode-executors.test.ts +++ b/packages/coc/test/server/executors/chat-mode-executors.test.ts @@ -18,7 +18,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import type { ModelInfo, QueuedTask } from '@plusplusoneplusplus/forge'; -import { modelMetadataStore, READ_ONLY_SYSTEM_MESSAGE } from '@plusplusoneplusplus/forge'; +import { modelMetadataStore, READ_ONLY_SYSTEM_MESSAGE, setHomeDirectoryOverride, clearMcpConfigCache } from '@plusplusoneplusplus/forge'; import { ChatExecutor } from '../../../src/server/executors/chat-executor'; import { AutopilotExecutor } from '../../../src/server/executors/autopilot-executor'; import { ClassificationExecutor } from '../../../src/server/executors/classification-executor'; @@ -1587,3 +1587,66 @@ describe('ChatBaseExecutor contextTier', () => { expect(call).not.toHaveProperty('contextTier'); }); }); + +// ============================================================================ +// AC-04 — MCP per-tool allow-list enforcement on the new-chat execute() path +// ============================================================================ + +describe('ChatExecutor MCP allow-list enforcement', () => { + let store: ReturnType; + let tmpHome: string; + let tmpWorkspace: string; + let tmpData: string; + + beforeEach(() => { + store = createMockProcessStore(); + sdkMocks.resetAll(); + sdkMocks.mockIsAvailable.mockResolvedValue({ available: true }); + sdkMocks.mockSendMessage.mockResolvedValue({ success: true, response: 'AI answer', sessionId: 'sess-1', toolCalls: [] }); + + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-chat-mcp-home-')); + tmpWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-chat-mcp-ws-')); + tmpData = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-chat-mcp-data-')); + setHomeDirectoryOverride(tmpHome); + clearMcpConfigCache(); + fs.mkdirSync(path.join(tmpHome, '.copilot'), { recursive: true }); + fs.writeFileSync( + path.join(tmpHome, '.copilot', 'mcp-config.json'), + JSON.stringify({ mcpServers: { srv: { command: 'srv-bin' } } }), + ); + }); + + afterEach(() => { + setHomeDirectoryOverride(null); + clearMcpConfigCache(); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpWorkspace, { recursive: true, force: true }); + fs.rmSync(tmpData, { recursive: true, force: true }); + }); + + it('sends mcpServers with the disabled tool absent and loadDefaultMcpConfig=false', async () => { + await store.registerWorkspace({ id: 'ws-mcp', name: 'ws', rootPath: tmpWorkspace, enabledMcpServers: null } as any); + writeRepoPreferences(tmpData, 'ws-mcp', { enabledMcpTools: { srv: ['kept_tool'] } }); + + const executor = new ChatExecutor(store, makeOptions(store), tmpData); + const task = makeChatTask('ask', 'task-mcp-allow'); + task.payload = { ...(task.payload as any), workspaceId: 'ws-mcp', workingDirectory: tmpWorkspace } as any; + + await executor.execute(task, 'Hello'); + + const call = sdkMocks.mockSendMessage.mock.calls[0][0] as any; + expect(call.loadDefaultMcpConfig).toBe(false); + expect(call.mcpServers.srv.tools).toEqual(['kept_tool']); + expect(call.mcpServers.srv.tools).not.toContain('dropped_tool'); + }); + + it('does not set mcpServers when the chat has no workspace context', async () => { + const executor = new ChatExecutor(store, makeOptions(store), tmpData); + // makeChatTask payload has no workspaceId/workingDirectory → no rootPath. + await executor.execute(makeChatTask('ask', 'task-mcp-no-ws'), 'Hello'); + + const call = sdkMocks.mockSendMessage.mock.calls[0][0] as any; + expect(call).not.toHaveProperty('mcpServers'); + expect(call).not.toHaveProperty('loadDefaultMcpConfig'); + }); +}); diff --git a/packages/coc/test/server/executors/follow-up-executor.test.ts b/packages/coc/test/server/executors/follow-up-executor.test.ts index a85851e7b..3f19e2417 100644 --- a/packages/coc/test/server/executors/follow-up-executor.test.ts +++ b/packages/coc/test/server/executors/follow-up-executor.test.ts @@ -13,9 +13,13 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import type { AIProcess, ModelInfo } from '@plusplusoneplusplus/forge'; -import { modelMetadataStore } from '@plusplusoneplusplus/forge'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { AIProcess, ModelInfo, WorkspaceInfo } from '@plusplusoneplusplus/forge'; +import { modelMetadataStore, setHomeDirectoryOverride, clearMcpConfigCache } from '@plusplusoneplusplus/forge'; import { FollowUpExecutor } from '../../../src/server/executors/follow-up-executor'; +import { writeRepoPreferences } from '../../../src/server/preferences-handler'; import { createMockProcessStore } from '../helpers/mock-process-store'; import { createMockSDKService } from '../../helpers/mock-sdk-service'; @@ -1583,4 +1587,75 @@ describe('FollowUpExecutor contextTier', () => { const call = sdkMocks.mockSendMessage.calls[0][0] as Record; expect(call).not.toHaveProperty('contextTier'); }); + + // ------------------------------------------------------------------------- + // AC-04 — MCP per-tool allow-list enforcement (dashboard chat/session path) + // ------------------------------------------------------------------------- + + describe('MCP allow-list enforcement', () => { + let tmpHome: string; + let tmpWorkspace: string; + let tmpData: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-fu-mcp-home-')); + tmpWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-fu-mcp-ws-')); + tmpData = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-fu-mcp-data-')); + setHomeDirectoryOverride(tmpHome); + clearMcpConfigCache(); + fs.mkdirSync(path.join(tmpHome, '.copilot'), { recursive: true }); + fs.writeFileSync( + path.join(tmpHome, '.copilot', 'mcp-config.json'), + JSON.stringify({ mcpServers: { srv: { command: 'srv-bin' } } }), + ); + }); + + afterEach(() => { + setHomeDirectoryOverride(null); + clearMcpConfigCache(); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpWorkspace, { recursive: true, force: true }); + fs.rmSync(tmpData, { recursive: true, force: true }); + }); + + it('sends mcpServers with the disabled tool absent and loadDefaultMcpConfig=false', async () => { + const ws: WorkspaceInfo = { + id: 'ws-mcp', + name: 'ws', + rootPath: tmpWorkspace, + enabledMcpServers: null, + } as WorkspaceInfo; + await store.registerWorkspace(ws); + // Allow-list keeps only `kept_tool`; `dropped_tool` is disabled. + writeRepoPreferences(tmpData, 'ws-mcp', { enabledMcpTools: { srv: ['kept_tool'] } }); + + const proc = makeProcess({ + id: 'proc-mcp', + metadata: { type: 'chat', workspaceId: 'ws-mcp' }, + }); + await store.addProcess(proc); + + const executor = makeExecutor(store, {}, tmpData); + await executor.executeFollowUp('proc-mcp', 'follow-up'); + + const callArg = sdkMocks.mockSendMessage.mock.calls[0][0] as any; + expect(callArg.loadDefaultMcpConfig).toBe(false); + expect(callArg.mcpServers.srv.tools).toEqual(['kept_tool']); + expect(callArg.mcpServers.srv.tools).not.toContain('dropped_tool'); + }); + + it('does not set mcpServers when there is no workspace context', async () => { + // No workspace registered, process has no workspaceId → rootPath + // unresolved → preserve SDK default MCP load. + const proc = makeProcess({ id: 'proc-no-ws', metadata: { type: 'chat' } }); + await store.addProcess(proc); + + const executor = makeExecutor(store, {}, tmpData); + await executor.executeFollowUp('proc-no-ws', 'follow-up'); + + const callArg = sdkMocks.mockSendMessage.mock.calls[0][0] as any; + expect(callArg).not.toHaveProperty('mcpServers'); + expect(callArg).not.toHaveProperty('loadDefaultMcpConfig'); + }); + }); }); diff --git a/packages/coc/test/server/executors/mcp-tool-enforcement.test.ts b/packages/coc/test/server/executors/mcp-tool-enforcement.test.ts new file mode 100644 index 000000000..347bd26ab --- /dev/null +++ b/packages/coc/test/server/executors/mcp-tool-enforcement.test.ts @@ -0,0 +1,254 @@ +/** + * MCP Tool Enforcement Unit Tests (AC-04) + * + * Verifies the per-repo MCP allow-list resolution used by the dashboard + * chat/session executors: + * - applyMcpAllowList: pure server-level + tool-level allow-list semantics. + * - resolveChatMcpServers: effective-config resolution + allow-list. + * - resolveChatMcpServersForWorkspace: workspace + per-repo prefs lookup. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { MCPServerConfig, ProcessStore, WorkspaceInfo } from '@plusplusoneplusplus/forge'; +import { setHomeDirectoryOverride, clearMcpConfigCache } from '@plusplusoneplusplus/forge'; +import { + applyMcpAllowList, + resolveChatMcpServers, + resolveChatMcpServersForWorkspace, +} from '../../../src/server/executors/mcp-tool-enforcement'; +import { writeRepoPreferences } from '../../../src/server/preferences-handler'; + +// ---------------------------------------------------------------------------- +// applyMcpAllowList (pure) +// ---------------------------------------------------------------------------- + +describe('applyMcpAllowList', () => { + const servers: Record = { + alpha: { command: 'alpha-bin', tools: ['*'] }, + beta: { type: 'http', url: 'https://beta', tools: ['*'] }, + }; + + it('returns undefined when no servers are configured', () => { + expect(applyMcpAllowList({}, null, null)).toBeUndefined(); + expect(applyMcpAllowList({}, ['alpha'], { alpha: ['a'] })).toBeUndefined(); + }); + + it('keeps all servers and tools when both allow-lists are absent', () => { + const result = applyMcpAllowList(servers, null, null); + expect(Object.keys(result!).sort()).toEqual(['alpha', 'beta']); + expect(result!.alpha.tools).toEqual(['*']); + expect(result!.beta.tools).toEqual(['*']); + }); + + it('enables exactly the listed tools (disabled tool absent)', () => { + const result = applyMcpAllowList(servers, null, { alpha: ['keep_me'] }); + expect(result!.alpha.tools).toEqual(['keep_me']); + // Server with no entry keeps all tools. + expect(result!.beta.tools).toEqual(['*']); + }); + + it('treats an empty entry as "all tools disabled" for that server', () => { + const result = applyMcpAllowList(servers, null, { alpha: [] }); + expect(result!.alpha.tools).toEqual([]); + expect(result!.beta.tools).toEqual(['*']); + }); + + it('omits a server disabled at the server level', () => { + const result = applyMcpAllowList(servers, ['beta'], null); + expect(Object.keys(result!)).toEqual(['beta']); + expect(result!.alpha).toBeUndefined(); + }); + + it('returns an empty map (disable all) when every server is disabled', () => { + const result = applyMcpAllowList(servers, [], null); + expect(result).toEqual({}); + }); + + it('treats null/undefined enabledMcpServers as all-enabled', () => { + expect(Object.keys(applyMcpAllowList(servers, null, null)!).sort()).toEqual(['alpha', 'beta']); + expect(Object.keys(applyMcpAllowList(servers, undefined, null)!).sort()).toEqual(['alpha', 'beta']); + }); + + it('does not mutate the input server configs', () => { + const input: Record = { alpha: { command: 'alpha-bin', tools: ['*'] } }; + applyMcpAllowList(input, null, { alpha: ['only'] }); + expect(input.alpha.tools).toEqual(['*']); + }); + + it('combines server-level and tool-level filtering', () => { + const result = applyMcpAllowList(servers, ['alpha'], { alpha: ['x', 'y'] }); + expect(Object.keys(result!)).toEqual(['alpha']); + expect(result!.alpha.tools).toEqual(['x', 'y']); + }); +}); + +// ---------------------------------------------------------------------------- +// resolveChatMcpServers (effective config + allow-list) +// ---------------------------------------------------------------------------- + +describe('resolveChatMcpServers', () => { + let tmpHome: string; + let tmpWorkspace: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-enf-home-')); + tmpWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-enf-ws-')); + setHomeDirectoryOverride(tmpHome); + clearMcpConfigCache(); + + fs.mkdirSync(path.join(tmpHome, '.copilot'), { recursive: true }); + fs.writeFileSync( + path.join(tmpHome, '.copilot', 'mcp-config.json'), + JSON.stringify({ + mcpServers: { + g1: { command: 'g1-bin' }, + g2: { command: 'g2-bin' }, + }, + }), + ); + }); + + afterEach(() => { + setHomeDirectoryOverride(null); + clearMcpConfigCache(); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpWorkspace, { recursive: true, force: true }); + }); + + it('returns undefined when no rootPath is supplied', () => { + expect(resolveChatMcpServers({ rootPath: undefined, enabledMcpServers: null, enabledMcpTools: null })) + .toBeUndefined(); + }); + + it('resolves the effective config and applies the tool allow-list', () => { + const result = resolveChatMcpServers({ + rootPath: tmpWorkspace, + enabledMcpServers: null, + enabledMcpTools: { g1: ['only_this'] }, + forceReload: true, + }); + expect(Object.keys(result!).sort()).toEqual(['g1', 'g2']); + expect(result!.g1.tools).toEqual(['only_this']); + // Loader defaults g2's tools to ['*'] when not specified. + expect(result!.g2.tools).toEqual(['*']); + }); + + it('drops a server disabled at the server level', () => { + const result = resolveChatMcpServers({ + rootPath: tmpWorkspace, + enabledMcpServers: ['g1'], + enabledMcpTools: null, + forceReload: true, + }); + expect(Object.keys(result!)).toEqual(['g1']); + }); +}); + +// ---------------------------------------------------------------------------- +// resolveChatMcpServersForWorkspace (workspace + prefs lookup) +// ---------------------------------------------------------------------------- + +function makeStoreWithWorkspace(ws: WorkspaceInfo | undefined): ProcessStore { + return { + getWorkspaces: async () => (ws ? [ws] : []), + } as unknown as ProcessStore; +} + +describe('resolveChatMcpServersForWorkspace', () => { + let tmpHome: string; + let tmpWorkspace: string; + let tmpData: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-enf-home-')); + tmpWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-enf-ws-')); + tmpData = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-enf-data-')); + setHomeDirectoryOverride(tmpHome); + clearMcpConfigCache(); + + fs.mkdirSync(path.join(tmpHome, '.copilot'), { recursive: true }); + fs.writeFileSync( + path.join(tmpHome, '.copilot', 'mcp-config.json'), + JSON.stringify({ + mcpServers: { + srv: { command: 'srv-bin' }, + }, + }), + ); + }); + + afterEach(() => { + setHomeDirectoryOverride(null); + clearMcpConfigCache(); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpWorkspace, { recursive: true, force: true }); + fs.rmSync(tmpData, { recursive: true, force: true }); + }); + + it('returns undefined without a workspaceId', async () => { + const store = makeStoreWithWorkspace(undefined); + const result = await resolveChatMcpServersForWorkspace({ + store, + dataDir: tmpData, + workspaceId: undefined, + workingDirectory: tmpWorkspace, + }); + expect(result).toBeUndefined(); + }); + + it('reads enabledMcpTools from per-repo prefs and applies it', async () => { + const ws: WorkspaceInfo = { + id: 'ws-1', + name: 'ws', + rootPath: tmpWorkspace, + enabledMcpServers: null, + } as WorkspaceInfo; + writeRepoPreferences(tmpData, 'ws-1', { enabledMcpTools: { srv: ['kept_tool'] } }); + + const store = makeStoreWithWorkspace(ws); + const result = await resolveChatMcpServersForWorkspace({ + store, + dataDir: tmpData, + workspaceId: 'ws-1', + workingDirectory: tmpWorkspace, + }); + expect(result!.srv.tools).toEqual(['kept_tool']); + }); + + it('honors the server-level allow-list from the workspace record', async () => { + const ws: WorkspaceInfo = { + id: 'ws-2', + name: 'ws', + rootPath: tmpWorkspace, + enabledMcpServers: [], + } as WorkspaceInfo; + + const store = makeStoreWithWorkspace(ws); + const result = await resolveChatMcpServersForWorkspace({ + store, + dataDir: tmpData, + workspaceId: 'ws-2', + workingDirectory: tmpWorkspace, + }); + // Every server disabled → empty map (disable all MCP servers). + expect(result).toEqual({}); + }); + + it('still resolves via the working directory when the workspace lookup throws', async () => { + const store = { + getWorkspaces: async () => { throw new Error('store down'); }, + } as unknown as ProcessStore; + const result = await resolveChatMcpServersForWorkspace({ + store, + dataDir: tmpData, + workspaceId: 'ws-3', + workingDirectory: tmpWorkspace, + }); + // Falls back to working-directory resolution; the global config still + // resolves the configured server with all tools enabled. + expect(result!.srv.tools).toEqual(['*']); + }); +}); diff --git a/packages/coc/test/server/mcp-connection-tester.test.ts b/packages/coc/test/server/mcp-connection-tester.test.ts index 4d2652046..1a0337ba6 100644 --- a/packages/coc/test/server/mcp-connection-tester.test.ts +++ b/packages/coc/test/server/mcp-connection-tester.test.ts @@ -41,7 +41,7 @@ vi.mock('https', () => ({ // ============================================================================ import type { McpTestRequest } from '../../src/server/routes/mcp-connection-tester'; -import { testMcpConnection } from '../../src/server/routes/mcp-connection-tester'; +import { testMcpConnection, listMcpTools } from '../../src/server/routes/mcp-connection-tester'; // ============================================================================ // Helpers @@ -67,9 +67,10 @@ function makeChildStub() { } /** Build a minimal fake http.IncomingMessage stub */ -function makeIncomingMessage(statusCode: number) { +function makeIncomingMessage(statusCode: number, headers: Record = {}) { const msg = new EventEmitter() as any; msg.statusCode = statusCode; + msg.headers = headers; msg.resume = vi.fn(); return msg; } @@ -77,11 +78,32 @@ function makeIncomingMessage(statusCode: number) { /** Build a minimal fake http.ClientRequest stub */ function makeClientRequest() { const req = new EventEmitter() as any; + req.write = vi.fn(); req.end = vi.fn(); req.destroy = vi.fn(); return req; } +/** + * Drive a sequence of POST responses for the Streamable HTTP discovery path. + * Each entry maps to one `http.request` call (initialize, initialized, tools/list). + */ +function setupHttpSequence(responses: Array<{ status?: number; body?: any; headers?: Record }>) { + let call = 0; + mockHttpRequest.mockImplementation((_opts: any, callback: any) => { + const spec = responses[call++] ?? { status: 200, body: '' }; + const clientReq = makeClientRequest(); + const msg = makeIncomingMessage(spec.status ?? 200, { 'content-type': 'application/json', ...(spec.headers ?? {}) }); + callback(msg); + queueMicrotask(() => { + const bodyStr = spec.body === undefined ? '' : typeof spec.body === 'string' ? spec.body : JSON.stringify(spec.body); + if (bodyStr) msg.emit('data', Buffer.from(bodyStr)); + msg.emit('end'); + }); + return clientReq; + }); +} + // ============================================================================ // Tests // ============================================================================ @@ -324,3 +346,179 @@ describe('testMcpConnection', () => { }); }); }); + +// ============================================================================ +// listMcpTools +// ============================================================================ + +describe('listMcpTools', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('stdio transport', () => { + /** Emit the initialize (id=1) response then the tools/list (id=2) response. */ + function driveHandshake(stdout: EventEmitter, tools: unknown[]) { + stdout.emit('data', Buffer.from(JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: { name: 'srv' } }, + }) + '\n')); + stdout.emit('data', Buffer.from(JSON.stringify({ + jsonrpc: '2.0', + id: 2, + result: { tools }, + }) + '\n')); + } + + it('returns error (not throw) when command is missing', async () => { + const result = await listMcpTools({ type: 'stdio' } as McpTestRequest); + expect(result.success).toBe(false); + expect(result.tools).toEqual([]); + expect(result.message).toMatch(/command/i); + }); + + it('returns error entry when spawn throws (per-server isolation)', async () => { + mockSpawn.mockImplementation(() => { throw new Error('spawn ENOENT'); }); + const result = await listMcpTools({ type: 'stdio', command: 'nope' }); + expect(result.success).toBe(false); + expect(result.tools).toEqual([]); + expect(result.message).toMatch(/spawn|ENOENT/i); + }); + + it('discovers real tools after a full initialize + tools/list handshake', async () => { + const { child, stdout, stdin } = makeChildStub(); + mockSpawn.mockReturnValue(child); + + const resultPromise = listMcpTools({ type: 'stdio', command: 'fake' }); + driveHandshake(stdout, [ + { name: 'read_file', description: 'Read a file', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } }, + { name: 'write_file', description: 'Write a file' }, + ]); + + const result = await resultPromise; + expect(result.success).toBe(true); + expect(result.serverName).toBe('srv'); + expect(result.tools).toHaveLength(2); + expect(result.tools[0]).toEqual({ + name: 'read_file', + description: 'Read a file', + inputSchema: { type: 'object', properties: { path: { type: 'string' } } }, + }); + expect(result.tools[1]).toEqual({ name: 'write_file', description: 'Write a file' }); + // It must complete the handshake before requesting tools. + const written = (stdin.write as any).mock.calls.map((c: any[]) => String(c[0])); + expect(written.some((w: string) => w.includes('"method":"initialize"'))).toBe(true); + expect(written.some((w: string) => w.includes('notifications/initialized'))).toBe(true); + expect(written.some((w: string) => w.includes('"method":"tools/list"'))).toBe(true); + }); + + it('drops malformed tool entries (missing name)', async () => { + const { child, stdout } = makeChildStub(); + mockSpawn.mockReturnValue(child); + + const resultPromise = listMcpTools({ type: 'stdio', command: 'fake' }); + driveHandshake(stdout, [{ name: 'ok' }, { description: 'no name' }, 'not-an-object']); + + const result = await resultPromise; + expect(result.success).toBe(true); + expect(result.tools).toEqual([{ name: 'ok' }]); + }); + + it('returns error when initialize fails', async () => { + const { child, stdout } = makeChildStub(); + mockSpawn.mockReturnValue(child); + + const resultPromise = listMcpTools({ type: 'stdio', command: 'fake' }); + stdout.emit('data', Buffer.from(JSON.stringify({ + jsonrpc: '2.0', id: 1, error: { code: -32600, message: 'bad init' }, + }) + '\n')); + + const result = await resultPromise; + expect(result.success).toBe(false); + expect(result.tools).toEqual([]); + expect(result.message).toMatch(/bad init/); + }); + + it('returns error when tools/list fails', async () => { + const { child, stdout } = makeChildStub(); + mockSpawn.mockReturnValue(child); + + const resultPromise = listMcpTools({ type: 'stdio', command: 'fake' }); + stdout.emit('data', Buffer.from(JSON.stringify({ + jsonrpc: '2.0', id: 1, result: { protocolVersion: '2024-11-05' }, + }) + '\n')); + stdout.emit('data', Buffer.from(JSON.stringify({ + jsonrpc: '2.0', id: 2, error: { code: -32601, message: 'no tools method' }, + }) + '\n')); + + const result = await resultPromise; + expect(result.success).toBe(false); + expect(result.message).toMatch(/no tools method/); + }); + + it('returns error when the process exits before responding', async () => { + const { child } = makeChildStub(); + mockSpawn.mockReturnValue(child); + + const resultPromise = listMcpTools({ type: 'stdio', command: 'fake' }); + child.emit('close', 1); + + const result = await resultPromise; + expect(result.success).toBe(false); + expect(result.message).toMatch(/exit/i); + }); + }); + + describe('http transport', () => { + it('returns error when url is missing', async () => { + const result = await listMcpTools({ type: 'http' } as McpTestRequest); + expect(result.success).toBe(false); + expect(result.tools).toEqual([]); + expect(result.message).toMatch(/url/i); + }); + + it('discovers tools over Streamable HTTP (initialize → tools/list)', async () => { + setupHttpSequence([ + { status: 200, headers: { 'mcp-session-id': 'sess-1' }, body: { jsonrpc: '2.0', id: 1, result: { protocolVersion: '2024-11-05', serverInfo: { name: 'remote' } } } }, + { status: 202, body: '' }, + { status: 200, body: { jsonrpc: '2.0', id: 2, result: { tools: [{ name: 'search', description: 'Search docs' }] } } }, + ]); + + const result = await listMcpTools({ type: 'http', url: 'http://localhost:8080/mcp' }); + expect(result.success).toBe(true); + expect(result.serverName).toBe('remote'); + expect(result.tools).toEqual([{ name: 'search', description: 'Search docs' }]); + }); + + it('parses tools from a text/event-stream response', async () => { + setupHttpSequence([ + { status: 200, headers: { 'content-type': 'text/event-stream' }, body: 'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05"}}\n\n' }, + { status: 202, body: '' }, + { status: 200, headers: { 'content-type': 'text/event-stream' }, body: 'event: message\ndata: {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"x"}]}}\n\n' }, + ]); + + const result = await listMcpTools({ type: 'http', url: 'http://localhost:8080/mcp' }); + expect(result.success).toBe(true); + expect(result.tools).toEqual([{ name: 'x' }]); + }); + + it('returns a per-server error on HTTP 500', async () => { + setupHttpSequence([{ status: 500, body: '' }]); + const result = await listMcpTools({ type: 'http', url: 'http://localhost:8080/mcp' }); + expect(result.success).toBe(false); + expect(result.tools).toEqual([]); + expect(result.message).toMatch(/500/); + }); + + it('returns a per-server error on connection failure', async () => { + const clientReq = makeClientRequest(); + mockHttpRequest.mockImplementation(() => clientReq); + const resultPromise = listMcpTools({ type: 'http', url: 'http://localhost:9999/mcp' }); + clientReq.emit('error', new Error('ECONNREFUSED')); + const result = await resultPromise; + expect(result.success).toBe(false); + expect(result.message).toMatch(/ECONNREFUSED/); + }); + }); +}); diff --git a/packages/coc/test/server/mcp-tools-config-roundtrip.test.ts b/packages/coc/test/server/mcp-tools-config-roundtrip.test.ts new file mode 100644 index 000000000..52517b8c8 --- /dev/null +++ b/packages/coc/test/server/mcp-tools-config-roundtrip.test.ts @@ -0,0 +1,158 @@ +/** + * MCP enabled-tools allow-list round-trip (AC-03). + * + * Verifies that `PUT /api/workspaces/:id/mcp-config` with an `enabledMcpTools` + * allow-list persists to the per-repo preference file and is echoed back by + * `GET /api/workspaces/:id/mcp-config` (so a UI reload reflects the toggle). + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import * as http from 'http'; +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import { createRouter } from '../../src/server/shared/router'; +import { registerApiRoutes } from '../../src/server/core/api-handler'; +import type { Route } from '../../src/server/types'; +import { createMockProcessStore } from './helpers/mock-process-store'; +import { readRepoPreferences } from '../../src/server/preferences-handler'; + +const mockLoadDefaultMcpConfig = vi.hoisted(() => vi.fn()); +const mockLoadWorkspaceMcpConfig = vi.hoisted(() => vi.fn()); +vi.mock('@plusplusoneplusplus/forge', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + loadDefaultMcpConfig: mockLoadDefaultMcpConfig, + loadWorkspaceMcpConfig: mockLoadWorkspaceMcpConfig, + }; +}); + +const mockReadAllDescriptions = vi.hoisted(() => vi.fn().mockReturnValue({})); +vi.mock('../../src/server/routes/mcp-config-writer', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, readAllDescriptions: mockReadAllDescriptions }; +}); + +// Avoid live network probes when the tools-discovery endpoint resolves. +vi.mock('../../src/server/routes/mcp-connection-tester', () => ({ + testMcpConnection: vi.fn(), + listMcpTools: vi.fn().mockResolvedValue({ success: true, message: 'ok', tools: [] }), +})); + +function request( + url: string, + options: { method?: string; body?: string } = {}, +): Promise<{ status: number; json: () => any }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname + parsed.search, + method: options.method || 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + const bodyStr = Buffer.concat(chunks).toString('utf-8'); + resolve({ status: res.statusCode || 0, json: () => JSON.parse(bodyStr) }); + }); + }, + ); + req.on('error', reject); + if (options.body) req.write(options.body); + req.end(); + }); +} + +describe('MCP enabled-tools allow-list round-trip', () => { + let server: http.Server; + let port: number; + let dataDir: string; + let mockStore: ReturnType; + + const WORKSPACE_ID = 'ws-tools'; + + beforeAll(async () => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-tools-roundtrip-')); + mockStore = createMockProcessStore({ + initialWorkspaces: [{ id: WORKSPACE_ID, name: 'Proj', rootPath: '/projects/proj' }], + }); + const routes: Route[] = []; + registerApiRoutes(routes, mockStore, undefined, dataDir); + const handleRequest = createRouter({ routes, spaHtml: '' }); + server = http.createServer(handleRequest); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + port = (server.address() as any).port; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + beforeEach(() => { + mockReadAllDescriptions.mockReturnValue({}); + mockLoadDefaultMcpConfig.mockReturnValue({ mcpServers: { github: { command: 'npx', type: 'stdio' } }, configPath: '~/.copilot/mcp-config.json', fileExists: true }); + mockLoadWorkspaceMcpConfig.mockReturnValue({ mcpServers: {}, configPath: '/projects/proj/.vscode/mcp.json', fileExists: false }); + (mockStore.getWorkspaces as any).mockResolvedValue([ + { id: WORKSPACE_ID, name: 'Proj', rootPath: '/projects/proj' }, + ]); + (mockStore.updateWorkspace as any).mockImplementation(async (id: string, updates: any) => ({ + id, name: 'Proj', rootPath: '/projects/proj', ...updates, + })); + }); + + const base = () => `http://127.0.0.1:${port}`; + + it('GET returns enabledMcpTools: null when none persisted', async () => { + const res = await request(`${base()}/api/workspaces/${WORKSPACE_ID}/mcp-config`); + expect(res.status).toBe(200); + expect(res.json().enabledMcpTools).toBeNull(); + }); + + it('PUT persists enabledMcpTools and GET echoes it back', async () => { + const put = await request(`${base()}/api/workspaces/${WORKSPACE_ID}/mcp-config`, { + method: 'PUT', + body: JSON.stringify({ + enabledMcpServers: null, + enabledMcpTools: { github: ['create_issue'] }, + }), + }); + expect(put.status).toBe(200); + + // Preference file round-trips on disk. + expect(readRepoPreferences(dataDir, WORKSPACE_ID).enabledMcpTools).toEqual({ github: ['create_issue'] }); + + // GET reflects the persisted allow-list (survives a "reload"). + const get = await request(`${base()}/api/workspaces/${WORKSPACE_ID}/mcp-config`); + expect(get.json().enabledMcpTools).toEqual({ github: ['create_issue'] }); + }); + + it('PUT with enabledMcpTools: null clears the allow-list', async () => { + await request(`${base()}/api/workspaces/${WORKSPACE_ID}/mcp-config`, { + method: 'PUT', + body: JSON.stringify({ enabledMcpServers: null, enabledMcpTools: { github: ['x'] } }), + }); + await request(`${base()}/api/workspaces/${WORKSPACE_ID}/mcp-config`, { + method: 'PUT', + body: JSON.stringify({ enabledMcpServers: null, enabledMcpTools: null }), + }); + const get = await request(`${base()}/api/workspaces/${WORKSPACE_ID}/mcp-config`); + expect(get.json().enabledMcpTools).toBeNull(); + }); + + it('persists an empty allow-list (disable-all) for a server', async () => { + const put = await request(`${base()}/api/workspaces/${WORKSPACE_ID}/mcp-config`, { + method: 'PUT', + body: JSON.stringify({ enabledMcpServers: null, enabledMcpTools: { github: [] } }), + }); + expect(put.status).toBe(200); + const get = await request(`${base()}/api/workspaces/${WORKSPACE_ID}/mcp-config`); + expect(get.json().enabledMcpTools).toEqual({ github: [] }); + }); +}); diff --git a/packages/coc/test/server/mcp-tools-discovery.test.ts b/packages/coc/test/server/mcp-tools-discovery.test.ts new file mode 100644 index 000000000..309dde85c --- /dev/null +++ b/packages/coc/test/server/mcp-tools-discovery.test.ts @@ -0,0 +1,206 @@ +/** + * MCP Tools Discovery Unit Tests + * + * Covers: + * - configToTestRequest mapping (stdio/http, invalid) + * - resolveEnabledMcpServers (effective merge + enabled allow-list filter) + * - discoverMcpToolsForServers (per-server success/error isolation) + * + * `listMcpTools` from mcp-connection-tester is mocked so no real processes spawn. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const listMcpToolsMock = vi.hoisted(() => vi.fn()); +vi.mock('../../src/server/routes/mcp-connection-tester', async (importActual) => { + const actual = await importActual(); + return { ...actual, listMcpTools: listMcpToolsMock }; +}); + +import { + configToTestRequest, + resolveEnabledMcpServers, + discoverMcpToolsForServers, + discoverWorkspaceMcpTools, +} from '../../src/server/routes/mcp-tools-discovery'; +import { setHomeDirectoryOverride } from '@plusplusoneplusplus/forge'; + +// ---------------------------------------------------------------------------- +// configToTestRequest +// ---------------------------------------------------------------------------- + +describe('configToTestRequest', () => { + it('maps a stdio server config to a request', () => { + const req = configToTestRequest({ type: 'stdio', command: 'node', args: ['server.js'], env: { A: '1' }, tools: ['*'] } as any); + expect(req).toEqual({ type: 'stdio', command: 'node', args: ['server.js'], env: { A: '1' } }); + }); + + it('treats missing type as stdio', () => { + const req = configToTestRequest({ command: 'mcp-bin' } as any); + expect(req).toEqual({ type: 'stdio', command: 'mcp-bin' }); + }); + + it('maps an http server config (with headers) to a request', () => { + const req = configToTestRequest({ type: 'http', url: 'http://localhost/mcp', headers: { Authorization: 'Bearer x' } } as any); + expect(req).toEqual({ type: 'http', url: 'http://localhost/mcp', headers: { Authorization: 'Bearer x' } }); + }); + + it('returns null for stdio config without a command', () => { + expect(configToTestRequest({ type: 'stdio' } as any)).toBeNull(); + }); + + it('returns null for http config without a url', () => { + expect(configToTestRequest({ type: 'http' } as any)).toBeNull(); + }); +}); + +// ---------------------------------------------------------------------------- +// resolveEnabledMcpServers +// ---------------------------------------------------------------------------- + +describe('resolveEnabledMcpServers', () => { + let tmpHome: string; + let tmpWorkspace: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-mcp-home-')); + tmpWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-mcp-ws-')); + setHomeDirectoryOverride(tmpHome); + + fs.mkdirSync(path.join(tmpHome, '.copilot'), { recursive: true }); + fs.writeFileSync( + path.join(tmpHome, '.copilot', 'mcp-config.json'), + JSON.stringify({ + mcpServers: { + g1: { command: 'g1-bin' }, + g2: { command: 'g2-bin' }, + }, + }), + ); + + fs.mkdirSync(path.join(tmpWorkspace, '.vscode'), { recursive: true }); + fs.writeFileSync( + path.join(tmpWorkspace, '.vscode', 'mcp.json'), + JSON.stringify({ + servers: { + w1: { command: 'w1-bin', args: ['--flag'] }, + }, + }), + ); + }); + + afterEach(() => { + setHomeDirectoryOverride(null); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpWorkspace, { recursive: true, force: true }); + }); + + it('returns all servers when enabled list is null', () => { + const resolved = resolveEnabledMcpServers(tmpWorkspace, null, true); + expect(Object.keys(resolved).sort()).toEqual(['g1', 'g2', 'w1']); + expect(resolved.w1).toEqual({ type: 'stdio', command: 'w1-bin', args: ['--flag'] }); + }); + + it('filters by the enabled allow-list', () => { + const resolved = resolveEnabledMcpServers(tmpWorkspace, ['g1', 'w1'], true); + expect(Object.keys(resolved).sort()).toEqual(['g1', 'w1']); + expect(resolved.g2).toBeUndefined(); + }); + + it('returns an empty map when nothing is enabled', () => { + const resolved = resolveEnabledMcpServers(tmpWorkspace, [], true); + expect(resolved).toEqual({}); + }); +}); + +// ---------------------------------------------------------------------------- +// discoverMcpToolsForServers (per-server isolation) +// ---------------------------------------------------------------------------- + +describe('discoverMcpToolsForServers', () => { + beforeEach(() => { + listMcpToolsMock.mockReset(); + }); + + it('returns ok for a reachable server and an error entry for an unreachable one', async () => { + listMcpToolsMock.mockImplementation(async (req: { command?: string }) => { + if (req.command === 'good') { + return { success: true, message: 'ok', tools: [{ name: 't1' }], serverName: 'good-srv' }; + } + return { success: false, message: 'connection refused', tools: [] }; + }); + + const results = await discoverMcpToolsForServers({ + alpha: { type: 'stdio', command: 'good' }, + beta: { type: 'stdio', command: 'bad' }, + }); + + expect(results.alpha).toEqual({ status: 'ok', tools: [{ name: 't1' }], serverName: 'good-srv' }); + expect(results.beta).toEqual({ status: 'error', tools: [], error: 'connection refused' }); + }); + + it('isolates a thrown error to a single server', async () => { + listMcpToolsMock.mockImplementation(async (req: { command?: string }) => { + if (req.command === 'boom') throw new Error('kaboom'); + return { success: true, message: 'ok', tools: [], serverName: 'ok-srv' }; + }); + + const results = await discoverMcpToolsForServers({ + ok: { type: 'stdio', command: 'fine' }, + broken: { type: 'stdio', command: 'boom' }, + }); + + expect(results.ok.status).toBe('ok'); + expect(results.broken).toEqual({ status: 'error', tools: [], error: 'kaboom' }); + }); + + it('returns an empty map for no servers', async () => { + const results = await discoverMcpToolsForServers({}); + expect(results).toEqual({}); + expect(listMcpToolsMock).not.toHaveBeenCalled(); + }); + + it('passes the per-server timeout through to listMcpTools', async () => { + listMcpToolsMock.mockResolvedValue({ success: true, message: 'ok', tools: [] }); + await discoverMcpToolsForServers({ a: { type: 'stdio', command: 'x' } }, { timeoutMs: 1234 }); + expect(listMcpToolsMock).toHaveBeenCalledWith({ type: 'stdio', command: 'x' }, 1234); + }); +}); + +// ---------------------------------------------------------------------------- +// discoverWorkspaceMcpTools (composition) +// ---------------------------------------------------------------------------- + +describe('discoverWorkspaceMcpTools', () => { + let tmpHome: string; + let tmpWorkspace: string; + + beforeEach(() => { + listMcpToolsMock.mockReset(); + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-mcp-home-')); + tmpWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-mcp-ws-')); + setHomeDirectoryOverride(tmpHome); + fs.mkdirSync(path.join(tmpHome, '.copilot'), { recursive: true }); + fs.writeFileSync( + path.join(tmpHome, '.copilot', 'mcp-config.json'), + JSON.stringify({ mcpServers: { g1: { command: 'g1-bin' }, g2: { command: 'g2-bin' } } }), + ); + }); + + afterEach(() => { + setHomeDirectoryOverride(null); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpWorkspace, { recursive: true, force: true }); + }); + + it('discovers only enabled servers and keys results by server name', async () => { + listMcpToolsMock.mockResolvedValue({ success: true, message: 'ok', tools: [{ name: 'tool' }] }); + const results = await discoverWorkspaceMcpTools(tmpWorkspace, ['g1'], { forceReload: true }); + expect(Object.keys(results)).toEqual(['g1']); + expect(results.g1.status).toBe('ok'); + expect(results.g1.tools).toEqual([{ name: 'tool' }]); + }); +}); diff --git a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx index 46d6caf64..3fc6e5b58 100644 --- a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx +++ b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx @@ -54,6 +54,27 @@ vi.mock('../../../../src/server/spa/client/react/hooks/preferences/useDisplaySet useDisplaySettings: () => ({ showReportIntent: false }), })); +// Keep ConversationArea + ConversationTurnBubble REAL (the path this suite +// exists to prove), but stub the surrounding chat chrome so the test stays +// focused on the transcript: the header renders its title + the native-session +// metadata rows it receives, the minimap and disabled composer are inert. +vi.mock('../../../../src/server/spa/client/react/features/chat/ChatHeader', () => ({ + ChatHeader: ({ title, metadataExtraRows, viewToggle }: any) => ( +
+ {viewToggle} + {(metadataExtraRows ?? []).map((r: any, i: number) => ( +
{r.label}: {r.value}
+ ))} +
+ ), +})); +vi.mock('../../../../src/server/spa/client/react/features/chat/conversation/ConversationMiniMap', () => ({ + ConversationMiniMap: () =>
, +})); +// FollowUpInputArea is rendered REAL here (not stubbed) so this suite also +// proves AC-05: chat's actual composer mounts disabled for a native session +// with the panel's inert stub props, without crashing. + import { NativeCopilotSessionsPanel } from '../../../../src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -208,9 +229,11 @@ describe('NativeCopilotSessionsPanel — real ConversationTurnBubble integration const detail = await openDetail(); expect(mockGet).toHaveBeenCalledWith('ws-1', 'session-rich-1', 'codex'); - // Metadata header preserved alongside the transcript. + // Session metadata (stored summary) is surfaced through the chat header's + // metadata rows; the transcript renders in the chat conversation area. expect(detail.textContent).toContain('Inspect the native session store and report its schema.'); - expect(screen.getByTestId('native-session-conversation').textContent).toContain('Conversation (3)'); + const conversation = screen.getByTestId('native-session-conversation'); + expect(conversation.textContent).toContain('can you check the session-store.db'); // The GENUINE ToolCallView surface rendered (not the panel suite's stub): // both bash calls render as full cards with their names. @@ -257,4 +280,15 @@ describe('NativeCopilotSessionsPanel — real ConversationTurnBubble integration expect(screen.queryByRole('button', { name: pattern })).toBeNull(); } }); + + it('renders the real follow-up composer blocked, with a read-only note (AC-05)', async () => { + await openDetail(); + // The genuine chat composer mounts (its disabled contenteditable input is + // present) rather than being stubbed. + const input = screen.getByTestId('activity-chat-input'); + expect(input).toBeTruthy(); + expect(input.getAttribute('contenteditable')).toBe('false'); + // A read-only note explains why follow-up is unavailable. + expect(screen.getByTestId('native-session-readonly-helper').textContent ?? '').toMatch(/disabled/i); + }); }); diff --git a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx index 99f0235db..e5fa53dad 100644 --- a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx +++ b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx @@ -29,27 +29,74 @@ vi.mock('../../../../src/server/spa/client/react/ui', () => ({ cn: (...parts: unknown[]) => parts.filter(Boolean).join(' '), })); -// Stub the reused chat bubble so the panel test stays focused on the panel's -// mapping/integration (the bubble is exercised by its own suite). The stub -// surfaces the mapped turn's shape via data-attributes + renders content as a -// React text node (so any stored HTML stays inert, mirroring the real bubble). -vi.mock('../../../../src/server/spa/client/react/features/chat/conversation/ConversationTurnBubble', () => ({ - ConversationTurnBubble: ({ turn, wsId, provider }: any) => ( +// Stub the reused chat sub-components so the panel test stays focused on the +// panel's fetch→prop wiring (ChatHeader, ConversationArea, ConversationMiniMap, +// and FollowUpInputArea are each exercised by their own suites). Each stub +// surfaces the props the panel passes via data-attributes. The ConversationArea +// stub reproduces the chat bubble's data-shape (one node per mapped turn, with +// content rendered as an inert React text node) so the transcript-mapping +// assertions still hold without pulling in the real bubble. +vi.mock('../../../../src/server/spa/client/react/features/chat/ChatHeader', () => ({ + ChatHeader: ({ title, task, turns, metadataExtraRows, viewToggle }: any) => (
+ {viewToggle} +
+ ), +})); + +vi.mock('../../../../src/server/spa/client/react/features/chat/ConversationArea', () => ({ + ConversationArea: ({ turns, provider, wsId, onAttachContext, onPinTurn, onArchiveTurn, onDeleteTurn }: any) => ( +
t.toolName).join(',')} - data-images={String((turn.images ?? []).length)} - data-timeline-types={(turn.timeline ?? []).map((i: any) => i.type).join(',')} - data-model={turn.model ?? ''} + data-attach-context={onAttachContext ? 'on' : 'off'} + data-pin={onPinTurn ? 'on' : 'off'} + data-archive={onArchiveTurn ? 'on' : 'off'} + data-delete={onDeleteTurn ? 'on' : 'off'} > - {turn.content} + {turns.length === 0 ? ( +
+ ) : ( + turns.map((turn: any, index: number) => ( +
t.toolName).join(',')} + data-images={String((turn.images ?? []).length)} + data-timeline-types={(turn.timeline ?? []).map((i: any) => i.type).join(',')} + data-model={turn.model ?? ''} + > + {turn.content} +
+ )) + )}
), })); +vi.mock('../../../../src/server/spa/client/react/features/chat/conversation/ConversationMiniMap', () => ({ + ConversationMiniMap: () =>
, +})); + +vi.mock('../../../../src/server/spa/client/react/features/chat/FollowUpInputArea', () => ({ + FollowUpInputArea: ({ inputDisabled, hideModeSelector }: any) => ( +
+ ), +})); + import { NativeCopilotSessionsPanel } from '../../../../src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -237,9 +284,30 @@ describe('NativeCopilotSessionsPanel', () => { expect(mockGet).toHaveBeenCalledWith('ws-1', 'session-aaaa-bbbb', 'copilot'); const detail = screen.getByTestId('native-session-detail'); - // Metadata header preserved. - expect(detail.textContent).toContain('Full stored summary'); - expect(screen.getByTestId('native-session-conversation').textContent).toContain('Conversation (2)'); + // The transcript is presented with chat's own header + conversation area + // (chat UI parity), not the legacy custom card layout. + const header = screen.getByTestId('chat-header'); + expect(header.getAttribute('data-title')).toBe('session-aaaa-bbbb'); + expect(header.getAttribute('data-provider')).toBe('copilot'); + expect(header.getAttribute('data-turns')).toBe('2'); + // AC-04: session metadata (incl. the stored summary) is surfaced through + // the header's metadata popover via extra rows, not a standalone card. + const extraRows = JSON.parse(header.getAttribute('data-extra-rows') ?? '[]'); + expect(extraRows.find((r: any) => r.label === 'Summary')?.value).toBe('Full stored summary'); + expect(extraRows.find((r: any) => r.label === 'Repository')?.value).toBe('owner/repo'); + expect(screen.getByTestId('conversation-area')).toBeTruthy(); + expect(screen.getByTestId('conversation-minimap')).toBeTruthy(); + + // AC-05: the follow-up composer is rendered but blocked (greyed/disabled). + expect(screen.getByTestId('follow-up-input-area').getAttribute('data-input-disabled')).toBe('true'); + + // AC-06: per-turn mutations are inert (no CoC processId) — attach-context + // stays, pin/archive/delete are not wired. + const conversationArea = screen.getByTestId('conversation-area'); + expect(conversationArea.getAttribute('data-attach-context')).toBe('on'); + expect(conversationArea.getAttribute('data-pin')).toBe('off'); + expect(conversationArea.getAttribute('data-archive')).toBe('off'); + expect(conversationArea.getAttribute('data-delete')).toBe('off'); // Transcript renders one reused chat bubble per conversation turn, in order. const bubbles = screen.getAllByTestId('conversation-turn-bubble'); @@ -279,7 +347,7 @@ describe('NativeCopilotSessionsPanel', () => { fireEvent.click(screen.getAllByTestId('native-session-row')[0]); await waitFor(() => expect(screen.getByTestId('native-session-detail')).toBeTruthy()); - expect(screen.getByTestId('native-session-conversation').textContent).toContain('Conversation (0)'); + expect(screen.getByTestId('conversation-area')).toBeTruthy(); expect(screen.getByTestId('native-session-no-turns')).toBeTruthy(); expect(screen.queryByTestId('conversation-turn-bubble')).toBeNull(); }); diff --git a/packages/coc/test/server/spa/client/nativeSessionChatAdapter.test.ts b/packages/coc/test/server/spa/client/nativeSessionChatAdapter.test.ts new file mode 100644 index 000000000..7551d44b1 --- /dev/null +++ b/packages/coc/test/server/spa/client/nativeSessionChatAdapter.test.ts @@ -0,0 +1,156 @@ +/** + * Unit tests for the native-session → chat `task`/`metadataProcess` adapters + * that let the read-only CLI Sessions detail view reuse `ChatHeader` and + * `ConversationMetadataPopover` without a fork. + */ +import { describe, it, expect } from 'vitest'; +import type { NativeCliSessionDetail } from '@plusplusoneplusplus/coc-client'; +import { + buildNativeSessionMetadataExtraRows, + deriveNativeSessionModel, + formatNativeSessionTimestamp, + nativeSessionTitle, + toNativeSessionHeaderTask, + toNativeSessionMetadataProcess, +} from '../../../../src/server/spa/client/react/features/native-copilot-sessions/nativeSessionChatAdapter'; + +function makeDetail(overrides: Partial = {}): NativeCliSessionDetail { + return { + id: 'session-aaaa-bbbb', + repository: 'owner/repo', + cwd: '/workspace/path', + hostType: 'github', + branch: 'main', + summary: 'Full stored summary', + createdAt: '2026-06-11T17:56:21.130Z', + updatedAt: '2026-06-11T17:56:22.081Z', + turns: [], + conversation: [ + { role: 'user', content: 'hi', timeline: [] }, + { role: 'assistant', content: 'hello', timeline: [], model: 'gpt-5.5' }, + ], + provider: 'copilot', + storePath: '/home/me/.copilot/session-store.db', + searchIndexAvailable: true, + ...overrides, + }; +} + +describe('nativeSessionTitle', () => { + it('uses the session id as the header title', () => { + expect(nativeSessionTitle(makeDetail())).toBe('session-aaaa-bbbb'); + }); +}); + +describe('toNativeSessionHeaderTask', () => { + it('carries the provider for the header provider badge and no run status', () => { + const task = toNativeSessionHeaderTask(makeDetail({ provider: 'claude' })); + expect(task).toEqual({ type: 'chat', metadata: { provider: 'claude' } }); + // No status field — native sessions have no CoC run lifecycle. + expect('status' in task).toBe(false); + }); +}); + +describe('deriveNativeSessionModel', () => { + it('returns the most recent assistant turn model', () => { + const detail = makeDetail({ + conversation: [ + { role: 'assistant', content: 'a', timeline: [], model: 'old-model' }, + { role: 'user', content: 'q', timeline: [] }, + { role: 'assistant', content: 'b', timeline: [], model: 'new-model' }, + ], + }); + expect(deriveNativeSessionModel(detail)).toBe('new-model'); + }); + + it('skips assistant turns without a model and trims whitespace', () => { + const detail = makeDetail({ + conversation: [ + { role: 'assistant', content: 'a', timeline: [], model: ' spaced-model ' }, + { role: 'assistant', content: 'b', timeline: [] }, + ], + }); + expect(deriveNativeSessionModel(detail)).toBe('spaced-model'); + }); + + it('returns undefined when no assistant turn carries a model', () => { + const detail = makeDetail({ + conversation: [ + { role: 'user', content: 'q', timeline: [] }, + { role: 'assistant', content: 'a', timeline: [], model: ' ' }, + ], + }); + expect(deriveNativeSessionModel(detail)).toBeUndefined(); + }); + + it('returns undefined for an empty conversation', () => { + expect(deriveNativeSessionModel(makeDetail({ conversation: [] }))).toBeUndefined(); + }); +}); + +describe('toNativeSessionMetadataProcess', () => { + it('includes the derived model when an assistant turn recorded one', () => { + expect(toNativeSessionMetadataProcess(makeDetail())).toEqual({ + metadata: { provider: 'copilot', model: 'gpt-5.5' }, + }); + }); + + it('omits the model when no assistant turn recorded one', () => { + const process = toNativeSessionMetadataProcess(makeDetail({ conversation: [] })); + expect(process).toEqual({ metadata: { provider: 'copilot' } }); + expect('model' in process.metadata).toBe(false); + }); +}); + +describe('formatNativeSessionTimestamp', () => { + it('returns null for null/blank values', () => { + expect(formatNativeSessionTimestamp(null)).toBeNull(); + expect(formatNativeSessionTimestamp(undefined)).toBeNull(); + expect(formatNativeSessionTimestamp(' ')).toBeNull(); + }); + + it('returns the raw value when unparseable', () => { + expect(formatNativeSessionTimestamp('not-a-date')).toBe('not-a-date'); + }); + + it('returns a locale string for a valid ISO timestamp', () => { + const iso = '2026-06-11T17:56:21.130Z'; + expect(formatNativeSessionTimestamp(iso)).toBe(new Date(Date.parse(iso)).toLocaleString()); + }); +}); + +describe('buildNativeSessionMetadataExtraRows', () => { + it('surfaces repository, branch, cwd, host, created/updated, and summary', () => { + const rows = buildNativeSessionMetadataExtraRows(makeDetail()); + const byLabel = new Map(rows.map(r => [r.label, r.value])); + expect(byLabel.get('Repository')).toBe('owner/repo'); + expect(byLabel.get('Branch')).toBe('main'); + expect(byLabel.get('Working Directory')).toBe('/workspace/path'); + expect(byLabel.get('Host')).toBe('github'); + expect(byLabel.get('Created')).toBe(formatNativeSessionTimestamp('2026-06-11T17:56:21.130Z')); + expect(byLabel.get('Updated')).toBe(formatNativeSessionTimestamp('2026-06-11T17:56:22.081Z')); + expect(byLabel.get('Summary')).toBe('Full stored summary'); + // Long / path-like values are marked break-all for layout. + expect(rows.find(r => r.label === 'Repository')?.breakAll).toBe(true); + expect(rows.find(r => r.label === 'Working Directory')?.breakAll).toBe(true); + expect(rows.find(r => r.label === 'Summary')?.breakAll).toBe(true); + }); + + it('omits rows whose source field is null or blank', () => { + const rows = buildNativeSessionMetadataExtraRows(makeDetail({ + repository: null, + branch: null, + cwd: null, + hostType: null, + createdAt: null, + updatedAt: null, + summary: ' ', + })); + expect(rows).toEqual([]); + }); + + it('preserves a stable display order', () => { + const labels = buildNativeSessionMetadataExtraRows(makeDetail()).map(r => r.label); + expect(labels).toEqual(['Repository', 'Branch', 'Working Directory', 'Host', 'Created', 'Updated', 'Summary']); + }); +}); diff --git a/packages/coc/test/spa/react/ConversationMetadataPopover.test.tsx b/packages/coc/test/spa/react/ConversationMetadataPopover.test.tsx index 874b334d1..f899d3327 100644 --- a/packages/coc/test/spa/react/ConversationMetadataPopover.test.tsx +++ b/packages/coc/test/spa/react/ConversationMetadataPopover.test.tsx @@ -207,6 +207,66 @@ describe('ConversationMetadataPopover', () => { }); }); +describe('ConversationMetadataPopover – extra rows', () => { + const EXTRA_ROWS = [ + { label: 'Repository', value: 'owner/repo', breakAll: true }, + { label: 'Branch', value: 'feature/x' }, + { label: 'Summary', value: 'Stored session summary', breakAll: true }, + ]; + + it('renders extra rows after the standard compact rows', async () => { + render(); + const trigger = screen.getByRole('button', { name: /conversation metadata/i }); + await act(async () => { fireEvent.click(trigger); }); + + // Standard rows still present… + expect(screen.getByText('Process ID')).toBeDefined(); + // …and the appended extra rows render their labels + values. + expect(screen.getByText('Repository')).toBeDefined(); + expect(screen.getByText('owner/repo')).toBeDefined(); + expect(screen.getByText('Branch')).toBeDefined(); + expect(screen.getByText('feature/x')).toBeDefined(); + expect(screen.getByText('Summary')).toBeDefined(); + expect(screen.getByText('Stored session summary')).toBeDefined(); + }); + + it('keeps each extra row in a single grid cell (label + value, no overflow children)', async () => { + render(); + const trigger = screen.getByRole('button', { name: /conversation metadata/i }); + await act(async () => { fireEvent.click(trigger); }); + + const contentsDivs = document.querySelectorAll('.contents'); + for (const div of contentsDivs) { + expect(div.children.length).toBe(2); + } + }); + + it('renders the popover from extra rows alone even when the process yields no rows', async () => { + // An empty process produces no buildRows output; extra rows must still + // surface (read-only native sessions rely on this). + render(); + const trigger = screen.getByRole('button', { name: /conversation metadata/i }); + await act(async () => { fireEvent.click(trigger); }); + + expect(screen.getByText('Repository')).toBeDefined(); + expect(screen.getByText('owner/repo')).toBeDefined(); + }); + + it('does not render the trigger when there are neither rows nor extra rows', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + it('leaves the standard rows unchanged when extraRows is omitted', async () => { + render(); + const trigger = screen.getByRole('button', { name: /conversation metadata/i }); + await act(async () => { fireEvent.click(trigger); }); + + expect(screen.queryByText('Repository')).toBeNull(); + expect(screen.queryByText('Branch')).toBeNull(); + }); +}); + describe('compact metadata helpers', () => { it('moves short categorical fields into a summary strip', () => { const rows = buildRows(BASE_PROCESS, 5); diff --git a/packages/coc/test/spa/react/features/skills/mcpToolsAllowList.test.ts b/packages/coc/test/spa/react/features/skills/mcpToolsAllowList.test.ts new file mode 100644 index 000000000..88173650b --- /dev/null +++ b/packages/coc/test/spa/react/features/skills/mcpToolsAllowList.test.ts @@ -0,0 +1,112 @@ +/** + * Unit tests for the MCP per-tool allow-list write logic (AC-03). + */ + +import { describe, it, expect } from 'vitest'; +import { + isMcpToolEnabled, + toggleMcpToolEntry, + applyMcpToolToggle, + enableAllMcpTools, + disableAllMcpTools, + normalizeEnabledMcpTools, +} from '../../../../../src/server/spa/client/react/features/skills/mcpToolsAllowList'; + +describe('isMcpToolEnabled', () => { + it('treats a missing entry as all-enabled', () => { + expect(isMcpToolEnabled(undefined, 'anything')).toBe(true); + }); + + it('enables only listed tools when an entry exists', () => { + expect(isMcpToolEnabled(['a', 'b'], 'a')).toBe(true); + expect(isMcpToolEnabled(['a', 'b'], 'c')).toBe(false); + }); + + it('disables every tool for an empty entry', () => { + expect(isMcpToolEnabled([], 'a')).toBe(false); + }); +}); + +describe('toggleMcpToolEntry', () => { + const discovered = ['read', 'write', 'delete']; + + it('returns undefined when turning on a tool with no entry (no-op)', () => { + expect(toggleMcpToolEntry(undefined, discovered, 'read', true)).toBeUndefined(); + }); + + it('materializes the complement on the first toggle-off', () => { + expect(toggleMcpToolEntry(undefined, discovered, 'delete', false)).toEqual(['read', 'write']); + }); + + it('removes a tool from an existing entry on toggle-off', () => { + expect(toggleMcpToolEntry(['read', 'write'], discovered, 'write', false)).toEqual(['read']); + }); + + it('adds a tool to an existing entry on toggle-on', () => { + expect(toggleMcpToolEntry(['read'], discovered, 'write', true)).toEqual(['read', 'write']); + }); + + it('does not duplicate an already-enabled tool', () => { + expect(toggleMcpToolEntry(['read'], discovered, 'read', true)).toEqual(['read']); + }); +}); + +describe('applyMcpToolToggle', () => { + it('materializes a new server entry on first toggle-off', () => { + const next = applyMcpToolToggle({}, 'srv', ['a', 'b', 'c'], 'b', false); + expect(next).toEqual({ srv: ['a', 'c'] }); + }); + + it('drops the entry when a tool is re-enabled into a no-op', () => { + // entry undefined + enable → undefined → key deleted (stays absent) + const next = applyMcpToolToggle({ other: ['x'] }, 'srv', ['a'], 'a', true); + expect(next).toEqual({ other: ['x'] }); + expect('srv' in next).toBe(false); + }); + + it('does not mutate the input map', () => { + const input = { srv: ['a', 'b'] }; + const next = applyMcpToolToggle(input, 'srv', ['a', 'b'], 'a', false); + expect(input).toEqual({ srv: ['a', 'b'] }); + expect(next).toEqual({ srv: ['b'] }); + }); + + it('keeps a server entry as an empty list when its last tool is disabled', () => { + const next = applyMcpToolToggle({ srv: ['a'] }, 'srv', ['a'], 'a', false); + expect(next).toEqual({ srv: [] }); + }); +}); + +describe('enableAllMcpTools / disableAllMcpTools', () => { + it('enableAll removes the server entry', () => { + expect(enableAllMcpTools({ srv: ['a'], other: ['x'] }, 'srv')).toEqual({ other: ['x'] }); + }); + + it('disableAll sets an empty list', () => { + expect(disableAllMcpTools({ other: ['x'] }, 'srv')).toEqual({ other: ['x'], srv: [] }); + }); +}); + +describe('normalizeEnabledMcpTools', () => { + it('returns null for an empty map', () => { + expect(normalizeEnabledMcpTools({})).toBeNull(); + }); + + it('returns the map when it has entries', () => { + expect(normalizeEnabledMcpTools({ srv: [] })).toEqual({ srv: [] }); + }); +}); + +describe('round-trip: toggle off then back on materializes then keeps an entry', () => { + it('newly discovered tools default off once an entry exists', () => { + // Start with no entry (all on), disable one tool → entry materialized. + let map = applyMcpToolToggle({}, 'srv', ['a', 'b'], 'b', false); + expect(map).toEqual({ srv: ['a'] }); + // A newly discovered tool 'c' is NOT in the entry → disabled by default. + expect(isMcpToolEnabled(map.srv, 'c')).toBe(false); + // Re-enable 'b' → entry now ['a','b'] but 'c' still off (entry persists). + map = applyMcpToolToggle(map, 'srv', ['a', 'b', 'c'], 'b', true); + expect(map).toEqual({ srv: ['a', 'b'] }); + expect(isMcpToolEnabled(map.srv, 'c')).toBe(false); + }); +}); diff --git a/packages/coc/test/spa/react/repos/ChatHeader.test.tsx b/packages/coc/test/spa/react/repos/ChatHeader.test.tsx index d76133a7f..4f36d6eff 100644 --- a/packages/coc/test/spa/react/repos/ChatHeader.test.tsx +++ b/packages/coc/test/spa/react/repos/ChatHeader.test.tsx @@ -66,13 +66,14 @@ vi.mock('../../../../src/server/spa/client/react/ui/BottomSheet', () => ({ })); vi.mock('../../../../src/server/spa/client/react/features/chat/conversation/ConversationMetadataPopover', () => ({ - ConversationMetadataPopover: ({ resumeSessionId, onLaunchInteractiveResume, onStartFreshSameContext, startingFreshSameContext }: any) => ( + ConversationMetadataPopover: ({ resumeSessionId, onLaunchInteractiveResume, onStartFreshSameContext, startingFreshSameContext, extraRows }: any) => ( r.label).join('|')} > i {onStartFreshSameContext && ( @@ -666,6 +667,36 @@ describe('ChatHeader', () => { }); }); + describe('metadata extra rows pass-through', () => { + const EXTRA_ROWS = [ + { label: 'Repository', value: 'owner/repo' }, + { label: 'Branch', value: 'main' }, + ]; + + it('forwards metadataExtraRows to the inline popover in wide tier', () => { + setTier('wide'); + render(); + const popover = screen.getByTestId('metadata-popover'); + expect(popover.getAttribute('data-extra-row-labels')).toBe('Repository|Branch'); + }); + + it('keeps the metadata item (which carries extra rows) in the overflow at medium tier', () => { + setTier('medium'); + render(); + // At medium tier the metadata popover moves into the overflow menu; its + // render closure forwards metadataExtraRows. The mock lists item keys. + const menu = screen.getByTestId('overflow-menu'); + expect(menu.getAttribute('data-keys')?.split(',')).toContain('metadata'); + }); + + it('passes no extra rows to the popover when metadataExtraRows is omitted (chat default unchanged)', () => { + setTier('wide'); + render(); + const popover = screen.getByTestId('metadata-popover'); + expect(popover.getAttribute('data-extra-row-labels')).toBe(''); + }); + }); + describe('provider badge', () => { it('shows provider badge when task.metadata.provider is "codex"', () => { render( vi.fn()); +const updateMcpConfig = vi.hoisted(() => vi.fn()); +const getMcpServerDetail = vi.hoisted(() => vi.fn()); + +vi.mock('../../../../src/server/spa/client/react/api/cocClient', () => ({ + getSpaCocClient: () => ({ + workspaces: { + discoverMcpTools: (...args: unknown[]) => discoverMcpTools(...args), + updateMcpConfig: (...args: unknown[]) => updateMcpConfig(...args), + getMcpServerDetail: (...args: unknown[]) => getMcpServerDetail(...args), + }, + }), + getSpaCocClientErrorMessage: (_e: unknown, fallback: string) => fallback, +})); + +const servers: McpServerEntry[] = [ + { name: 'github-mcp', type: 'stdio' }, + { name: 'search-mcp', type: 'sse' }, +]; + +function renderPanel(overrides: Partial[0]> = {}) { + return render( + true} + enabledMcpServers={null} + onToggle={vi.fn()} + {...overrides} + /> + ); +} + +beforeEach(() => { + discoverMcpTools.mockResolvedValue({ + servers: { + 'github-mcp': { + status: 'ok', + tools: [ + { name: 'create_issue', description: 'Create a new issue', inputSchema: { type: 'object', properties: { title: { type: 'string' } } } }, + { name: 'list_issues', description: 'List repository issues' }, + ], + }, + 'search-mcp': { status: 'error', tools: [], error: 'ECONNREFUSED' }, + }, + }); + updateMcpConfig.mockResolvedValue({ workspace: {} }); + getMcpServerDetail.mockRejectedValue(new Error('no detail')); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +async function openToolsTab(serverName: string) { + const user = userEvent.setup(); + // Expand the server row. + await user.click(screen.getByRole('button', { name: new RegExp(`Expand ${serverName}`) })); + // Switch to the Tools inspector tab. + await user.click(screen.getByRole('button', { name: 'Tools' })); + return user; +} + +describe('McpServersPanel — Tools tab discovery', () => { + it('eagerly discovers tools and populates the per-row count', async () => { + renderPanel(); + await waitFor(() => { + expect(screen.getByTestId('mcp-tools-count-github-mcp').textContent).toBe('2'); + }); + // Unreachable server shows an error marker, not a number. + expect(screen.getByTestId('mcp-tools-count-search-mcp').textContent).toBe('!'); + expect(discoverMcpTools).toHaveBeenCalledWith('ws-1', undefined); + }); + + it('shows the discovered tools with name + description in the Tools tab', async () => { + renderPanel(); + await waitFor(() => expect(screen.getByTestId('mcp-tools-count-github-mcp').textContent).toBe('2')); + await openToolsTab('github-mcp'); + + const list = await screen.findByTestId('mcp-tool-list'); + expect(within(list).getByText('create_issue')).toBeTruthy(); + expect(within(list).getByText('Create a new issue')).toBeTruthy(); + expect(within(list).getByText('list_issues')).toBeTruthy(); + }); + + it('expands the collapsible input schema', async () => { + renderPanel(); + await waitFor(() => expect(screen.getByTestId('mcp-tools-count-github-mcp').textContent).toBe('2')); + const user = await openToolsTab('github-mcp'); + + expect(screen.queryByText(/"properties"/)).toBeNull(); + await user.click(screen.getAllByText('Show input schema')[0]); + expect(screen.getByText(/"properties"/)).toBeTruthy(); + }); + + it('filters the tool list with the search box', async () => { + renderPanel(); + await waitFor(() => expect(screen.getByTestId('mcp-tools-count-github-mcp').textContent).toBe('2')); + const user = await openToolsTab('github-mcp'); + + await user.type(screen.getByTestId('mcp-tools-search'), 'create'); + const list = screen.getByTestId('mcp-tool-list'); + expect(within(list).queryByText('create_issue')).toBeTruthy(); + expect(within(list).queryByText('list_issues')).toBeNull(); + }); + + it('shows a per-server error state for an unreachable server', async () => { + renderPanel(); + await waitFor(() => expect(screen.getByTestId('mcp-tools-count-search-mcp').textContent).toBe('!')); + await openToolsTab('search-mcp'); + expect(await screen.findByTestId('mcp-tools-error')).toBeTruthy(); + expect(screen.getByText(/ECONNREFUSED/)).toBeTruthy(); + }); +}); + +describe('McpServersPanel — Tools tab persistence (allow-list)', () => { + it('persists a toggle-off as the complement of discovered tools', async () => { + renderPanel(); + await waitFor(() => expect(screen.getByTestId('mcp-tools-count-github-mcp').textContent).toBe('2')); + const user = await openToolsTab('github-mcp'); + + await user.click(await screen.findByTestId('mcp-tool-toggle-create_issue')); + await waitFor(() => expect(updateMcpConfig).toHaveBeenCalled()); + expect(updateMcpConfig).toHaveBeenCalledWith('ws-1', { + enabledMcpServers: null, + enabledMcpTools: { 'github-mcp': ['list_issues'] }, + }); + }); + + it('Disable all writes an empty allow-list for the server', async () => { + renderPanel(); + await waitFor(() => expect(screen.getByTestId('mcp-tools-count-github-mcp').textContent).toBe('2')); + const user = await openToolsTab('github-mcp'); + + await user.click(await screen.findByTestId('mcp-tools-disable-all')); + await waitFor(() => expect(updateMcpConfig).toHaveBeenCalled()); + expect(updateMcpConfig).toHaveBeenCalledWith('ws-1', { + enabledMcpServers: null, + enabledMcpTools: { 'github-mcp': [] }, + }); + }); + + it('Enable all clears the server entry (no entry = all on)', async () => { + renderPanel({ enabledMcpTools: { 'github-mcp': ['list_issues'] } }); + await waitFor(() => expect(screen.getByTestId('mcp-tools-count-github-mcp').textContent).toBe('1/2')); + const user = await openToolsTab('github-mcp'); + + await user.click(await screen.findByTestId('mcp-tools-enable-all')); + await waitFor(() => expect(updateMcpConfig).toHaveBeenCalled()); + expect(updateMcpConfig).toHaveBeenCalledWith('ws-1', { + enabledMcpServers: null, + enabledMcpTools: null, + }); + }); + + it('reflects a pre-existing allow-list in the tool toggles', async () => { + renderPanel({ enabledMcpTools: { 'github-mcp': ['list_issues'] } }); + await waitFor(() => expect(screen.getByTestId('mcp-tools-count-github-mcp').textContent).toBe('1/2')); + await openToolsTab('github-mcp'); + + const createToggle = await screen.findByTestId('mcp-tool-toggle-create_issue') as HTMLInputElement; + const listToggle = screen.getByTestId('mcp-tool-toggle-list_issues') as HTMLInputElement; + expect(createToggle.checked).toBe(false); + expect(listToggle.checked).toBe(true); + }); +});