View on GitHub

Lightspeed Core Stack

Lightspeed Core Stack

Spike for Human-in-the-Loop (HIL) for MCP Tool Calling

Overview

The problem: MCP tools can perform write operations (create issues, send messages, modify data) that carry risk of unwanted changes. Currently, LCS hardcodes require_approval="never" for all MCP tools, providing no mechanism for human review before execution.

The recommendation: Implement asynchronous approval via a new /approvals API. When a tool requires approval, LCS returns a requires_action status with approval request details. Clients submit approvals via POST, and LCS continues the agent loop. Allow/deny lists in YAML config enable permanent pre-approval for trusted tools.

Decisions for Product/Architecture Review

These are the high-level decisions that determine scope, approach, and cost. Each has a recommendation - please confirm or override.

Decision 1: Approval flow model

When an MCP tool requires approval, how should LCS handle the request?

Option Description Pros Cons
A. Synchronous (hold connection) Keep HTTP connection open until approval received Simple client integration Timeout issues, doesn’t scale
B. Asynchronous (separate API) Return immediately with requires_action, client submits approval via /approvals Scalable, supports UIs/webhooks More complex, requires storage

Recommendation: Option B (Asynchronous). This matches OpenAI’s pattern for function calling requiring action, scales better, and supports diverse client implementations (CLI, web UI, mobile).

Decision 2: Approval scope

What is the scope of an approval decision?

Option Description
A. Per-user global Once approved, tool is approved for all user conversations
B. Per-conversation Approval is valid only for the current conversation
C. Per-request Each tool invocation requires separate approval

Recommendation: Option C (Per-request). Each tool invocation requires explicit approval, providing maximum security and auditability. Users maintain full control over every action taken on their behalf.

Decision 3: Permanent allow/deny list storage

Where should permanent tool allow/deny lists be configured?

Option Description
A. Database only Runtime configuration, managed via API
B. YAML config only Deployment-time configuration
C. Both YAML baseline with database overrides

Recommendation: Option B (YAML config only). Keeps security policy declarative and auditable. Runtime overrides can be added in a future iteration if customer demand exists.

Decision 4: Default approval requirement

What should the default require_approval value be for MCP servers?

Option Description
A. "never" (current behavior) Backwards compatible, opt-in HIL
B. "always" Secure by default, opt-out for trusted tools

Recommendation: Option A ("never"). Maintains backwards compatibility. Operators explicitly enable HIL for servers with write operations.

Technical Decisions for Engineering Review

Architecture-level and implementation-level decisions.

Decision 5: Approval request storage backend

Where should pending approval requests be stored?

Option Description
A. In-memory only Simple, no persistence
B. Existing cache backends (SQLite/PostgreSQL) Reuse infrastructure
C. New dedicated store Clean separation

Recommendation: Option B (Existing cache backends). Approval requests are ephemeral (configurable TTL), similar to conversation cache entries. Adding a new table to existing cache backends minimizes infrastructure changes.

See: quota module pattern (sql.py, connect_sqlite.py, connect_pg.py)

Decision 6: Approval timeout configuration

How should approval request expiration be configured?

Recommendation: Add approval_timeout_seconds field to main configuration with a default of 300 seconds (5 minutes). This is configurable per-deployment.

# lightspeed-stack.yaml
approval_timeout_seconds: 300  # Default: 5 minutes

Decision 7: Response status for pending approvals

How should LCS indicate that an approval is required?

Recommendation: Return HTTP 200 with response body containing:

This follows the OpenAI Assistants API pattern for requires_action status.

Decision 8: MCP tool annotation handling

Should LCS use MCP tool annotations (destructiveHint, readOnlyHint) to automatically determine approval requirements?

Option Description
A. Ignore annotations Use only YAML config
B. Trust annotations Auto-require approval for destructiveHint=true
C. Annotations as hints Annotations inform defaults, config overrides

Recommendation: Option A (Ignore annotations). MCP spec explicitly states annotations are “untrusted hints.” Security policy should be explicit in YAML, not derived from potentially malicious MCP servers.

Proposed JIRAs

LCORE-???? Add require_approval configuration for MCP servers

Description: Extend ModelContextProtocolServer configuration to support require_approval field with values "always", "never", or granular allow/deny lists per tool.

Scope:

Acceptance criteria:

Agentic tool instruction:

Read the "Configuration" section in docs/design/human-in-the-loop/human-in-the-loop.md.
Key files: src/models/config.py, src/configuration.py.

LCORE-???? Implement approval request storage

Description: Create storage layer for pending approval requests following the existing src/quota/ module pattern (SQLite/PostgreSQL).

Scope:

Acceptance criteria:

Agentic tool instruction:

