diff --git a/web/__tests__/mcp-execution-design.test.ts b/web/__tests__/mcp-execution-design.test.ts index 0e9ffe5..9b017b7 100644 --- a/web/__tests__/mcp-execution-design.test.ts +++ b/web/__tests__/mcp-execution-design.test.ts @@ -272,6 +272,104 @@ 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', + '{"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', + '{"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('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', @@ -372,6 +470,85 @@ 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 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('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', @@ -408,7 +585,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')) @@ -423,6 +600,48 @@ 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 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', + 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 +698,140 @@ 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: { + 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('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([]), + mcpRequirements: [{ + mcpId: 'github', + requirement: 'required', + permissions: [], + fallback: { action: 'ask_user', message: 'Use issue context from the prompt.' }, + }], + metadata: { + promptOverlay: 'Use 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('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('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', @@ -497,6 +850,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 5b8df39..9b6cf78 100644 --- a/web/worker/mcp-execution-design.ts +++ b/web/worker/mcp-execution-design.ts @@ -352,17 +352,36 @@ 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 = agents.map((agent) => + decisionStatus( + requirement, + status, + requirementCapabilitiesForAgent(requirement, agent), + designHasRunScopedMcpInstructionsForAgentRequirement(design, agent, requirement.mcpId), + ), + ) + 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 (!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 (!canProceedWithoutMcp(requirement)) blocked.push(message) + if (hasBlockedAgentDecision) blocked.push(message) else warnings.push(message) } } @@ -457,6 +476,25 @@ function metadataMcpAwareSubtasks(metadata: unknown): Record[] return objectArrayFrom(metadata.mcpAwareSubtasks) } +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, + packageMcpIds: Set, +): boolean { + if (!isRecord(metadata)) return false + return (packageMcpIds.size === 1 && cleanText(metadata.promptOverlay, 2_000) !== '') || + metadataMcpAwareSubtasks(metadata).some((subtask) => subtaskHasMcpContext(subtask, mcpId)) +} + function capabilityArray(entry: Record): { capabilities: string[] present: boolean @@ -569,6 +607,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 @@ -606,9 +649,10 @@ export function evaluateWorkPackageMcpBroker(input: { warnings.push(`MCP '${mcpId}' grant is warning-only.`) } + 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 (hasRunScopedMcpInstructions) warnings.push(message) + if (hasPromptOnlyContextForMcp) warnings.push(message) else blocked.push(message) } @@ -632,7 +676,15 @@ export function evaluateWorkPackageMcpBroker(input: { const status = healthFor(input.mcpOverview, mcpId) if (!healthyStatus(status)) { const message = statusMessage(mcpId, status) - shouldBlock(message) + const hasOnlyPlanningOnlyCapabilities = capabilities.length > 0 && actionableCapabilityCount === 0 + if ( + hasOnlyPlanningOnlyCapabilities || + (requirement === 'required' && capabilities.length === 0 && hasPromptOnlyContextForMcp) + ) { + warnings.push(message) + } else { + shouldBlock(message) + } } } @@ -706,7 +758,11 @@ function decisionStatus( ): McpGrantDecisionStatus { if (!isKnownMcpId(requirement.mcpId)) return 'blocked' if (capabilities.length === 0) { - return requirement.requirement === 'optional' || hasRunScopedMcpInstructions ? 'warning' : 'blocked' + return canProceedWithoutMcp(requirement) || + (requirement.requirement === 'optional' && healthyStatus(status)) || + (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 @@ -726,9 +782,22 @@ 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 designHasRunScopedMcpInstructionsForAgentRequirement( + design: McpExecutionDesign, + agent: string, + mcpId: string, +): boolean { + 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( @@ -761,7 +830,7 @@ export function deriveMcpGrantDecisions( requirement, status, capabilities, - designHasRunScopedMcpInstructionsForAgent(design, agent), + designHasRunScopedMcpInstructionsForAgentRequirement(design, agent, requirement.mcpId), ) summary[grantStatus] += 1 decisions.push({