Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions agent/src/channel_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,67 @@ def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool:
f"Linear MCP configured at {mcp_path} (server key: {LINEAR_MCP_SERVER_KEY})",
)
return True


def configure_profile_mcp(repo_dir: str, mcp_servers: list[str]) -> bool:
"""Write tool-profile MCP server entries into ``.mcp.json``.

Each entry in ``mcp_servers`` is a server identifier (e.g. ``"eslint-mcp"``).
The server entries are written as Streamable HTTP stubs — the actual URL
resolution is expected to be handled by the MCP server registry or
environment-based configuration.

For v1, profile MCP server entries use a convention-based URL pattern:
``MCP_SERVER_<UPPER_NAME>_URL`` env var, falling back to a placeholder.
The Claude Agent SDK will attempt to connect; if the URL is invalid or
unreachable the server simply won't contribute tools.

Args:
repo_dir: the cloned-repo working directory.
mcp_servers: list of MCP server identifiers from the tool profile.

Returns:
True if at least one server entry was written, False otherwise.
"""
if not mcp_servers:
return False

if not repo_dir or not os.path.isdir(repo_dir):
log("WARN", f"configure_profile_mcp: repo_dir missing or not a directory: {repo_dir!r}")
return False

mcp_path = os.path.join(repo_dir, ".mcp.json")
config = _read_existing_mcp_config(mcp_path)

servers = config.get("mcpServers")
if not isinstance(servers, dict):
servers = {}

for server_name in mcp_servers:
# Convention: look for MCP_SERVER_<NAME>_URL env var for the endpoint
env_key = f"MCP_SERVER_{server_name.upper().replace('-', '_')}_URL"
url = os.environ.get(env_key, "")
if not url:
log("WARN", f"No URL found for profile MCP server '{server_name}' (checked ${env_key}), skipping")
continue
servers[server_name] = {
"type": "http",
"url": url,
}

config["mcpServers"] = servers