Read the "Storage / data model changes" section in docs/design/human-in-the-loop/human-in-the-loop.md.
Reference implementation: src/quota/ module (sql.py, connect_sqlite.py, connect_pg.py).
Scheduler reference: src/runners/quota_scheduler.py.

LCORE-???? Implement /approvals API endpoints

Description: Create REST API endpoints for listing, viewing, and submitting approval decisions.

Scope:

Acceptance criteria:

Agentic tool instruction:

Read the "API changes" section in docs/design/human-in-the-loop/human-in-the-loop.md.
Key files: src/app/endpoints/, src/models/requests.py, src/models/responses.py.

LCORE-???? Integrate approval flow into query endpoints

Description: Modify query and streaming_query endpoints to handle mcp_approval_request events, store pending approvals, and return requires_action status.

Scope:

Acceptance criteria:

Agentic tool instruction:

Read the "Trigger mechanism" and "API changes" sections in docs/design/human-in-the-loop/human-in-the-loop.md.
Key files: src/app/endpoints/query.py, src/app/endpoints/streaming_query.py, src/utils/responses.py.

LCORE-???? Wire require_approval to MCP tool creation

Description: Pass the configured require_approval value to InputToolMCP when creating MCP tools for Llama Stack requests.

Scope:

Acceptance criteria:

Agentic tool instruction:

Read the "Implementation Suggestions" section in docs/design/human-in-the-loop/human-in-the-loop.md.
Key files: src/utils/responses.py (get_mcp_tools function).

LCORE-???? Add E2E tests for HIL approval flow

Description: Create end-to-end tests covering the full approval workflow.

Scope:

Acceptance criteria:

Agentic tool instruction:

Read the "Test patterns" section in docs/design/human-in-the-loop/human-in-the-loop.md.
Key files: tests/e2e/features/, tests/e2e/mock_mcp_server/.

LCORE-???? Document HIL feature for operators and API consumers

Description: Create user-facing documentation for the HIL feature.

Scope:

Acceptance criteria:

Agentic tool instruction:

Read docs/design/human-in-the-loop/human-in-the-loop.md for feature details.
Reference existing docs in docs/ for style.

PoC Results

No PoC was built for this spike. The core mechanisms are already validated:

  1. Llama Stack approval types exist: MCPApprovalRequest and MCPApprovalResponse are defined in llama_stack_api.openai_responses
  2. LCS already parses approval events: build_tool_call_summary() in responses.py:1067-1094 handles both mcp_approval_request and mcp_approval_response types
  3. Llama Stack supports require_approval: The InputToolMCP model accepts "always", "never", or ApprovalFilter

The main implementation work is:

Background Sections

Current Architecture

MCP tool creation (responses.py:687-744):

async def get_mcp_tools(...) -> list[InputToolMCP]:
    # ...
    tools.append(
        InputToolMCP(
            type="mcp",
            server_label=mcp_server.name,
            server_url=mcp_server.url,
            require_approval="never",  # <-- Hardcoded, needs to be configurable
            # ...
        )
    )

MCP server configuration (config.py:468-530):

Approval event handling (responses.py:1067-1094):

Llama Stack Support

From llama_stack_api.openai_responses:

class ApprovalFilter(BaseModel):
    always: list[str] | None = None  # Tools that always require approval
    never: list[str] | None = None   # Tools that never require approval

class OpenAIResponseInputToolMCP(BaseModel):
    require_approval: Literal["always"] | Literal["never"] | ApprovalFilter = "never"
    # ...

class OpenAIResponseMCPApprovalRequest(BaseModel):
    arguments: str
    id: str
    name: str
    server_label: str
    type: Literal["mcp_approval_request"]

class OpenAIResponseMCPApprovalResponse(BaseModel):
    approval_request_id: str
    approve: bool
    reason: str | None = None
    type: Literal["mcp_approval_response"]

MCP Specification Context

From MCP spec research:

  1. Tool annotations (readOnlyHint, destructiveHint, etc.) are defined but explicitly labeled as “untrusted hints”
  2. MCP Elicitation is the spec’s native HIL mechanism, but it’s for server-initiated user prompts, not tool approval
  3. Best practice: Clients should decide approval policy based on server trust level, not annotation values

Alternative Considered: Synchronous Approval

A simpler approach would hold the HTTP connection open until approval is received. This was rejected because:

Appendix B: OpenAI Assistants API Reference

The proposed requires_action pattern follows OpenAI’s Assistants API:

{
  "id": "run_abc123",
  "status": "requires_action",
  "required_action": {
    "type": "submit_tool_outputs",
    "submit_tool_outputs": {
      "tool_calls": [...]
    }
  }
}

LCS adapts this for MCP approvals:

{
  "status": "requires_action",
  "required_action": {
    "type": "mcp_approval",
    "approvals": [...]
  }
}