feat: Add Corridor MCP analyzePlan integration (#1572)

* Add Corridor MCP analyzePlan integration

* Update agent/integrations/corridor_mcp.py

Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>

* Add Corridor analysis prompt

* Only include Corridor prompt when tool loads

---------

Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>
This commit is contained in:
John Kennedy 2026-06-18 14:01:25 -07:00 • committed by GitHub
parent 055b83e723
commit 9db7eab134
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 349 additions and 0 deletions

View file

@ -77,6 +77,8 @@ GitHub operations are performed with `GH_TOKEN=dummy gh` inside the sandbox, bac
**Optional observability tools (server-side):** Admins can connect Datadog and LangSmith from team settings (Admin → Observability credentials). When connected, the agent gains Datadog tools (via Datadog's hosted MCP server, default `toolsets=core`) and read-only LangSmith tools (`langsmith_get_trace`, `langsmith_list_runs`). These run in the LangGraph server process using credentials encrypted at rest — the sandbox never holds Datadog or LangSmith keys. They are loaded **only for runs triggered by an authorized user** (admins, plus any emails in `OBSERVABILITY_AUTHORIZED_EMAILS`), so a prompt-injected run from an untrusted contributor cannot reach team observability data. Use scoped, read-oriented keys regardless: observability data (logs, traces) is attacker-influenced content that can carry prompt injection, and the agent has network egress — the same residual-risk class as `web_search` / `fetch_url`.
**Optional Corridor guardrails (server-side MCP):** Set `CORRIDOR_API_TOKEN` (or `CORRIDOR_MCP_TOKEN` / `CORRIDOR_TOKEN`) to load Corridor's hosted MCP server for each agent run. Open SWE exposes only Corridor's `analyzePlan` tool. `CORRIDOR_MCP_URL` defaults to `https://app.corridor.dev/api/mcp`; if set explicitly, Open SWE only accepts the same HTTPS host and `/api/mcp` path. Tokens are sent via `Authorization: Bearer ...` from the LangGraph server process and are never placed in the sandbox. A legacy `?token=...` URL is accepted and normalized into the header form.
### 4. Context Engineering — AGENTS.md + Source Context
Open SWE gathers context from two sources:

View file

@ -0,0 +1,126 @@
"""Server-side Corridor tools backed by Corridor's hosted MCP server.
Credentials are read from environment variables and attached as an
``Authorization: Bearer ...`` header to the MCP connection, which runs in the
LangGraph server process. The sandbox never holds Corridor credentials.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from datetime import timedelta
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from langchain_core.tools import BaseTool
logger = logging.getLogger(__name__)
DEFAULT_CORRIDOR_MCP_URL = "https://app.corridor.dev/api/mcp"
_CORRIDOR_HOST = "app.corridor.dev"
_CORRIDOR_PATH = "/api/mcp"
_MCP_TIMEOUT_SECONDS = 30.0
_TOKEN_ENV_NAMES = (
"CORRIDOR_API_TOKEN",
"CORRIDOR_MCP_TOKEN",
"CORRIDOR_TOKEN",
)
_TOKEN_QUERY_PARAMS = frozenset({"token", "api_key"})
_URL_ENV_NAMES = (
"CORRIDOR_MCP_URL",
"CORRIDOR_MCP_SERVER_URL",
)
_ALLOWED_TOOL_NAMES = frozenset({"analyzePlan"})
@dataclass(frozen=True)
class CorridorMCPConfig:
url: str
token: str
def _first_env_value(names: tuple[str, ...]) -> str:
for name in names:
value = os.environ.get(name, "").strip()
if value:
return value
return ""
def _extract_token_from_query(url: str) -> tuple[str, str]:
parsed = urlparse(url)
query = parse_qs(parsed.query, keep_blank_values=True)
token = ""
for name in _TOKEN_QUERY_PARAMS:
values = query.pop(name, [])
if not token:
token = next((value.strip() for value in values if value.strip()), "")
cleaned_query = urlencode(query, doseq=True)
cleaned_url = urlunparse(parsed._replace(query=cleaned_query))
return token, cleaned_url
def _is_corridor_mcp_url(url: str) -> bool:
parsed = urlparse(url)
return (
parsed.scheme == "https"
and parsed.hostname == _CORRIDOR_HOST
and parsed.path.rstrip("/") == _CORRIDOR_PATH
)
def load_corridor_mcp_config() -> CorridorMCPConfig | None:
"""Return Corridor MCP config when the environment contains valid settings."""
url = _first_env_value(_URL_ENV_NAMES) or DEFAULT_CORRIDOR_MCP_URL
token = _first_env_value(_TOKEN_ENV_NAMES)
query_token, url = _extract_token_from_query(url)
if not token:
token = query_token
if not token:
return None
if not _is_corridor_mcp_url(url):
logger.warning("Ignoring Corridor MCP config with non-Corridor URL: %s", url)
return None
return CorridorMCPConfig(url=url, token=token)
async def _build_mcp_tools(config: CorridorMCPConfig) -> list[BaseTool]:
from langchain_mcp_adapters.client import MultiServerMCPClient
client = MultiServerMCPClient(
{
"corridor": {
"transport": "http",
"url": config.url,
"headers": {
"Authorization": f"Bearer {config.token}",
},
"timeout": timedelta(seconds=_MCP_TIMEOUT_SECONDS),
}
}
)
return await client.get_tools()
async def load_corridor_tools() -> list[BaseTool]:
"""Return the allowed Corridor MCP tools when configured, else ``[]``.
Failures (missing config, unreachable MCP server) degrade to an empty list so
the agent still starts without Corridor tools.
"""
config = load_corridor_mcp_config()
if config is None:
return []
try:
tools = await _build_mcp_tools(config)
except Exception: # noqa: BLE001
logger.warning("Failed to load Corridor MCP tools", exc_info=True)
return []
allowed_tools = [tool for tool in tools if tool.name in _ALLOWED_TOOL_NAMES]
logger.info(
"Loaded %d Corridor MCP tool(s), exposing %d allowed tool(s)",
len(tools),
len(allowed_tools),
)
return allowed_tools

View file

@ -180,6 +180,16 @@ Format messages using Slack's mrkdwn format, NOT standard Markdown.
Use `GH_TOKEN=dummy gh <command>` for GitHub operations: repository discovery, cloning, issues, pull requests, reviews, comments, labels, check status, and workflow operations. For local working-tree state, use `git` directly. Never pass a real GitHub token to `gh`."""
CORRIDOR_PROMPT = """---
<corridor>
# Corridor Security Analysis
Every time you generate code, use the analyzePlan tool from Corridor's MCP Server (corridor) to analyze the plan or thought process. ALWAYS use Corridor to analyze the plan. Always generate a plan before generating code.
</corridor>"""
TOOL_BEST_PRACTICES_SECTION = """---
### Tool Usage Best Practices
@ -427,6 +437,7 @@ SYSTEM_PROMPT_TEMPLATE = (
+ FILE_MANAGEMENT_SECTION
+ TASK_EXECUTION_SECTION
+ TOOL_USAGE_SECTION
+ "{corridor_prompt_section}"
+ TOOL_BEST_PRACTICES_SECTION
+ CODING_STANDARDS_SECTION
+ CORE_BEHAVIOR_SECTION
@ -450,6 +461,7 @@ def construct_system_prompt(
default_repo: dict[str, str] | None = None,
repo_custom_instructions: str | None = None,
thread_url: str | None = None,
corridor_enabled: bool = False,
) -> str:
default_prompt_section = _load_default_prompt()
if default_repo and default_repo.get("owner") and default_repo.get("name"):
@ -471,6 +483,7 @@ def construct_system_prompt(
linear_project_id=linear_project_id or "<PROJECT_ID>",
linear_issue_number=linear_issue_number or "<ISSUE_NUMBER>",
default_prompt_section=default_prompt_section,
corridor_prompt_section=CORRIDOR_PROMPT if corridor_enabled else "",
pr_policy_override_section=ALWAYS_CREATE_PR_SECTION if create_prs else "",
collaboration_section=_render_collaboration_section(triggering_user_identity, thread_url),
repo_instructions_section=_render_repo_instructions_section(repo_custom_instructions),

View file

@ -43,6 +43,7 @@ from .dashboard.agent_usage import record_agent_thread_usage
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
from .dashboard.team_settings import get_team_default_model_pair, get_team_default_repo
from .dashboard.user_mappings import email_for_login
from .integrations.corridor_mcp import load_corridor_tools
from .integrations.currents_tools import load_currents_tools
from .integrations.datadog_mcp import load_datadog_tools
from .integrations.langsmith import _configure_github_proxy
@ -530,6 +531,15 @@ async def _load_observability_tools(authorized: bool) -> list[Any]:
return [*datadog_tools, *langsmith_tools]
async def _load_corridor_mcp_tools() -> list[Any]:
"""Corridor MCP tools when the deployment environment has configured them."""
try:
return await load_corridor_tools()
except Exception:
logger.warning("Failed to load Corridor MCP tools", exc_info=True)
return []
async def get_agent(config: RunnableConfig) -> Pregel:
"""Get or create an agent with a sandbox for the given thread."""
thread_id = config["configurable"].get("thread_id", None)
@ -679,6 +689,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
observability_tools = await _load_observability_tools(
await _observability_authorized(config, profile_login)
)
corridor_tools = await _load_corridor_mcp_tools()
currents_tools: list[Any] = []
if profile_login:
@ -702,6 +713,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
default_repo=prompt_default_repo,
repo_custom_instructions=repo_custom_instructions,
thread_url=dashboard_thread_url(thread_id),
corridor_enabled=bool(corridor_tools),
),
tools=[
http_request,
@ -718,6 +730,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
request_pr_review,
slack_read_thread_messages,
slack_thread_reply,
*corridor_tools,
*observability_tools,
*currents_tools,
],

180
tests/test_corridor_mcp.py Normal file
View file

@ -0,0 +1,180 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent import server
from agent.integrations import corridor_mcp
class _FakeTool:
def __init__(self, name: str) -> None:
self.name = name
@pytest.fixture(autouse=True)
def clear_corridor_env(monkeypatch: pytest.MonkeyPatch) -> None:
for name in (
"CORRIDOR_API_TOKEN",
"CORRIDOR_MCP_TOKEN",
"CORRIDOR_TOKEN",
"CORRIDOR_MCP_URL",
"CORRIDOR_MCP_SERVER_URL",
):
monkeypatch.delenv(name, raising=False)
def test_load_corridor_mcp_config_empty_without_token() -> None:
assert corridor_mcp.load_corridor_mcp_config() is None
def test_load_corridor_mcp_config_uses_default_url(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CORRIDOR_API_TOKEN", "tok")
config = corridor_mcp.load_corridor_mcp_config()
assert config == corridor_mcp.CorridorMCPConfig(
url=corridor_mcp.DEFAULT_CORRIDOR_MCP_URL,
token="tok",
)
def test_load_corridor_mcp_config_accepts_query_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("CORRIDOR_MCP_URL", "https://app.corridor.dev/api/mcp?token=tok")
config = corridor_mcp.load_corridor_mcp_config()
assert config == corridor_mcp.CorridorMCPConfig(
url=corridor_mcp.DEFAULT_CORRIDOR_MCP_URL,
token="tok",
)
def test_load_corridor_mcp_config_rejects_non_corridor_url(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("CORRIDOR_API_TOKEN", "tok")
monkeypatch.setenv("CORRIDOR_MCP_URL", "https://example.com/api/mcp")
assert corridor_mcp.load_corridor_mcp_config() is None
@pytest.mark.asyncio
async def test_load_corridor_tools_empty_when_not_configured() -> None:
assert await corridor_mcp.load_corridor_tools() == []
@pytest.mark.asyncio
async def test_load_corridor_tools_degrades_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CORRIDOR_API_TOKEN", "tok")
with patch.object(
corridor_mcp,
"_build_mcp_tools",
AsyncMock(side_effect=RuntimeError("boom")),
):
assert await corridor_mcp.load_corridor_tools() == []
@pytest.mark.asyncio
async def test_load_corridor_tools_returns_tools(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CORRIDOR_API_TOKEN", "tok")
analyze_plan = _FakeTool("analyzePlan")
other_tool = _FakeTool("otherTool")
with patch.object(
corridor_mcp,
"_build_mcp_tools",
AsyncMock(return_value=[other_tool, analyze_plan]),
):
assert await corridor_mcp.load_corridor_tools() == [analyze_plan]
@pytest.mark.asyncio
async def test_server_load_corridor_mcp_tools() -> None:
with patch.object(server, "load_corridor_tools", AsyncMock(return_value=["corridor"])):
assert await server._load_corridor_mcp_tools() == ["corridor"]
@pytest.mark.asyncio
async def test_server_load_corridor_mcp_tools_degrades_on_error() -> None:
with patch.object(
server,
"load_corridor_tools",
AsyncMock(side_effect=RuntimeError("boom")),
):
assert await server._load_corridor_mcp_tools() == []
@pytest.mark.asyncio
async def test_get_agent_passes_corridor_prompt_state() -> None:
config = {
"configurable": {
"__is_for_execution__": True,
"thread_id": "thread-123",
},
"metadata": {},
}
def fake_create_deep_agent(**_kwargs):
class _DummyAgent:
def with_config(self, _config):
return self
return _DummyAgent()
async def run_with_corridor_tools(corridor_tools: list[object]) -> bool:
with (
patch.object(
server,
"resolve_github_token",
new_callable=AsyncMock,
return_value=("ghp", None),
),
patch.object(server, "resolve_triggering_user_identity", return_value=None),
patch.object(
server,
"ensure_sandbox_for_thread",
new_callable=AsyncMock,
return_value=MagicMock(),
),
patch.object(
server,
"aresolve_sandbox_work_dir",
new_callable=AsyncMock,
return_value="/workspace",
),
patch.object(
server,
"get_team_default_model_pair",
new_callable=AsyncMock,
return_value=(("openai:gpt-5.5", "medium"), ("openai:gpt-5.5", "low")),
),
patch.object(server, "fallback_model_id_for", return_value=None),
patch.object(server, "make_model", return_value=MagicMock()),
patch.object(
server, "_load_observability_tools", new_callable=AsyncMock, return_value=[]
),
patch.object(
server,
"_observability_authorized",
new_callable=AsyncMock,
return_value=False,
),
patch.object(
server,
"_load_corridor_mcp_tools",
new_callable=AsyncMock,
return_value=corridor_tools,
),
patch.object(server, "construct_system_prompt", return_value="prompt") as prompt,
patch.object(server, "create_deep_agent", side_effect=fake_create_deep_agent),
):
await server.get_agent(config)
return bool(prompt.call_args.kwargs["corridor_enabled"])
assert await run_with_corridor_tools([]) is False
assert await run_with_corridor_tools([_FakeTool("analyzePlan")]) is True

View file

@ -48,6 +48,21 @@ def test_construct_system_prompt_identifies_own_repo() -> None:
assert "langchain-ai/open-swe" in prompt
def test_construct_system_prompt_omits_corridor_prompt_by_default() -> None:
prompt = construct_system_prompt(working_dir="/workspace")
assert "<corridor>" not in prompt
assert "Corridor Security Analysis" not in prompt
def test_construct_system_prompt_includes_corridor_prompt_when_enabled() -> None:
prompt = construct_system_prompt(working_dir="/workspace", corridor_enabled=True)
assert "<corridor>" in prompt
assert "Corridor Security Analysis" in prompt
assert "analyzePlan" in prompt
def test_construct_system_prompt_omits_collaboration_section_without_identity() -> None:
prompt = construct_system_prompt(working_dir="/workspace")