diff --git a/.env.example b/.env.example index 41f20ef..e1ffe93 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,9 @@ export AGENT_URL=http://localhost:8123/ # AG-UI endpoint; the bundled Deep Age # export OPENAI_VERBOSITY=low # -- Internal Sources (Optional) -- +# A fine-grained PAT with read access enables GitHub code, repo, issue, and PR search. +# export GITHUB_PERSONAL_ACCESS_TOKEN=github_pat_... +# export GITHUB_MCP_URL=https://api.githubcopilot.com/mcp/readonly # Create a personal API key with PostHog's "MCP Server" preset. # The bundled connection uses CLI mode and is read-only. # export POSTHOG_PERSONAL_API_KEY=phx_... diff --git a/.railway/railway.ts b/.railway/railway.ts index 65f6b00..d79213e 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -26,6 +26,8 @@ export default defineRailway(() => { PORT: "8123", OPENAI_API_KEY: preserve(), TAVILY_API_KEY: preserve(), + GITHUB_PERSONAL_ACCESS_TOKEN: preserve(), + GITHUB_MCP_URL: preserve(), POSTHOG_PERSONAL_API_KEY: preserve(), POSTHOG_MCP_URL: preserve(), LINEAR_API_KEY: preserve(), diff --git a/README.md b/README.md index 0127074..6a6c395 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ runtime (Node + CopilotRuntime with embedded Channels) agent (Python + LangGraph deepagents) ├── OpenAI ├── Tavily (optional) + ├── GitHub MCP (optional, read-only) ├── PostHog MCP (optional, read-only) ├── Linear MCP (optional) └── Notion MCP (optional remote server) @@ -67,8 +68,9 @@ Prerequisites: Node.js 22+, pnpm, Python 3.12, and INTELLIGENCE_API_KEY=cpk-... ``` - Tavily, PostHog, Linear, and Notion are optional. Both Node and Python load - this root `.env`; Railway supplies the same values as service variables. + Tavily, GitHub, PostHog, Linear, and Notion are optional. Both Node and + Python load this root `.env`; Railway supplies the same values as service + variables. `INTELLIGENCE_API_URL` and `INTELLIGENCE_GATEWAY_WS_URL` default to the production Intelligence endpoints. `INTELLIGENCE_CHANNEL_NAME` defaults to @@ -140,6 +142,9 @@ Slack app**. Reusing it preserves the bot user, workspace installation, and - `TAVILY_API_KEY` enables live web research. Without it, OpenTag still chats, triages requests and renders UI from model knowledge. +- `GITHUB_PERSONAL_ACCESS_TOKEN` enables read-only repository, code, issue, and + pull-request search through GitHub's hosted MCP. `GITHUB_MCP_URL` can override + the endpoint; OpenTag still sends GitHub's read-only configuration header. - `POSTHOG_PERSONAL_API_KEY` enables PostHog analytics through its hosted MCP. Create the key with PostHog's **MCP Server** preset. The default connection is token-efficient and read-only; `POSTHOG_MCP_URL` can override the endpoint. @@ -165,8 +170,9 @@ The runtime reaches the agent over Railway private networking and embeds the managed `open-tag` Channel. Railway sets `INTELLIGENCE_CHANNEL_NAME=open-tag`; Intelligence owns both platform adapters. The runtime API key is preserved. OpenAI is required on `agent`; -Tavily, PostHog, Linear, and remote Notion settings are optional. Connecting both -services to `main` enables GitHub-triggered deployments after merges. +Tavily, GitHub search, PostHog, Linear, and remote Notion settings are optional. +Connecting both services to `main` enables GitHub-triggered deployments after +merges. The repository configuration does not mutate the existing production Railway project. Inventory and cutover should happen after Railway authentication. diff --git a/agent/internal_sources.py b/agent/internal_sources.py index f9a5383..1471b3f 100644 --- a/agent/internal_sources.py +++ b/agent/internal_sources.py @@ -1,4 +1,4 @@ -"""Optional PostHog, Linear, and Notion MCP integrations.""" +"""Optional GitHub, PostHog, Linear, and Notion MCP integrations.""" import asyncio import logging @@ -13,6 +13,15 @@ MCP_SERVERS = { + "github": { + "token_env": "GITHUB_PERSONAL_ACCESS_TOKEN", + "url_env": "GITHUB_MCP_URL", + "default_url": "https://api.githubcopilot.com/mcp/readonly", + "headers": { + "X-MCP-Readonly": "true", + "X-MCP-Toolsets": "repos,issues,pull_requests", + }, + }, "posthog": { "token_env": "POSTHOG_PERSONAL_API_KEY", "url_env": "POSTHOG_MCP_URL", @@ -82,10 +91,14 @@ def _configured_connections( ) continue + headers = { + "Authorization": f"Bearer {token}", + **config.get("headers", {}), + } connections[name] = { "transport": "streamable_http", "url": url, - "headers": {"Authorization": f"Bearer {token}"}, + "headers": headers, } return connections diff --git a/agent/prompts/system.py b/agent/prompts/system.py index 1616c24..b83641e 100644 --- a/agent/prompts/system.py +++ b/agent/prompts/system.py @@ -1,7 +1,7 @@ """Core OpenTag persona and workflow.""" SYSTEM_PROMPT = """You are OpenTag, your team's on-call triage assistant for fast -incident support, research, and Linear/Notion workflows. +incident support, research, GitHub search, and Linear/Notion workflows. Hard rules (ALWAYS follow): - NEVER output raw JSON, data structures, or code blocks in your messages diff --git a/agent/prompts/tools.py b/agent/prompts/tools.py index f348fca..24fd15f 100644 --- a/agent/prompts/tools.py +++ b/agent/prompts/tools.py @@ -1,7 +1,9 @@ """Guidance for internal sources and write tools.""" TOOLS_PROMPT = """- For internal or company-specific questions, prefer the team's Notion/Linear - (internal sources) first; use the web for external questions + and GitHub sources first; use the web for external questions +- Use GitHub tools to search repositories, code, issues, and pull requests. The + GitHub integration is read-only - CRITICAL: Every Linear or Notion mutation tool automatically pauses with its exact action and draft details. Call the mutation once; it runs only after the user grants approval, and otherwise no write occurs diff --git a/agent/tests/test_agent_configuration.py b/agent/tests/test_agent_configuration.py index 1a01ab7..94c2eee 100644 --- a/agent/tests/test_agent_configuration.py +++ b/agent/tests/test_agent_configuration.py @@ -39,6 +39,7 @@ def build_with_captured_configuration(monkeypatch): monkeypatch.setenv("OPENAI_API_KEY", "sk-test") monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.delenv("GITHUB_PERSONAL_ACCESS_TOKEN", raising=False) monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False) monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) @@ -146,6 +147,7 @@ def _generate( model = RecordingOpenAIModel() monkeypatch.setenv("OPENAI_API_KEY", "sk-test") monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.delenv("GITHUB_PERSONAL_ACCESS_TOKEN", raising=False) monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False) monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) @@ -170,6 +172,7 @@ def _configure_minimal_environment(monkeypatch): monkeypatch.delenv("OPENAI_REASONING_EFFORT", raising=False) monkeypatch.delenv("OPENAI_VERBOSITY", raising=False) monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.delenv("GITHUB_PERSONAL_ACCESS_TOKEN", raising=False) monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False) monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) diff --git a/agent/tests/test_health.py b/agent/tests/test_health.py index 8529298..cb39210 100644 --- a/agent/tests/test_health.py +++ b/agent/tests/test_health.py @@ -7,6 +7,7 @@ def test_health_ok(monkeypatch): monkeypatch.setenv("OPENAI_API_KEY", "sk-test") monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.delenv("GITHUB_PERSONAL_ACCESS_TOKEN", raising=False) monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False) monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) @@ -24,6 +25,7 @@ def test_health_ok(monkeypatch): def test_server_exposes_opentag_metadata(monkeypatch): monkeypatch.setenv("OPENAI_API_KEY", "sk-test") monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.delenv("GITHUB_PERSONAL_ACCESS_TOKEN", raising=False) monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False) monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) @@ -52,6 +54,7 @@ def test_local_agent_port_rejects_invalid_server_port(): def test_build_agent_without_tavily(monkeypatch, capsys): monkeypatch.setenv("OPENAI_API_KEY", "sk-test") monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.delenv("GITHUB_PERSONAL_ACCESS_TOKEN", raising=False) monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False) monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) @@ -64,6 +67,7 @@ def test_build_agent_without_tavily(monkeypatch, capsys): def test_build_agent_with_tavily(monkeypatch, capsys): monkeypatch.setenv("OPENAI_API_KEY", "sk-test") monkeypatch.setenv("TAVILY_API_KEY", "tvly-test") + monkeypatch.delenv("GITHUB_PERSONAL_ACCESS_TOKEN", raising=False) monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False) monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) diff --git a/agent/tests/test_internal_sources.py b/agent/tests/test_internal_sources.py index 6979507..debfad8 100644 --- a/agent/tests/test_internal_sources.py +++ b/agent/tests/test_internal_sources.py @@ -9,12 +9,22 @@ @pytest.fixture(autouse=True) -def clear_posthog_credentials(monkeypatch): +def clear_ambient_credentials(monkeypatch): + monkeypatch.delenv("GITHUB_PERSONAL_ACCESS_TOKEN", raising=False) monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False) def test_mcp_servers_are_configured_in_one_place(): assert internal_sources.MCP_SERVERS == { + "github": { + "token_env": "GITHUB_PERSONAL_ACCESS_TOKEN", + "url_env": "GITHUB_MCP_URL", + "default_url": "https://api.githubcopilot.com/mcp/readonly", + "headers": { + "X-MCP-Readonly": "true", + "X-MCP-Toolsets": "repos,issues,pull_requests", + }, + }, "posthog": { "token_env": "POSTHOG_PERSONAL_API_KEY", "url_env": "POSTHOG_MCP_URL", @@ -36,12 +46,46 @@ def test_mcp_servers_are_configured_in_one_place(): def test_internal_source_tools_empty_without_env(monkeypatch): + monkeypatch.delenv("GITHUB_PERSONAL_ACCESS_TOKEN", raising=False) monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False) monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) assert internal_sources.internal_source_tools() == [] +def test_github_uses_hosted_read_only_search_mcp_with_pat(): + assert internal_sources._configured_connections( + {"GITHUB_PERSONAL_ACCESS_TOKEN": "github_pat_test"} + ) == { + "github": { + "transport": "streamable_http", + "url": "https://api.githubcopilot.com/mcp/readonly", + "headers": { + "Authorization": "Bearer github_pat_test", + "X-MCP-Readonly": "true", + "X-MCP-Toolsets": "repos,issues,pull_requests", + }, + } + } + + +def test_github_url_can_be_overridden_without_disabling_read_only_mode(): + assert internal_sources._configured_connections( + { + "GITHUB_PERSONAL_ACCESS_TOKEN": "github_pat_test", + "GITHUB_MCP_URL": "https://github.example.test/mcp", + } + )["github"] == { + "transport": "streamable_http", + "url": "https://github.example.test/mcp", + "headers": { + "Authorization": "Bearer github_pat_test", + "X-MCP-Readonly": "true", + "X-MCP-Toolsets": "repos,issues,pull_requests", + }, + } + + def test_posthog_uses_hosted_read_only_mcp_with_personal_api_key(): assert internal_sources._configured_connections( {"POSTHOG_PERSONAL_API_KEY": "phx_test"} diff --git a/agent/tests/test_prompts.py b/agent/tests/test_prompts.py index e6cb007..d53d4b4 100644 --- a/agent/tests/test_prompts.py +++ b/agent/tests/test_prompts.py @@ -19,6 +19,11 @@ def test_prompt_uses_triage_first_behavior(): assert "/reports/final_report.md" not in BASE_SYSTEM_PROMPT +def test_prompt_describes_read_only_github_search(): + assert "GitHub tools to search repositories" in BASE_SYSTEM_PROMPT + assert "GitHub integration is read-only" in BASE_SYSTEM_PROMPT + + def test_prompt_describes_direct_optional_web_search(): assert "web_search(query, max_results=5)" in WEB_SEARCH_TOOL_ADDENDUM assert "source snippets" in WEB_SEARCH_TOOL_ADDENDUM diff --git a/app/railway.test.ts b/app/railway.test.ts index 8f836b3..650929d 100644 --- a/app/railway.test.ts +++ b/app/railway.test.ts @@ -68,6 +68,8 @@ describe("Railway deployment graph", () => { expect(agent?.variables).toMatchObject({ OPENAI_API_KEY: { type: "preserve" }, TAVILY_API_KEY: { type: "preserve" }, + GITHUB_PERSONAL_ACCESS_TOKEN: { type: "preserve" }, + GITHUB_MCP_URL: { type: "preserve" }, POSTHOG_PERSONAL_API_KEY: { type: "preserve" }, POSTHOG_MCP_URL: { type: "preserve" }, LINEAR_API_KEY: { type: "preserve" }, diff --git a/setup.md b/setup.md index e62f4dd..742881f 100644 --- a/setup.md +++ b/setup.md @@ -61,6 +61,8 @@ cp .env.example .env | `OPENAI_REASONING_EFFORT` | No | Defaults to `low` | | `OPENAI_VERBOSITY` | No | Defaults to `low` | | `TAVILY_API_KEY` | No | Enables live web research | +| `GITHUB_PERSONAL_ACCESS_TOKEN` | No | Enables read-only GitHub repository, code, issue, and PR search | +| `GITHUB_MCP_URL` | No | Overrides the hosted GitHub MCP URL; OpenTag still sends read-only headers | | `POSTHOG_PERSONAL_API_KEY` | No | Enables the hosted PostHog MCP in read-only CLI mode | | `POSTHOG_MCP_URL` | No | Overrides the hosted PostHog MCP URL | | `LINEAR_API_KEY` | No | Enables the hosted Linear MCP | @@ -179,6 +181,15 @@ Reads and UI rendering are never gated. Set `TAVILY_API_KEY` in the root `.env` to enable live web research. The `web_search` tool is not registered when the key is absent. +### GitHub + +Set `GITHUB_PERSONAL_ACCESS_TOKEN` in the root `.env` to enable GitHub search. +Use a fine-grained personal access token limited to the repositories and read +permissions the agent needs. OpenTag connects to GitHub's hosted MCP with only +the repository, issue, and pull-request toolsets and requests read-only mode. +Set `GITHUB_MCP_URL` only to override the hosted endpoint, then restart +`pnpm agent` so it discovers the tools. + ### PostHog Create a PostHog personal API key using the **MCP Server** preset, then set @@ -212,8 +223,8 @@ The IaC file declares exactly: `runtime.AGENT_URL` references the agent's Railway private domain and port. Production Intelligence URLs are literal configuration, the API key is preserved, and the Channel name is `open-tag`. `OPENAI_API_KEY` is required on -`agent`; Tavily, PostHog, Linear, and the paired remote Notion variables are -optional preserved settings. +`agent`; Tavily, GitHub, PostHog, Linear, and the paired remote Notion variables +are optional preserved settings. Evaluate the configuration locally without applying it: