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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/coc-client/src/contracts/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]> | 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<server, toolNames[]>` to replace it.
*/
enabledMcpTools?: Record<string, string[]> | 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<string, McpServerToolsResult>;
}

export type WorkspaceInstructionMode = 'base' | 'ask' | 'autopilot';
Expand Down
16 changes: 15 additions & 1 deletion packages/coc-client/src/domains/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type {
WorkspaceInstructionResponse,
WorkspaceInstructionsResponse,
WorkspaceMcpConfigResponse,
WorkspaceMcpToolsResponse,
WorkspaceSummaryOptions,
WorkspaceSummaryResponse,
WorkspacesResponse,
Expand Down Expand Up @@ -144,12 +145,25 @@ export class WorkspacesClient {
}

updateMcpConfig(workspaceId: string, request: UpdateWorkspaceMcpConfigRequest): Promise<{ workspace: WorkspaceInfo }> {
const body: Record<string, unknown> = {
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<WorkspaceMcpToolsResponse> {
return this.transport.request<WorkspaceMcpToolsResponse>(
`/workspaces/${encodePathSegment(workspaceId)}/mcp-config/tools`,
{ query: options?.forceReload ? { forceReload: true } : undefined },
);
}

getMcpServerDetail(workspaceId: string, serverName: string): Promise<McpServerDetail> {
return this.transport.request<McpServerDetail>(
`/workspaces/${encodePathSegment(workspaceId)}/mcp-config/${encodePathSegment(serverName)}/detail`,
Expand Down
14 changes: 14 additions & 0 deletions packages/coc/src/server/executors/chat-base-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
// ============================================================================
Expand Down Expand Up @@ -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,
Expand All @@ -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(() => {
Expand Down
14 changes: 14 additions & 0 deletions packages/coc/src/server/executors/follow-up-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
// ============================================================================
Expand Down Expand Up @@ -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,
Expand All @@ -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) => {
Expand Down
146 changes: 146 additions & 0 deletions packages/coc/src/server/executors/mcp-tool-enforcement.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>;

/**
* 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<string, MCPServerConfig>,
enabledMcpServers: string[] | null | undefined,
enabledMcpTools: EnabledMcpToolsMap | null | undefined,
): Record<string, MCPServerConfig> | 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<string, MCPServerConfig> = {};

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<string, MCPServerConfig> | 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<Record<string, MCPServerConfig> | 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;
}
}
22 changes: 21 additions & 1 deletion packages/coc/src/server/routes/api-workspace-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 });
},
});

Expand Down
Loading
Loading