Skip to content

feat(integrations): per-tool agent approvals for remote mcp servers#2855

Open
jordan-umusu wants to merge 1 commit into
mainfrom
feat/remote-mcp-approvals
Open

feat(integrations): per-tool agent approvals for remote mcp servers#2855
jordan-umusu wants to merge 1 commit into
mainfrom
feat/remote-mcp-approvals

Conversation

@jordan-umusu

@jordan-umusu jordan-umusu commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Core change

  • mcpintegration.tools column schema extended — each stored tool entry now carries
    per-tool policy: enabled (default true), requires_approval (default false), and
    status (available | missing). No migration needed: old [{name, description}] rows
    self-heal on read via MCPToolSummary defaults, and the next successful verification
    rewrites them in the new shape.
  • Re-verification merges instead of overwrites — discovery owns name/description/status;
    stored rows own user policy. Tools that disappear from the server are kept as
    status: "missing" (policy preserved) instead of silently dropped, and a failed
    verification no longer clears the stored tool snapshot.

Summary by cubic

Adds per-tool enable/approval policies for remote MCP integrations. Agents now enforce these policies and route approved user MCP tool calls through the trusted MCP router; the UI adds toggles and clearer tool counts.

  • New Features
    • API: PATCH /workspaces/{workspace_id}/mcp-integrations/{mcp_integration_id}/tools to update per-tool policies; adds MCPToolPolicyUpdate, MCPToolPolicyUpdateRequest; MCPToolSummary now includes enabled, requires_approval, and status.
    • Verification/update preserves policy and marks missing tools; failures no longer clear stored tool snapshots.
    • Agent: compiles effective tool_approvals, checks entitlements, blocks unapproved calls, and executes approved user MCP tools via new execute_remote_mcp_tool activity (token-scoped trusted router).
    • Frontend: Tools list shows per-tool “Enabled” and “Approval” switches with badges and pending state; catalog cards show “Connected · {active}/{total} tools”; new useUpdateMcpIntegrationToolPolicies hook and generated client types.
    • Tests: coverage for policy merging, approval gating, proxy-routed MCP tool approval, and workflow/tool-name resolution.

Written for commit d16921f. Summary will update on new commits.

Review in cubic

image

@jordan-umusu jordan-umusu added integrations Pre-built actions agents LLM agents labels Jun 12, 2026
@jordan-umusu jordan-umusu marked this pull request as ready for review June 12, 2026 18:43
@zeropath-ai

zeropath-ai Bot commented Jun 12, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to d16921f.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► frontend/src/app/workspaces/[workspaceId]/mcp-servers/page.tsx
    Refactor tool count display to show active/total tools
► frontend/src/client/schemas.gen.ts
    Add schemas for MCP tool policy updates
► frontend/src/client/services.gen.ts
    Add API endpoint for updating MCP integration tool policies
► frontend/src/client/types.gen.ts
    Add types for MCP tool policy updates
► frontend/src/components/integrations/mcp-integration-dialog.tsx
    Implement UI for managing MCP tool policies
► frontend/src/lib/hooks.tsx
    Add hook for updating MCP integration tool policies
► packages/tracecat-ee/tracecat_ee/agent/activities.py
    Add logic to resolve and apply stored MCP tool policies during tool definition compilation
    Implement activity for executing remote MCP tools
► packages/tracecat-ee/tracecat_ee/agent/workflows/durable.py
    Add logic to apply tool approval policies to agent specs
    Implement routing for approved user MCP tool calls
► tests/unit/test_agent_activities.py
    Add tests for MCP tool policy filtering and approval mapping
► tests/unit/test_agent_runtime.py
    Add test for proxy-routed user MCP tool approval denial
► tests/unit/test_durable_agent_workflow_search_attributes.py
    Add helper function to normalize user MCP tool names
► tests/unit/test_mcp_integrations.py
    Add types and tests for MCP tool policy updates
Refactor ► frontend/src/client/types.gen.ts
    Rename status types to avoid naming conflicts
Other ► deployments/k8s
    Update subproject commit hash

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 18 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Re-trigger cubic

@jordan-umusu

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d16921f158

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/tracecat-ee/tracecat_ee/agent/workflows/durable.py
@jordan-umusu jordan-umusu requested a review from daryllimyt June 12, 2026 19:39

@daryllimyt daryllimyt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found two functional issues that should be addressed before this patch is considered correct.

if not server or not remote_tool:
return None
tool_name = action_name
return action_name_to_mcp_tool_name(tool_name)

@daryllimyt daryllimyt Jun 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This path is lossy for user MCP names that contain dots: the approval key uses mcp.{server}.{tool}, then action_name_to_mcp_tool_name() converts every dot to __, so mcp.Jira.issue.get becomes mcp__Jira__issue__get rather than preserving the remote segment as issue.get.