try:
with open(mcp_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
f.write("\n")
except OSError as e:
log("ERROR", f"Failed to write profile MCP config to {mcp_path}: {e}")
return False

written = [s for s in mcp_servers if s in servers]
log(
"TASK",
f"Profile MCP servers configured at {mcp_path}: {written}",
)
return len(written) > 0
3 changes: 3 additions & 0 deletions agent/src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ def build_config(
channel_metadata: dict[str, str] | None = None,
trace: bool = False,
user_id: str = "",
tool_profile: str = "",
) -> TaskConfig:
"""Build and validate configuration from explicit parameters.

Expand Down Expand Up @@ -146,6 +147,7 @@ def build_config(
channel_metadata=channel_metadata or {},
trace=trace,
user_id=user_id,
tool_profile=tool_profile,
)


Expand All @@ -170,6 +172,7 @@ def get_config() -> TaskConfig:
# an unreachable ``traces//`` key.
trace=os.environ.get("TRACE", "").lower() in ("1", "true", "yes"),
user_id=os.environ.get("USER_ID", ""),
tool_profile=os.environ.get("TOOL_PROFILE", ""),
)
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
Expand Down
3 changes: 3 additions & 0 deletions agent/src/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ class TaskConfig(BaseModel):
# previews live, so dropping ``trace`` here silently no-ops the
# feature for the fields that matter.
trace: bool = False
# Tool profile selected at task submission (from Blueprint.toolProfiles).
# Empty string means legacy single-tier behavior (no profile selected).
tool_profile: str = ""
# Enriched mid-flight by pipeline.py:
cedar_policies: list[str] = []
issue: GitHubIssue | None = None
Expand Down
13 changes: 13 additions & 0 deletions agent/src/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,9 @@ def run_task(
branch_name: str = "",
pr_number: str = "",
cedar_policies: list[str] | None = None,
tool_profile: str = "",
profile_mcp_servers: list[str] | None = None,
profile_skills: list[str] | None = None,
channel_source: str = "",
channel_metadata: dict[str, str] | None = None,
trace: bool = False,
Expand Down Expand Up @@ -282,6 +285,7 @@ def run_task(
channel_metadata=channel_metadata,
trace=trace,
user_id=user_id,
tool_profile=tool_profile,
)

# Inject Cedar policies into config for the PolicyEngine in runner.py
Expand Down Expand Up @@ -418,6 +422,15 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None:
resolve_linear_api_token()
configure_channel_mcp(setup.repo_dir, config.channel_source)

# Tool-profile MCP wiring. Write profile MCP server entries into
# .mcp.json so the SDK picks them up via setting_sources=["project"].
if profile_mcp_servers:
from channel_mcp import configure_profile_mcp

configure_profile_mcp(setup.repo_dir, profile_mcp_servers)
if profile_skills:
log("TASK", f"Tool profile skills: {profile_skills}")

# 👀 on the Linear issue — acknowledges the task is picked up.
# No-op for non-Linear tasks. Best-effort; failures are logged
# but do not block the pipeline. Capture the reaction id so we
Expand Down
12 changes: 12 additions & 0 deletions agent/src/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,9 @@ def _run_task_background(
branch_name: str = "",
pr_number: str = "",
cedar_policies: list[str] | None = None,
tool_profile: str = "",
profile_mcp_servers: list[str] | None = None,
profile_skills: list[str] | None = None,
channel_source: str = "",
channel_metadata: dict[str, str] | None = None,
trace: bool = False,
Expand Down Expand Up @@ -322,6 +325,9 @@ def _run_task_background(
branch_name=branch_name,
pr_number=pr_number,
cedar_policies=cedar_policies,
tool_profile=tool_profile,
profile_mcp_servers=profile_mcp_servers,
profile_skills=profile_skills,
channel_source=channel_source,
channel_metadata=channel_metadata,
trace=trace,
Expand Down Expand Up @@ -371,6 +377,9 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict:
branch_name = inp.get("branch_name", "")
pr_number = str(inp.get("pr_number", ""))
cedar_policies = inp.get("cedar_policies") or []
tool_profile = inp.get("tool_profile", "") or ""
profile_mcp_servers = inp.get("profile_mcp_servers") or []
profile_skills = inp.get("profile_skills") or []
channel_source = inp.get("channel_source", "") or ""
channel_metadata = inp.get("channel_metadata") or {}
# ``trace`` is strictly opt-in (design §10.1). Accept only real
Expand Down Expand Up @@ -418,6 +427,9 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict:
"branch_name": branch_name,
"pr_number": pr_number,
"cedar_policies": cedar_policies,
"tool_profile": tool_profile,
"profile_mcp_servers": profile_mcp_servers,
"profile_skills": profile_skills,
"channel_source": channel_source,
"channel_metadata": channel_metadata,
"trace": trace,
Expand Down
53 changes: 53 additions & 0 deletions agent/tests/test_channel_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
LINEAR_MCP_SERVER_KEY,
LINEAR_MCP_URL,
configure_channel_mcp,
configure_profile_mcp,
)


Expand Down Expand Up @@ -139,3 +140,55 @@ def test_missing_repo_dir(self, tmp_path):
def test_empty_repo_dir_string(self):
wrote = configure_channel_mcp("", "linear")
assert wrote is False


class TestConfigureProfileMcp:
"""Tests for configure_profile_mcp — writing profile MCP server entries."""

def test_no_op_for_empty_servers(self, tmp_path):
wrote = configure_profile_mcp(str(tmp_path), [])
assert wrote is False

def test_no_op_for_missing_repo_dir(self, tmp_path):
wrote = configure_profile_mcp(str(tmp_path / "nope"), ["eslint-mcp"])
assert wrote is False

def test_writes_server_entry_when_env_var_present(self, tmp_path, monkeypatch):
monkeypatch.setenv("MCP_SERVER_ESLINT_MCP_URL", "https://eslint.example/mcp")
wrote = configure_profile_mcp(str(tmp_path), ["eslint-mcp"])
assert wrote is True
config = _read_mcp(str(tmp_path))
assert "eslint-mcp" in config["mcpServers"]
assert config["mcpServers"]["eslint-mcp"]["url"] == "https://eslint.example/mcp"
assert config["mcpServers"]["eslint-mcp"]["type"] == "http"

def test_skips_server_without_env_var(self, tmp_path, monkeypatch):
monkeypatch.delenv("MCP_SERVER_MYSERVER_URL", raising=False)
wrote = configure_profile_mcp(str(tmp_path), ["myserver"])
assert wrote is False

def test_multiple_servers_partial_env(self, tmp_path, monkeypatch):
monkeypatch.setenv("MCP_SERVER_SERVER_A_URL", "https://a.example/mcp")
monkeypatch.delenv("MCP_SERVER_SERVER_B_URL", raising=False)
wrote = configure_profile_mcp(str(tmp_path), ["server-a", "server-b"])
assert wrote is True
config = _read_mcp(str(tmp_path))
assert "server-a" in config["mcpServers"]
assert "server-b" not in config["mcpServers"]

def test_merges_with_existing_mcp_config(self, tmp_path, monkeypatch):
monkeypatch.setenv("MCP_SERVER_NEW_MCP_URL", "https://new.example/mcp")
existing = {"mcpServers": {"existing-server": {"type": "http", "url": "https://existing.example"}}}
(tmp_path / ".mcp.json").write_text(json.dumps(existing))
configure_profile_mcp(str(tmp_path), ["new-mcp"])
config = _read_mcp(str(tmp_path))
assert "existing-server" in config["mcpServers"]
assert "new-mcp" in config["mcpServers"]

def test_coexists_with_channel_mcp(self, tmp_path, monkeypatch):
monkeypatch.setenv("MCP_SERVER_ESLINT_MCP_URL", "https://eslint.example/mcp")
configure_channel_mcp(str(tmp_path), "linear")
configure_profile_mcp(str(tmp_path), ["eslint-mcp"])
config = _read_mcp(str(tmp_path))
assert LINEAR_MCP_SERVER_KEY in config["mcpServers"]
assert "eslint-mcp" in config["mcpServers"]
19 changes: 19 additions & 0 deletions agent/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,22 @@ def test_auto_generated_task_id(self):
)
assert config.task_id
assert len(config.task_id) == 12

def test_tool_profile_defaults_to_empty(self):
config = build_config(
repo_url="owner/repo",
task_description="fix bug",
github_token="ghp_test",
aws_region="us-east-1",
)
assert config.tool_profile == ""

def test_tool_profile_passed_through(self):
config = build_config(
repo_url="owner/repo",
task_description="fix bug",
github_token="ghp_test",
aws_region="us-east-1",
tool_profile="frontend",
)
assert config.tool_profile == "frontend"
17 changes: 17 additions & 0 deletions agent/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,23 @@ def test_trace_false_allows_empty_user_id(self):
assert config.trace is False
assert config.user_id == ""

def test_tool_profile_defaults_to_empty_string(self):
config = TaskConfig(
repo_url="owner/repo",
github_token="ghp_test",
aws_region="us-east-1",
)
assert config.tool_profile == ""

def test_tool_profile_accepts_valid_name(self):
config = TaskConfig(
repo_url="owner/repo",
github_token="ghp_test",
aws_region="us-east-1",
tool_profile="frontend",
)
assert config.tool_profile == "frontend"


class TestRepoSetup:
def test_construction(self):
Expand Down
Loading
Loading