From 8d46f245987347774bae3e94300d8caea761038a Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:25:17 +0800 Subject: [PATCH 1/8] Scope prompt-only MCP grants by MCP --- web/__tests__/mcp-execution-design.test.ts | 82 ++++++++++++++++++++++ web/worker/mcp-execution-design.ts | 49 +++++++++++-- 2 files changed, 125 insertions(+), 6 deletions(-) diff --git a/web/__tests__/mcp-execution-design.test.ts b/web/__tests__/mcp-execution-design.test.ts index 0e9ffe5..6122f62 100644 --- a/web/__tests__/mcp-execution-design.test.ts +++ b/web/__tests__/mcp-execution-design.test.ts @@ -423,6 +423,44 @@ describe('deriveMcpGrantDecisions', () => { }) }) + it('blocks empty grant decisions when only unrelated MCP subtasks exist', () => { + const { design } = parseMcpExecutionDesign([ + '```mcp_execution_design_json', + '{"schemaVersion":1,"requirements":[{"mcpId":"github","requirement":"required","assignment":{"type":"agent","targetAgents":["backend"]},"agentPermissions":{},"fallback":{"action":"ask_user","message":"Use GitHub issue context."}}],"promptOverlays":{},"mcpAwareSubtasks":[{"id":"inspect-files","agent":"backend","mcpCapabilities":["filesystem.project.read"],"inputs":[],"outputs":[],"verification":[],"dependsOn":[],"stoppingCondition":"Project context is available.","fallback":"Continue from prompt."}]}', + '```', + ].join('\n')) + + const result = deriveMcpGrantDecisions(design, overview([healthyGithub, healthyFilesystem])) + + expect(result.summary).toEqual({ proposed: 0, warning: 0, blocked: 1 }) + expect(result.decisions[0]).toMatchObject({ + agent: 'backend', + mcpId: 'github', + status: 'blocked', + }) + }) + + it('keeps prompt-only missing MCP grant decisions warning-only', () => { + const { design } = parseMcpExecutionDesign([ + '```mcp_execution_design_json', + '{"schemaVersion":1,"requirements":[{"mcpId":"github","requirement":"required","assignment":{"type":"agent","targetAgents":["backend"]},"agentPermissions":{},"fallback":{"action":"ask_user","message":"Use GitHub issue context from the prompt."}}],"promptOverlays":{"backend":"Use GitHub issue context if available, otherwise continue from the prompt."},"mcpAwareSubtasks":[]}', + '```', + ].join('\n')) + + const result = deriveMcpGrantDecisions(design, overview([])) + + expect(result.summary).toEqual({ proposed: 0, warning: 1, blocked: 0 }) + expect(result.decisions[0]).toMatchObject({ + agent: 'backend', + health: { + installState: 'unknown', + status: 'unknown', + }, + mcpId: 'github', + status: 'warning', + }) + }) + it('blocks grant decisions for capabilities outside the safe beta allowlist', () => { const { design } = parseMcpExecutionDesign([ '```mcp_execution_design_json', @@ -479,6 +517,50 @@ describe('deriveMcpGrantDecisions', () => { expect(result.warnings.join('\n')).toMatch(/planning-only prompt context/) }) + it('blocks empty work-package grants when prompt context belongs to another MCP', () => { + const result = evaluateWorkPackageMcpBroker({ + mcpOverview: overview([healthyGithub, healthyFilesystem]), + mcpRequirements: [{ + mcpId: 'github', + requirement: 'required', + permissions: [], + fallback: { action: 'ask_user', message: 'Use GitHub issue context.' }, + }], + metadata: { + promptOverlay: 'Use the project context if available.', + mcpAwareSubtasks: [{ + id: 'inspect-repository', + mcpCapabilities: ['filesystem.project.read'], + }], + }, + title: 'Backend work package', + }) + + expect(result.status).toBe('blocked') + expect(result.blocked.join('\n')).toMatch(/no approved capabilities/) + }) + + it('keeps same-MCP prompt-only work packages warning-only when the MCP is unavailable', () => { + const result = evaluateWorkPackageMcpBroker({ + mcpOverview: overview([]), + mcpRequirements: [{ + mcpId: 'github', + requirement: 'required', + permissions: [], + fallback: { action: 'ask_user', message: 'Use GitHub issue context from the prompt.' }, + }], + metadata: { + promptOverlay: 'Use GitHub issue context if available, otherwise continue from the prompt.', + }, + title: 'Backend work package', + }) + + expect(result.status).toBe('warnings') + expect(result.blocked).toEqual([]) + expect(result.warnings.join('\n')).toMatch(/not configured/) + expect(result.warnings.join('\n')).toMatch(/planning-only prompt context/) + }) + it('blocks work-package optional unavailable MCP access unless fallback is non-blocking', () => { const result = evaluateWorkPackageMcpBroker({ assignedRole: 'reviewer', diff --git a/web/worker/mcp-execution-design.ts b/web/worker/mcp-execution-design.ts index 5b8df39..99d73be 100644 --- a/web/worker/mcp-execution-design.ts +++ b/web/worker/mcp-execution-design.ts @@ -457,6 +457,31 @@ function metadataMcpAwareSubtasks(metadata: unknown): Record[] return objectArrayFrom(metadata.mcpAwareSubtasks) } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function promptTextMentionsMcp(value: unknown, mcpId: string): boolean { + const text = cleanText(value, 2_000) + if (text === '') return false + return new RegExp(`\\b${escapeRegExp(mcpId)}\\b`, 'i').test(text) +} + +function capabilityBelongsToMcp(capability: string, mcpId: string): boolean { + return capabilityMcpId(capability) === mcpId +} + +function subtaskHasMcpContext(subtask: Record, mcpId: string): boolean { + return cleanTextArray(subtask.mcpCapabilities, 40, 120) + .some((capability) => capabilityBelongsToMcp(capability, mcpId)) +} + +function metadataHasRunScopedMcpInstructionsForMcp(metadata: unknown, mcpId: string): boolean { + if (!isRecord(metadata)) return false + return promptTextMentionsMcp(metadata.promptOverlay, mcpId) || + metadataMcpAwareSubtasks(metadata).some((subtask) => subtaskHasMcpContext(subtask, mcpId)) +} + function capabilityArray(entry: Record): { capabilities: string[] present: boolean @@ -606,9 +631,10 @@ export function evaluateWorkPackageMcpBroker(input: { warnings.push(`MCP '${mcpId}' grant is warning-only.`) } + const hasPromptOnlyContextForMcp = metadataHasRunScopedMcpInstructionsForMcp(input.metadata, mcpId) if (requirement === 'required' && (!capabilitiesPresent || capabilities.length === 0)) { const message = `MCP '${mcpId}' has no approved capabilities for required access.` - if (hasRunScopedMcpInstructions) warnings.push(message) + if (hasPromptOnlyContextForMcp) warnings.push(message) else blocked.push(message) } @@ -632,7 +658,11 @@ export function evaluateWorkPackageMcpBroker(input: { const status = healthFor(input.mcpOverview, mcpId) if (!healthyStatus(status)) { const message = statusMessage(mcpId, status) - shouldBlock(message) + if (requirement === 'required' && capabilities.length === 0 && hasPromptOnlyContextForMcp) { + warnings.push(message) + } else { + shouldBlock(message) + } } } @@ -726,9 +756,16 @@ function decisionStatus( return 'blocked' } -function designHasRunScopedMcpInstructionsForAgent(design: McpExecutionDesign, agent: string): boolean { - return typeof design.promptOverlays[agent] === 'string' || - design.mcpAwareSubtasks.some((subtask) => subtask.agent === agent) +function designHasRunScopedMcpInstructionsForRequirement( + design: McpExecutionDesign, + agent: string, + mcpId: string, +): boolean { + return promptTextMentionsMcp(design.promptOverlays[agent], mcpId) || + design.mcpAwareSubtasks.some((subtask) => + subtask.agent === agent && + subtask.mcpCapabilities.some((capability) => capabilityBelongsToMcp(capability, mcpId)), + ) } export function deriveMcpGrantDecisions( @@ -761,7 +798,7 @@ export function deriveMcpGrantDecisions( requirement, status, capabilities, - designHasRunScopedMcpInstructionsForAgent(design, agent), + designHasRunScopedMcpInstructionsForRequirement(design, agent, requirement.mcpId), ) summary[grantStatus] += 1 decisions.push({ From 3238a419bcd733ca800d18fc4ab041554be90b2e Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:04:16 +0800 Subject: [PATCH 2/8] Align prompt-only MCP validation --- web/__tests__/mcp-execution-design.test.ts | 13 +++++---- web/worker/mcp-execution-design.ts | 34 ++++++++++++---------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/web/__tests__/mcp-execution-design.test.ts b/web/__tests__/mcp-execution-design.test.ts index 6122f62..d07e9db 100644 --- a/web/__tests__/mcp-execution-design.test.ts +++ b/web/__tests__/mcp-execution-design.test.ts @@ -408,7 +408,7 @@ describe('deriveMcpGrantDecisions', () => { it('warns for required healthy MCP access without capabilities when prompt-only context exists', () => { const { design } = parseMcpExecutionDesign([ '```mcp_execution_design_json', - '{"schemaVersion":1,"requirements":[{"mcpId":"github","requirement":"required","assignment":{"type":"agent","targetAgents":["backend"]},"agentPermissions":{},"fallback":{"action":"ask_user","message":"Use prompt context only."}}],"promptOverlays":{"backend":"Use GitHub issue context if available, otherwise continue from the prompt."},"mcpAwareSubtasks":[]}', + '{"schemaVersion":1,"requirements":[{"mcpId":"github","requirement":"required","assignment":{"type":"agent","targetAgents":["backend"]},"agentPermissions":{},"fallback":{"action":"ask_user","message":"Use prompt context only."}}],"promptOverlays":{"backend":"Use issue context if available, otherwise continue from the prompt."},"mcpAwareSubtasks":[]}', '```', ].join('\n')) @@ -443,12 +443,16 @@ describe('deriveMcpGrantDecisions', () => { it('keeps prompt-only missing MCP grant decisions warning-only', () => { const { design } = parseMcpExecutionDesign([ '```mcp_execution_design_json', - '{"schemaVersion":1,"requirements":[{"mcpId":"github","requirement":"required","assignment":{"type":"agent","targetAgents":["backend"]},"agentPermissions":{},"fallback":{"action":"ask_user","message":"Use GitHub issue context from the prompt."}}],"promptOverlays":{"backend":"Use GitHub issue context if available, otherwise continue from the prompt."},"mcpAwareSubtasks":[]}', + '{"schemaVersion":1,"requirements":[{"mcpId":"github","requirement":"required","assignment":{"type":"agent","targetAgents":["backend"]},"agentPermissions":{},"fallback":{"action":"ask_user","message":"Use issue context from the prompt."}}],"promptOverlays":{"backend":"Use issue context if available, otherwise continue from the prompt."},"mcpAwareSubtasks":[]}', '```', ].join('\n')) + const validation = validateMcpExecutionDesign(design, overview([])) const result = deriveMcpGrantDecisions(design, overview([])) + expect(validation.status).toBe('warnings') + expect(validation.blocked).toEqual([]) + expect(validation.warnings.join('\n')).toMatch(/not configured/) expect(result.summary).toEqual({ proposed: 0, warning: 1, blocked: 0 }) expect(result.decisions[0]).toMatchObject({ agent: 'backend', @@ -527,7 +531,6 @@ describe('deriveMcpGrantDecisions', () => { fallback: { action: 'ask_user', message: 'Use GitHub issue context.' }, }], metadata: { - promptOverlay: 'Use the project context if available.', mcpAwareSubtasks: [{ id: 'inspect-repository', mcpCapabilities: ['filesystem.project.read'], @@ -547,10 +550,10 @@ describe('deriveMcpGrantDecisions', () => { mcpId: 'github', requirement: 'required', permissions: [], - fallback: { action: 'ask_user', message: 'Use GitHub issue context from the prompt.' }, + fallback: { action: 'ask_user', message: 'Use issue context from the prompt.' }, }], metadata: { - promptOverlay: 'Use GitHub issue context if available, otherwise continue from the prompt.', + promptOverlay: 'Use issue context if available, otherwise continue from the prompt.', }, title: 'Backend work package', }) diff --git a/web/worker/mcp-execution-design.ts b/web/worker/mcp-execution-design.ts index 99d73be..be62857 100644 --- a/web/worker/mcp-execution-design.ts +++ b/web/worker/mcp-execution-design.ts @@ -352,17 +352,20 @@ export function validateMcpExecutionDesign( } } + const hasPromptOnlyContext = designHasRunScopedMcpInstructionsForRequirement(design, requirement) const status = healthFor(mcpOverview, requirement.mcpId) if (!status) { const message = statusMessage(requirement.mcpId, null) - if (!canProceedWithoutMcp(requirement)) blocked.push(message) + if (hasPromptOnlyContext) warnings.push(message) + else if (!canProceedWithoutMcp(requirement)) blocked.push(message) else warnings.push(message) continue } if (!healthyStatus(status)) { const message = statusMessage(requirement.mcpId, status) - if (!canProceedWithoutMcp(requirement)) blocked.push(message) + if (hasPromptOnlyContext) warnings.push(message) + else if (!canProceedWithoutMcp(requirement)) blocked.push(message) else warnings.push(message) } } @@ -457,16 +460,6 @@ function metadataMcpAwareSubtasks(metadata: unknown): Record[] return objectArrayFrom(metadata.mcpAwareSubtasks) } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -function promptTextMentionsMcp(value: unknown, mcpId: string): boolean { - const text = cleanText(value, 2_000) - if (text === '') return false - return new RegExp(`\\b${escapeRegExp(mcpId)}\\b`, 'i').test(text) -} - function capabilityBelongsToMcp(capability: string, mcpId: string): boolean { return capabilityMcpId(capability) === mcpId } @@ -478,7 +471,7 @@ function subtaskHasMcpContext(subtask: Record, mcpId: string): function metadataHasRunScopedMcpInstructionsForMcp(metadata: unknown, mcpId: string): boolean { if (!isRecord(metadata)) return false - return promptTextMentionsMcp(metadata.promptOverlay, mcpId) || + return cleanText(metadata.promptOverlay, 2_000) !== '' || metadataMcpAwareSubtasks(metadata).some((subtask) => subtaskHasMcpContext(subtask, mcpId)) } @@ -756,18 +749,27 @@ function decisionStatus( return 'blocked' } -function designHasRunScopedMcpInstructionsForRequirement( +function designHasRunScopedMcpInstructionsForAgentRequirement( design: McpExecutionDesign, agent: string, mcpId: string, ): boolean { - return promptTextMentionsMcp(design.promptOverlays[agent], mcpId) || + return typeof design.promptOverlays[agent] === 'string' || design.mcpAwareSubtasks.some((subtask) => subtask.agent === agent && subtask.mcpCapabilities.some((capability) => capabilityBelongsToMcp(capability, mcpId)), ) } +function designHasRunScopedMcpInstructionsForRequirement( + design: McpExecutionDesign, + requirement: McpExecutionRequirement, +): boolean { + return agentsForRequirement(requirement).some((agent) => + designHasRunScopedMcpInstructionsForAgentRequirement(design, agent, requirement.mcpId), + ) +} + export function deriveMcpGrantDecisions( design: McpExecutionDesign | null, mcpOverview: ProjectMcpOverview, @@ -798,7 +800,7 @@ export function deriveMcpGrantDecisions( requirement, status, capabilities, - designHasRunScopedMcpInstructionsForRequirement(design, agent, requirement.mcpId), + designHasRunScopedMcpInstructionsForAgentRequirement(design, agent, requirement.mcpId), ) summary[grantStatus] += 1 decisions.push({ From 72571fe8b850f4cad3bce5bd8cad5c6af55198a0 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:29:04 +0800 Subject: [PATCH 3/8] Align MCP validation with grant decisions --- web/__tests__/mcp-execution-design.test.ts | 34 ++++++++++++++++++++++ web/worker/mcp-execution-design.ts | 25 +++++++--------- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/web/__tests__/mcp-execution-design.test.ts b/web/__tests__/mcp-execution-design.test.ts index d07e9db..4f27c7e 100644 --- a/web/__tests__/mcp-execution-design.test.ts +++ b/web/__tests__/mcp-execution-design.test.ts @@ -272,6 +272,40 @@ describe('validateMcpExecutionDesign', () => { expect(result.blocked.join('\n')).toMatch(/not configured/) }) + it('blocks unavailable required MCPs with live capabilities even when prompt overlay exists', () => { + const { design } = parseMcpExecutionDesign([ + '```mcp_execution_design_json', + '{"schemaVersion":1,"requirements":[{"mcpId":"github","requirement":"required","assignment":{"type":"agent","targetAgents":["backend"]},"agentPermissions":{"backend":["github.issues.read"]},"fallback":{"action":"ask_user","message":"Connect GitHub."}}],"promptOverlays":{"backend":"Use issue context if available."},"mcpAwareSubtasks":[]}', + '```', + ].join('\n')) + + const validation = validateMcpExecutionDesign(design, overview([])) + const decisions = deriveMcpGrantDecisions(design, overview([])) + + expect(validation.status).toBe('blocked') + expect(validation.blocked.join('\n')).toMatch(/not configured/) + expect(decisions.summary).toEqual({ proposed: 0, warning: 0, blocked: 1 }) + }) + + it('blocks unavailable prompt-only requirements when any assigned agent lacks prompt context', () => { + const { design } = parseMcpExecutionDesign([ + '```mcp_execution_design_json', + '{"schemaVersion":1,"requirements":[{"mcpId":"github","requirement":"required","assignment":{"type":"multiple_agents","targetAgents":["backend","qa"]},"agentPermissions":{},"fallback":{"action":"ask_user","message":"Use prompt context."}}],"promptOverlays":{"backend":"Use issue context if available."},"mcpAwareSubtasks":[]}', + '```', + ].join('\n')) + + const validation = validateMcpExecutionDesign(design, overview([])) + const decisions = deriveMcpGrantDecisions(design, overview([])) + + expect(validation.status).toBe('blocked') + expect(validation.blocked.join('\n')).toMatch(/not configured/) + expect(decisions.summary).toEqual({ proposed: 0, warning: 1, blocked: 1 }) + expect(decisions.decisions.map((decision) => [decision.agent, decision.status])).toEqual([ + ['backend', 'warning'], + ['qa', 'blocked'], + ]) + }) + it('warns for optional known MCPs that are absent from the project overview', () => { const { design } = parseMcpExecutionDesign([ '```mcp_execution_design_json', diff --git a/web/worker/mcp-execution-design.ts b/web/worker/mcp-execution-design.ts index be62857..d24b578 100644 --- a/web/worker/mcp-execution-design.ts +++ b/web/worker/mcp-execution-design.ts @@ -352,20 +352,26 @@ export function validateMcpExecutionDesign( } } - const hasPromptOnlyContext = designHasRunScopedMcpInstructionsForRequirement(design, requirement) const status = healthFor(mcpOverview, requirement.mcpId) + const agentDecisionStatuses = agentsForRequirement(requirement).map((agent) => + decisionStatus( + requirement, + status, + requirementCapabilitiesForAgent(requirement, agent), + designHasRunScopedMcpInstructionsForAgentRequirement(design, agent, requirement.mcpId), + ), + ) + const hasBlockedAgentDecision = agentDecisionStatuses.includes('blocked') if (!status) { const message = statusMessage(requirement.mcpId, null) - if (hasPromptOnlyContext) warnings.push(message) - else if (!canProceedWithoutMcp(requirement)) blocked.push(message) + if (hasBlockedAgentDecision) blocked.push(message) else warnings.push(message) continue } if (!healthyStatus(status)) { const message = statusMessage(requirement.mcpId, status) - if (hasPromptOnlyContext) warnings.push(message) - else if (!canProceedWithoutMcp(requirement)) blocked.push(message) + if (hasBlockedAgentDecision) blocked.push(message) else warnings.push(message) } } @@ -761,15 +767,6 @@ function designHasRunScopedMcpInstructionsForAgentRequirement( ) } -function designHasRunScopedMcpInstructionsForRequirement( - design: McpExecutionDesign, - requirement: McpExecutionRequirement, -): boolean { - return agentsForRequirement(requirement).some((agent) => - designHasRunScopedMcpInstructionsForAgentRequirement(design, agent, requirement.mcpId), - ) -} - export function deriveMcpGrantDecisions( design: McpExecutionDesign | null, mcpOverview: ProjectMcpOverview, From 5723f8ac50ddc08021a3503c27d9ff5c291bcee3 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:26:58 +0800 Subject: [PATCH 4/8] Harden MCP prompt-only scope edge cases --- web/__tests__/mcp-execution-design.test.ts | 45 ++++++++++++++++++++++ web/worker/mcp-execution-design.ts | 23 +++++++++-- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/web/__tests__/mcp-execution-design.test.ts b/web/__tests__/mcp-execution-design.test.ts index 4f27c7e..607b0e8 100644 --- a/web/__tests__/mcp-execution-design.test.ts +++ b/web/__tests__/mcp-execution-design.test.ts @@ -272,6 +272,22 @@ describe('validateMcpExecutionDesign', () => { expect(result.blocked.join('\n')).toMatch(/not configured/) }) + it('blocks required MCP requirements without any effective target agent', () => { + const { design } = parseMcpExecutionDesign([ + '```mcp_execution_design_json', + '{"schemaVersion":1,"requirements":[{"mcpId":"github","requirement":"required","assignment":{"type":"agent","targetAgents":[]},"agentPermissions":{},"fallback":{"action":"block","message":"GitHub required."}}],"promptOverlays":{},"mcpAwareSubtasks":[]}', + '```', + ].join('\n')) + + const validation = validateMcpExecutionDesign(design, overview([])) + const decisions = deriveMcpGrantDecisions(design, overview([])) + + expect(validation.status).toBe('blocked') + expect(validation.blocked.join('\n')).toMatch(/does not target any valid agent/) + expect(decisions.summary).toEqual({ proposed: 0, warning: 0, blocked: 0 }) + expect(decisions.decisions).toEqual([]) + }) + it('blocks unavailable required MCPs with live capabilities even when prompt overlay exists', () => { const { design } = parseMcpExecutionDesign([ '```mcp_execution_design_json', @@ -577,6 +593,35 @@ describe('deriveMcpGrantDecisions', () => { expect(result.blocked.join('\n')).toMatch(/no approved capabilities/) }) + it('blocks unrelated required MCPs when a package prompt overlay is ambiguous across MCPs', () => { + const result = evaluateWorkPackageMcpBroker({ + mcpOverview: overview([healthyFilesystem]), + mcpRequirements: [{ + mcpId: 'github', + requirement: 'required', + permissions: [], + fallback: { action: 'ask_user', message: 'Use issue context if available.' }, + }, { + mcpId: 'filesystem', + requirement: 'required', + permissions: [], + fallback: { action: 'ask_user', message: 'Use project context if available.' }, + }], + metadata: { + promptOverlay: 'Use project file context from the prompt.', + mcpAwareSubtasks: [{ + id: 'inspect-files', + mcpCapabilities: ['filesystem.project.read'], + }], + }, + title: 'Backend work package', + }) + + expect(result.status).toBe('blocked') + expect(result.blocked.join('\n')).toMatch(/MCP 'github' has no approved capabilities/) + expect(result.blocked.join('\n')).toMatch(/MCP 'github' is not configured/) + }) + it('keeps same-MCP prompt-only work packages warning-only when the MCP is unavailable', () => { const result = evaluateWorkPackageMcpBroker({ mcpOverview: overview([]), diff --git a/web/worker/mcp-execution-design.ts b/web/worker/mcp-execution-design.ts index d24b578..66ce1b0 100644 --- a/web/worker/mcp-execution-design.ts +++ b/web/worker/mcp-execution-design.ts @@ -352,8 +352,14 @@ export function validateMcpExecutionDesign( } } + const agents = agentsForRequirement(requirement) + if (agents.length === 0) { + blocked.push(`MCP '${requirement.mcpId}' requirement does not target any valid agent.`) + continue + } + const status = healthFor(mcpOverview, requirement.mcpId) - const agentDecisionStatuses = agentsForRequirement(requirement).map((agent) => + const agentDecisionStatuses = agents.map((agent) => decisionStatus( requirement, status, @@ -475,9 +481,13 @@ function subtaskHasMcpContext(subtask: Record, mcpId: string): .some((capability) => capabilityBelongsToMcp(capability, mcpId)) } -function metadataHasRunScopedMcpInstructionsForMcp(metadata: unknown, mcpId: string): boolean { +function metadataHasRunScopedMcpInstructionsForMcp( + metadata: unknown, + mcpId: string, + packageMcpIds: Set, +): boolean { if (!isRecord(metadata)) return false - return cleanText(metadata.promptOverlay, 2_000) !== '' || + return (packageMcpIds.size === 1 && cleanText(metadata.promptOverlay, 2_000) !== '') || metadataMcpAwareSubtasks(metadata).some((subtask) => subtaskHasMcpContext(subtask, mcpId)) } @@ -593,6 +603,11 @@ export function evaluateWorkPackageMcpBroker(input: { const blocked: string[] = [] const warnings: string[] = [] const entries = brokerEntries(input) + const packageMcpIds = new Set( + entries + .map((entry) => cleanText(entry.mcpId, 80)) + .filter((mcpId) => mcpId !== ''), + ) const hasRunScopedMcpInstructions = metadataHasRunScopedMcpInstructions(input.metadata) const approvedCapabilities = new Set() // A capability prohibited by any grant entry is prohibited for the whole @@ -630,7 +645,7 @@ export function evaluateWorkPackageMcpBroker(input: { warnings.push(`MCP '${mcpId}' grant is warning-only.`) } - const hasPromptOnlyContextForMcp = metadataHasRunScopedMcpInstructionsForMcp(input.metadata, mcpId) + const hasPromptOnlyContextForMcp = metadataHasRunScopedMcpInstructionsForMcp(input.metadata, mcpId, packageMcpIds) if (requirement === 'required' && (!capabilitiesPresent || capabilities.length === 0)) { const message = `MCP '${mcpId}' has no approved capabilities for required access.` if (hasPromptOnlyContextForMcp) warnings.push(message) From 49e2de00722c8877ed9196d46c8711e9a5d80535 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:56:42 +0800 Subject: [PATCH 5/8] Align prompt-only MCP preview scoping --- web/__tests__/mcp-execution-design.test.ts | 20 ++++++++++++++++++++ web/worker/mcp-execution-design.ts | 16 +++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/web/__tests__/mcp-execution-design.test.ts b/web/__tests__/mcp-execution-design.test.ts index 607b0e8..7a46d32 100644 --- a/web/__tests__/mcp-execution-design.test.ts +++ b/web/__tests__/mcp-execution-design.test.ts @@ -322,6 +322,26 @@ describe('validateMcpExecutionDesign', () => { ]) }) + it('blocks ambiguous prompt-only overlays across multiple MCP requirements', () => { + const { design } = parseMcpExecutionDesign([ + '```mcp_execution_design_json', + '{"schemaVersion":1,"requirements":[{"mcpId":"github","requirement":"required","assignment":{"type":"agent","targetAgents":["backend"]},"agentPermissions":{},"fallback":{"action":"ask_user","message":"Use issue context from the prompt."}},{"mcpId":"filesystem","requirement":"required","assignment":{"type":"agent","targetAgents":["backend"]},"agentPermissions":{},"fallback":{"action":"ask_user","message":"Use project context from the prompt."}}],"promptOverlays":{"backend":"Use issue and project context from the prompt."},"mcpAwareSubtasks":[]}', + '```', + ].join('\n')) + + const validation = validateMcpExecutionDesign(design, overview([])) + const decisions = deriveMcpGrantDecisions(design, overview([])) + + expect(validation.status).toBe('blocked') + expect(validation.blocked.join('\n')).toMatch(/github.*not configured/) + expect(validation.blocked.join('\n')).toMatch(/filesystem.*not configured/) + expect(decisions.summary).toEqual({ proposed: 0, warning: 0, blocked: 2 }) + expect(decisions.decisions.map((decision) => [decision.mcpId, decision.status])).toEqual([ + ['github', 'blocked'], + ['filesystem', 'blocked'], + ]) + }) + it('warns for optional known MCPs that are absent from the project overview', () => { const { design } = parseMcpExecutionDesign([ '```mcp_execution_design_json', diff --git a/web/worker/mcp-execution-design.ts b/web/worker/mcp-execution-design.ts index 66ce1b0..d3f1f28 100644 --- a/web/worker/mcp-execution-design.ts +++ b/web/worker/mcp-execution-design.ts @@ -775,11 +775,17 @@ function designHasRunScopedMcpInstructionsForAgentRequirement( agent: string, mcpId: string, ): boolean { - return typeof design.promptOverlays[agent] === 'string' || - design.mcpAwareSubtasks.some((subtask) => - subtask.agent === agent && - subtask.mcpCapabilities.some((capability) => capabilityBelongsToMcp(capability, mcpId)), - ) + const hasSameMcpSubtask = design.mcpAwareSubtasks.some((subtask) => + subtask.agent === agent && + subtask.mcpCapabilities.some((capability) => capabilityBelongsToMcp(capability, mcpId)), + ) + if (hasSameMcpSubtask) return true + + const agentMcpIds = new Set() + for (const requirement of design.requirements) { + if (agentsForRequirement(requirement).includes(agent)) agentMcpIds.add(requirement.mcpId) + } + return typeof design.promptOverlays[agent] === 'string' && agentMcpIds.size === 1 } export function deriveMcpGrantDecisions( From 92981c0ce4a3fbb7e59bc7e7f28968feca439635 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:26:32 +0800 Subject: [PATCH 6/8] Align optional empty MCP grant previews --- web/__tests__/mcp-execution-design.test.ts | 39 ++++++++++++++++++++++ web/worker/mcp-execution-design.ts | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/web/__tests__/mcp-execution-design.test.ts b/web/__tests__/mcp-execution-design.test.ts index 7a46d32..27550b1 100644 --- a/web/__tests__/mcp-execution-design.test.ts +++ b/web/__tests__/mcp-execution-design.test.ts @@ -442,6 +442,27 @@ describe('deriveMcpGrantDecisions', () => { }) }) + it('blocks optional empty MCP access with ask_user fallback and no prompt context', () => { + const { design } = parseMcpExecutionDesign([ + '```mcp_execution_design_json', + '{"schemaVersion":1,"requirements":[{"mcpId":"github","requirement":"optional","assignment":{"type":"agent","targetAgents":["reviewer"]},"agentPermissions":{},"fallback":{"action":"ask_user","message":"Ask before proceeding without GitHub."}}],"promptOverlays":{},"mcpAwareSubtasks":[]}', + '```', + ].join('\n')) + + const validation = validateMcpExecutionDesign(design, overview([])) + const decisions = deriveMcpGrantDecisions(design, overview([])) + + expect(validation.status).toBe('blocked') + expect(validation.blocked).toEqual(["MCP 'github' is not configured for this project."]) + expect(decisions.summary).toEqual({ proposed: 0, warning: 0, blocked: 1 }) + expect(decisions.decisions[0]).toMatchObject({ + agent: 'reviewer', + capabilities: [], + status: 'blocked', + fallback: { action: 'ask_user' }, + }) + }) + it('blocks unknown MCPs even when they are optional with a non-blocking fallback', () => { const { design } = parseMcpExecutionDesign([ '```mcp_execution_design_json', @@ -681,6 +702,24 @@ describe('deriveMcpGrantDecisions', () => { expect(result.blockedReason).toMatch(/not configured/i) }) + it('blocks work-package optional empty MCP access with ask_user fallback', () => { + const result = evaluateWorkPackageMcpBroker({ + assignedRole: 'reviewer', + mcpOverview: overview([]), + mcpRequirements: [{ + mcpId: 'github', + requirement: 'optional', + permissions: [], + fallback: { action: 'ask_user' }, + }], + metadata: {}, + title: 'Reviewer package', + }) + + expect(result.status).toBe('blocked') + expect(result.blocked).toEqual(["MCP 'github' is not configured for this project."]) + }) + it('blocks optional unknown MCP ids even when fallback says continue without MCP', () => { const result = evaluateWorkPackageMcpBroker({ mcpRequirements: [{ diff --git a/web/worker/mcp-execution-design.ts b/web/worker/mcp-execution-design.ts index d3f1f28..e0f298d 100644 --- a/web/worker/mcp-execution-design.ts +++ b/web/worker/mcp-execution-design.ts @@ -750,7 +750,7 @@ function decisionStatus( ): McpGrantDecisionStatus { if (!isKnownMcpId(requirement.mcpId)) return 'blocked' if (capabilities.length === 0) { - return requirement.requirement === 'optional' || hasRunScopedMcpInstructions ? 'warning' : 'blocked' + return canProceedWithoutMcp(requirement) || hasRunScopedMcpInstructions ? 'warning' : 'blocked' } // A capability outside the safe beta allowlist (or explicitly prohibited) is // blocked at handoff by evaluateWorkPackageMcpBroker. Apply the same allowlist From c3a7579a79901becd200b428ce76ddb1c0967695 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:50:44 +0800 Subject: [PATCH 7/8] Align MCP prompt-only broker decisions --- web/__tests__/mcp-execution-design.test.ts | 90 ++++++++++++++++++++++ web/worker/mcp-execution-design.ts | 15 +++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/web/__tests__/mcp-execution-design.test.ts b/web/__tests__/mcp-execution-design.test.ts index 27550b1..088ee8e 100644 --- a/web/__tests__/mcp-execution-design.test.ts +++ b/web/__tests__/mcp-execution-design.test.ts @@ -272,6 +272,34 @@ describe('validateMcpExecutionDesign', () => { expect(result.blocked.join('\n')).toMatch(/not configured/) }) + it('blocks required healthy MCP requirements with no capabilities or prompt-only context', () => { + const { design } = parseMcpExecutionDesign([ + '```mcp_execution_design_json', + '{"schemaVersion":1,"requirements":[{"mcpId":"github","requirement":"required","assignment":{"type":"agent","targetAgents":["backend"]},"agentPermissions":{},"fallback":{"action":"ask_user","message":"Use GitHub issue context."}}],"promptOverlays":{},"mcpAwareSubtasks":[]}', + '```', + ].join('\n')) + + const validation = validateMcpExecutionDesign(design, overview([healthyGithub])) + const decisions = deriveMcpGrantDecisions(design, overview([healthyGithub])) + const broker = evaluateWorkPackageMcpBroker({ + mcpOverview: overview([healthyGithub]), + mcpRequirements: [{ + mcpId: 'github', + requirement: 'required', + permissions: [], + fallback: { action: 'ask_user' }, + }], + metadata: {}, + title: 'Backend package', + }) + + expect(validation.status).toBe('blocked') + expect(validation.blocked.join('\n')).toMatch(/no approved capabilities/) + expect(decisions.summary).toEqual({ proposed: 0, warning: 0, blocked: 1 }) + expect(broker.status).toBe('blocked') + expect(broker.blocked.join('\n')).toMatch(/no approved capabilities/) + }) + it('blocks required MCP requirements without any effective target agent', () => { const { design } = parseMcpExecutionDesign([ '```mcp_execution_design_json', @@ -463,6 +491,37 @@ describe('deriveMcpGrantDecisions', () => { }) }) + it('blocks optional empty MCP access with ask_user fallback even when prompt-only context exists', () => { + const { design } = parseMcpExecutionDesign([ + '```mcp_execution_design_json', + '{"schemaVersion":1,"requirements":[{"mcpId":"github","requirement":"optional","assignment":{"type":"agent","targetAgents":["reviewer"]},"agentPermissions":{},"fallback":{"action":"ask_user","message":"Ask before proceeding without GitHub."}}],"promptOverlays":{"reviewer":"Use issue context from the prompt."},"mcpAwareSubtasks":[]}', + '```', + ].join('\n')) + + const validation = validateMcpExecutionDesign(design, overview([])) + const decisions = deriveMcpGrantDecisions(design, overview([])) + const broker = evaluateWorkPackageMcpBroker({ + assignedRole: 'reviewer', + mcpOverview: overview([]), + mcpRequirements: [{ + mcpId: 'github', + requirement: 'optional', + permissions: [], + fallback: { action: 'ask_user' }, + }], + metadata: { + promptOverlay: 'Use issue context from the prompt.', + }, + title: 'Reviewer package', + }) + + expect(validation.status).toBe('blocked') + expect(validation.blocked).toEqual(["MCP 'github' is not configured for this project."]) + expect(decisions.summary).toEqual({ proposed: 0, warning: 0, blocked: 1 }) + expect(broker.status).toBe('blocked') + expect(broker.blocked).toEqual(["MCP 'github' is not configured for this project."]) + }) + it('blocks unknown MCPs even when they are optional with a non-blocking fallback', () => { const { design } = parseMcpExecutionDesign([ '```mcp_execution_design_json', @@ -684,6 +743,37 @@ describe('deriveMcpGrantDecisions', () => { expect(result.warnings.join('\n')).toMatch(/planning-only prompt context/) }) + it('keeps planning-only filesystem write packages warning-only when filesystem MCP is unavailable', () => { + const { design } = parseMcpExecutionDesign([ + '```mcp_execution_design_json', + '{"schemaVersion":1,"requirements":[{"mcpId":"filesystem","requirement":"required","assignment":{"type":"agent","targetAgents":["frontend"]},"agentPermissions":{"frontend":["filesystem.project.write"]},"fallback":{"action":"ask_user","message":"Use sandbox output writes."}}],"promptOverlays":{},"mcpAwareSubtasks":[]}', + '```', + ].join('\n')) + const validation = validateMcpExecutionDesign(design, overview([])) + const decisions = deriveMcpGrantDecisions(design, overview([])) + const broker = evaluateWorkPackageMcpBroker({ + mcpOverview: overview([]), + mcpRequirements: [{ + mcpId: 'filesystem', + requirement: 'required', + permissions: ['filesystem.project.write'], + fallback: { action: 'ask_user' }, + }], + metadata: {}, + title: 'Frontend package', + }) + + expect(validation.status).toBe('warnings') + expect(validation.blocked).toEqual([]) + expect(validation.warnings.join('\n')).toMatch(/filesystem\.project\.write/) + expect(validation.warnings.join('\n')).toMatch(/not configured/) + expect(decisions.summary).toEqual({ proposed: 0, warning: 1, blocked: 0 }) + expect(broker.status).toBe('warnings') + expect(broker.blocked).toEqual([]) + expect(broker.warnings.join('\n')).toMatch(/filesystem\.project\.write/) + expect(broker.warnings.join('\n')).toMatch(/not configured/) + }) + it('blocks work-package optional unavailable MCP access unless fallback is non-blocking', () => { const result = evaluateWorkPackageMcpBroker({ assignedRole: 'reviewer', diff --git a/web/worker/mcp-execution-design.ts b/web/worker/mcp-execution-design.ts index e0f298d..ae37b95 100644 --- a/web/worker/mcp-execution-design.ts +++ b/web/worker/mcp-execution-design.ts @@ -368,6 +368,10 @@ export function validateMcpExecutionDesign( ), ) const hasBlockedAgentDecision = agentDecisionStatuses.includes('blocked') + if (requirement.requirement === 'required' && healthyStatus(status) && hasBlockedAgentDecision) { + blocked.push(`MCP '${requirement.mcpId}' has no approved capabilities or prompt-only context for required access.`) + continue + } if (!status) { const message = statusMessage(requirement.mcpId, null) if (hasBlockedAgentDecision) blocked.push(message) @@ -672,7 +676,11 @@ export function evaluateWorkPackageMcpBroker(input: { const status = healthFor(input.mcpOverview, mcpId) if (!healthyStatus(status)) { const message = statusMessage(mcpId, status) - if (requirement === 'required' && capabilities.length === 0 && hasPromptOnlyContextForMcp) { + const hasOnlyPlanningOnlyCapabilities = capabilities.length > 0 && actionableCapabilityCount === 0 + if ( + requirement === 'required' && + ((capabilities.length === 0 && hasPromptOnlyContextForMcp) || hasOnlyPlanningOnlyCapabilities) + ) { warnings.push(message) } else { shouldBlock(message) @@ -750,7 +758,10 @@ function decisionStatus( ): McpGrantDecisionStatus { if (!isKnownMcpId(requirement.mcpId)) return 'blocked' if (capabilities.length === 0) { - return canProceedWithoutMcp(requirement) || hasRunScopedMcpInstructions ? 'warning' : 'blocked' + return canProceedWithoutMcp(requirement) || + (requirement.requirement === 'required' && hasRunScopedMcpInstructions) + ? 'warning' + : 'blocked' } // A capability outside the safe beta allowlist (or explicitly prohibited) is // blocked at handoff by evaluateWorkPackageMcpBroker. Apply the same allowlist From 85b85e911cb6d9b56dcdda7d903d7f808e413da5 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:13:18 +0800 Subject: [PATCH 8/8] Align optional MCP grant previews --- web/__tests__/mcp-execution-design.test.ts | 58 ++++++++++++++++++++++ web/worker/mcp-execution-design.ts | 5 +- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/web/__tests__/mcp-execution-design.test.ts b/web/__tests__/mcp-execution-design.test.ts index 088ee8e..9b017b7 100644 --- a/web/__tests__/mcp-execution-design.test.ts +++ b/web/__tests__/mcp-execution-design.test.ts @@ -522,6 +522,33 @@ describe('deriveMcpGrantDecisions', () => { expect(broker.blocked).toEqual(["MCP 'github' is not configured for this project."]) }) + it('warns for optional healthy empty MCP access with ask_user fallback', () => { + const { design } = parseMcpExecutionDesign([ + '```mcp_execution_design_json', + '{"schemaVersion":1,"requirements":[{"mcpId":"github","requirement":"optional","assignment":{"type":"agent","targetAgents":["reviewer"]},"agentPermissions":{},"fallback":{"action":"ask_user","message":"Ask before proceeding without GitHub."}}],"promptOverlays":{},"mcpAwareSubtasks":[]}', + '```', + ].join('\n')) + + const validation = validateMcpExecutionDesign(design, overview([healthyGithub])) + const decisions = deriveMcpGrantDecisions(design, overview([healthyGithub])) + const broker = evaluateWorkPackageMcpBroker({ + assignedRole: 'reviewer', + mcpOverview: overview([healthyGithub]), + mcpRequirements: [{ + mcpId: 'github', + requirement: 'optional', + permissions: [], + fallback: { action: 'ask_user' }, + }], + metadata: {}, + title: 'Reviewer package', + }) + + expect(validation.status).toBe('valid') + expect(decisions.summary).toEqual({ proposed: 0, warning: 1, blocked: 0 }) + expect(broker.status).toBe('allowed') + }) + it('blocks unknown MCPs even when they are optional with a non-blocking fallback', () => { const { design } = parseMcpExecutionDesign([ '```mcp_execution_design_json', @@ -774,6 +801,37 @@ describe('deriveMcpGrantDecisions', () => { expect(broker.warnings.join('\n')).toMatch(/not configured/) }) + it('keeps optional planning-only filesystem write packages warning-only when filesystem MCP is unavailable', () => { + const { design } = parseMcpExecutionDesign([ + '```mcp_execution_design_json', + '{"schemaVersion":1,"requirements":[{"mcpId":"filesystem","requirement":"optional","assignment":{"type":"agent","targetAgents":["frontend"]},"agentPermissions":{"frontend":["filesystem.project.write"]},"fallback":{"action":"ask_user","message":"Use sandbox output writes."}}],"promptOverlays":{},"mcpAwareSubtasks":[]}', + '```', + ].join('\n')) + const validation = validateMcpExecutionDesign(design, overview([])) + const decisions = deriveMcpGrantDecisions(design, overview([])) + const broker = evaluateWorkPackageMcpBroker({ + mcpOverview: overview([]), + mcpRequirements: [{ + mcpId: 'filesystem', + requirement: 'optional', + permissions: ['filesystem.project.write'], + fallback: { action: 'ask_user' }, + }], + metadata: {}, + title: 'Frontend package', + }) + + expect(validation.status).toBe('warnings') + expect(validation.blocked).toEqual([]) + expect(validation.warnings.join('\n')).toMatch(/filesystem\.project\.write/) + expect(validation.warnings.join('\n')).toMatch(/not configured/) + expect(decisions.summary).toEqual({ proposed: 0, warning: 1, blocked: 0 }) + expect(broker.status).toBe('warnings') + expect(broker.blocked).toEqual([]) + expect(broker.warnings.join('\n')).toMatch(/filesystem\.project\.write/) + expect(broker.warnings.join('\n')).toMatch(/not configured/) + }) + it('blocks work-package optional unavailable MCP access unless fallback is non-blocking', () => { const result = evaluateWorkPackageMcpBroker({ assignedRole: 'reviewer', diff --git a/web/worker/mcp-execution-design.ts b/web/worker/mcp-execution-design.ts index ae37b95..9b6cf78 100644 --- a/web/worker/mcp-execution-design.ts +++ b/web/worker/mcp-execution-design.ts @@ -678,8 +678,8 @@ export function evaluateWorkPackageMcpBroker(input: { const message = statusMessage(mcpId, status) const hasOnlyPlanningOnlyCapabilities = capabilities.length > 0 && actionableCapabilityCount === 0 if ( - requirement === 'required' && - ((capabilities.length === 0 && hasPromptOnlyContextForMcp) || hasOnlyPlanningOnlyCapabilities) + hasOnlyPlanningOnlyCapabilities || + (requirement === 'required' && capabilities.length === 0 && hasPromptOnlyContextForMcp) ) { warnings.push(message) } else { @@ -759,6 +759,7 @@ function decisionStatus( if (!isKnownMcpId(requirement.mcpId)) return 'blocked' if (capabilities.length === 0) { return canProceedWithoutMcp(requirement) || + (requirement.requirement === 'optional' && healthyStatus(status)) || (requirement.requirement === 'required' && hasRunScopedMcpInstructions) ? 'warning' : 'blocked'