If dotted remote tool names are intentionally unsupported because provider tool registration rejects them, we should enforce that during discovery/verification and surface a clear validation error. Otherwise, approved execution needs a non-lossy mapping from provider-safe tool names back to the original mcp__{server}__{remote_tool} name.

Comment on lines +3658 to +3659
if policy_update.requires_approval is not None:
update_fields["requires_approval"] = policy_update.requires_approval

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For organizations without agent_addons, this endpoint still persists requires_approval=true for custom MCP tools; later agent compilation sees the stored policy and _check_tool_approval_entitlement() rejects the run, so a user with only integration:update can save a setting that makes the MCP-backed agent unusable. Reject approval-enabling updates without the entitlement while still allowing false/enabled changes.

Copy link
Copy Markdown
Contributor

I think this is worth adding as a real Temporal integration test. The PR already has good unit coverage for policy merge/update, build-time approval mapping, runtime hook denial, and name conversion, but it does not currently exercise the exact new workflow branch:

approval requested -> workflow waits -> approval submitted -> approved user MCP tool executes via execute_remote_mcp_tool -> reconcile -> resume agent -> replay suspended/completed histories

Here is a compact version that should fit in tests/temporal/test_durable_agent_workflow.py and reuse the existing helpers in that file.

# Add to existing imports:
# from tracecat_ee.agent.activities import ExecuteRemoteMCPToolArgs
# from tracecat.agent.tokens import UserMCPServerClaim

@pytest.mark.anyio
@pytest.mark.integration
async def test_agent_workflow_routes_approved_user_mcp_tool_to_remote_activity_and_replays(
    svc_role: Role,
    temporal_client: Client,
    agent_worker_factory,
    mock_session_id: uuid.UUID,
) -> None:
    queue = f"test-agent-queue-{mock_session_id}"
    integration_id = uuid.uuid4()
    approval_request_recorded = asyncio.Event()
    agent_inputs: list[AgentExecutorInput] = []
    remote_activity_inputs: list[ExecuteRemoteMCPToolArgs] = []
    registry_activity_inputs: list[RunActionInput] = []
    reconcile_inputs: list[ReconcileToolResultsInput] = []

    @activity.defn(name="load_session_activity")
    async def mock_load_session_activity(
        input: LoadSessionInput,
    ) -> LoadSessionResult:
        assert input.session_id == mock_session_id
        return LoadSessionResult(
            found=bool(agent_inputs),
            sdk_session_id="sdk-session" if agent_inputs else None,
            sdk_session_data=None,
            is_fork=False,
        )

    @activity.defn(name="build_agent_tool_definitions")
    async def mock_build_tool_definitions(
        args: BuildAgentToolDefsArgs,
    ) -> BuildAgentToolDefsResult:
        tool_result = BuildToolDefsResult(
            tool_definitions={
                "mcp__Jira__getIssue": MCPToolDefinition(
                    name="mcp__Jira__getIssue",
                    description="Get an issue",
                    parameters_json_schema={
                        "type": "object",
                        "properties": {"issue_key": {"type": "string"}},
                        "required": ["issue_key"],
                        "additionalProperties": False,
                    },
                )
            },
            registry_lock=RegistryLock(origins={}, actions={}),
            user_mcp_claims=[UserMCPServerClaim(name="Jira", id=integration_id)],
            tool_approvals={"mcp.Jira.getIssue": True},
        )
        return BuildAgentToolDefsResult(
            scopes={scope.scope: tool_result for scope in args.scopes}
        )

    @activity.defn(name="run_agent_activity")
    async def mock_run_agent_activity(
        input: AgentExecutorInput,
    ) -> AgentExecutorResult:
        agent_inputs.append(input)
        if len(agent_inputs) == 1:
            assert input.is_approval_continuation is False
            return AgentExecutorResult(
                success=True,
                approval_requested=True,
                approval_items=[
                    ToolCallContent(
                        id="call-user-mcp",
                        name="mcp__tracecat-registry__mcp__Jira__getIssue",
                        input={"issue_key": "ISSUE-1"},
                    )
                ],
            )

        assert input.is_approval_continuation is True
        return AgentExecutorResult(
            success=True,
            approval_requested=False,
            output={"continued": True},
        )

    @activity.defn(name="record_approval_requests")
    async def mock_record_approval_requests(
        input: PersistApprovalsActivityInputs,
    ) -> None:
        assert [approval.tool_call_id for approval in input.approvals] == [
            "call-user-mcp"
        ]
        approval_request_recorded.set()

    @activity.defn(name="apply_approval_decisions")
    async def mock_apply_approval_decisions(
        input: ApplyApprovalResultsActivityInputs,
    ) -> None:
        assert [decision.tool_call_id for decision in input.decisions] == [
            "call-user-mcp"
        ]
        assert input.decisions[0].approved is True

    @activity.defn(name="execute_remote_mcp_tool")
    async def mock_execute_remote_mcp_tool(args: ExecuteRemoteMCPToolArgs) -> str:
        remote_activity_inputs.append(args)
        assert args.tool_name == "mcp__Jira__getIssue"
        assert args.args == {"issue_key": "ISSUE-1"}
        assert args.mcp_auth_token
        return '{"ok": true}'

    @activity.defn(name="execute_action_activity")
    async def mock_execute_action_activity(
        input: RunActionInput,
        role: Role,
    ) -> InlineObject[dict[str, str]]:
        del role
        registry_activity_inputs.append(input)
        return InlineObject(data={"unexpected": "registry path"})

    @activity.defn(name="reconcile_tool_results_activity")
    async def mock_reconcile_tool_results_activity(
        input: ReconcileToolResultsInput,
    ) -> ReconcileToolResultsResult:
        reconcile_inputs.append(input)
        return ReconcileToolResultsResult(
            results=[
                ToolExecutionResult(
                    tool_call_id=pending.tool_call_id,
                    tool_name=pending.tool_name,
                    result=pending.raw_result,
                    is_error=pending.is_error,
                )
                for pending in input.pending_results
            ]
        )

    workflow_args = AgentWorkflowArgs(
        role=svc_role,
        agent_args=RunAgentArgs(
            session_id=mock_session_id,
            user_prompt="Validate remote MCP approval continuation",
            config=AgentConfig(
                model_name="claude-3-5-sonnet-20241022",
                model_provider="anthropic",
                actions=[],
                tool_approvals={"mcp.Jira.getIssue": True},
            ),
        ),
        entity_type=AgentSessionEntity.WORKFLOW,
        entity_id=uuid.uuid4(),
    )

    activities = [
        create_mock_create_session_activity(),
        mock_load_session_activity,
        create_mock_load_session_messages_activity(),
        mock_build_tool_definitions,
        mock_run_agent_activity,
        mock_record_approval_requests,
        mock_apply_approval_decisions,
        mock_execute_remote_mcp_tool,
        mock_execute_action_activity,
        mock_reconcile_tool_results_activity,
    ]

    async with agent_worker_factory(
        temporal_client,
        task_queue=queue,
        custom_activities=activities,
    ):
        wf_handle = await temporal_client.start_workflow(
            DurableAgentWorkflow.run,
            workflow_args,
            id=AgentWorkflowID(mock_session_id),
            task_queue=queue,
            retry_policy=RETRY_POLICIES["workflow:fail_fast"],
            execution_timeout=timedelta(seconds=30),
        )

        await asyncio.wait_for(approval_request_recorded.wait(), timeout=10)
        suspended_history = await fetch_history_after_completed_workflow_task(wf_handle)
        await replay_durable_agent_workflow_history(temporal_client, suspended_history)

        await wf_handle.execute_update(
            DurableAgentWorkflow.set_approvals,
            WorkflowApprovalSubmission(
                approvals={"call-user-mcp": True},
                approved_by=svc_role.user_id,
            ),
        )

        result = await wf_handle.result()
        completed_history = await wf_handle.fetch_history()
        await replay_durable_agent_workflow_history(temporal_client, completed_history)

    assert result.output == {"continued": True}
    assert len(remote_activity_inputs) == 1
    assert remote_activity_inputs[0].tool_name == "mcp__Jira__getIssue"
    assert remote_activity_inputs[0].args == {"issue_key": "ISSUE-1"}
    assert registry_activity_inputs == []
    assert len(reconcile_inputs) == 1
    assert len(reconcile_inputs[0].pending_results) == 1
    assert reconcile_inputs[0].pending_results[0].raw_result == '{"ok": true}'
    assert [input.is_approval_continuation for input in agent_inputs] == [False, True]

I validated this shape locally with a standalone Temporal time-skipping run. The important part is not the mock remote MCP call itself; it is proving the workflow schedules the new execute_remote_mcp_tool activity after approval, does not fall through to execute_action_activity, resumes the agent turn, and remains replay-safe before and after the approval signal.

"""Whether the tool was present in the latest successful discovery."""

@classmethod
def validate_stored(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: why do we need this? is it just to correlate the validation error to the ID? seems redundant otherwise

Copy link
Copy Markdown
Contributor

Small clarification on the Temporal test suggestion above: that test covers replay of histories generated by the updated workflow code. It does not prove deploy compatibility for an execution that already passed approval under the pre-PR workflow and recorded execute_action_activity in history.

If we want to explicitly cover that risk, we should add a separate replay fixture generated from origin/main for:

approval requested -> approval submitted -> approved user MCP tool execution recorded

and replay that fixture against this branch. That is the test that would catch a post-approval command mismatch between old execute_action_activity history and the new execute_remote_mcp_tool branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents LLM agents integrations Pre-built actions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants