diff --git a/.env.example b/.env.example index a93ba82..5f44b6c 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,10 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope # export SERVER_HOST=0.0.0.0 # export SERVER_PORT=8123 +# -- Parallel Search (Optional) -- +# No account or API key is required for Parallel Search MCP. +# export PARALLEL_MCP_URL=https://search.parallel.ai/mcp + # -- GitHub Search (Optional) -- # A fine-grained PAT with read access enables GitHub repo, code, PR, and CI search. # If coding reuses this token, it also needs permission to push branches and open PRs. diff --git a/.railway/railway.ts b/.railway/railway.ts index a71abfa..4e51e0e 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -41,6 +41,7 @@ export default defineRailway(() => { LINEAR_API_KEY: preserve(), NOTION_MCP_URL: preserve(), NOTION_MCP_AUTH_TOKEN: preserve(), + PARALLEL_MCP_URL: preserve(), }, }); diff --git a/README.md b/README.md index b9485b4..4c92a24 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,7 @@ or one directory, and none of them require touching the Channel lifecycle. | The persona and behavior | [`agent/prompts/`](./agent/prompts) | `system.py` holds the base system prompt | | The agent itself | [`agent/agent.py`](./agent/agent.py) | A LangGraph deep agent; model and reasoning effort come from the environment | | **The agent framework** | `AGENT_URL` | Point it at _any_ AG-UI-compatible agent. The runtime speaks AG-UI over HTTP and does not care what is on the other end | -| Which tools the agent has | [`agent/tools.py`](./agent/tools.py), [`agent/internal_sources.py`](./agent/internal_sources.py) | Sources register only when their credentials are present | +| Which tools the agent has | [`agent/tools.py`](./agent/tools.py), [`agent/internal_sources.py`](./agent/internal_sources.py) | Sources register only when their opt-in configuration is present | | What gets rendered in chat | [`app/components/`](./app/components), [`app/tools/`](./app/tools) | Issue cards, tables, charts, diagrams | | Mentions, commands, triggers | [`app/channel.tsx`](./app/channel.tsx) | The whole Channel surface in one file | | Which writes need approval | [`agent/write_confirmation.py`](./agent/write_confirmation.py) | The interceptor that emits `confirm_write` | @@ -320,7 +320,8 @@ agent (Python + LangGraph deepagents) ├── GitHub MCP (optional, read-only) ├── PostHog MCP (optional, read-only) ├── Linear MCP (optional) - └── Notion MCP (optional remote server) + ├── Notion MCP (optional remote server) + └── Parallel Search MCP (optional, no account or API key) ``` | You run | CopilotKit Intelligence manages | @@ -362,6 +363,7 @@ knowledge work, and renders UI from model knowledge. | `POSTHOG_PERSONAL_API_KEY` | PostHog analytics, read-only (use the **MCP Server** key preset) | | `LINEAR_API_KEY` | Hosted Linear MCP | | `NOTION_MCP_URL` + `NOTION_MCP_AUTH_TOKEN` | Remote Notion MCP; setting only one disables it | +| `PARALLEL_MCP_URL` | Live web search and URL fetching with no account or API key | | `DAYTONA_API_KEY` + a PAT or GitHub App | Coding subagent: edit in Daytona, then push and publish a draft PR after `confirm_write` | Every Linear and Notion mutation is intercepted in code before the MCP request @@ -370,6 +372,11 @@ reads and rendering do not pause. Coder push plus draft-PR create/update uses th same card. See [`setup.md`](./setup.md#github) for PAT/App selection and required GitHub permissions. +Set `PARALLEL_MCP_URL=https://search.parallel.ai/mcp` to opt into Parallel +Search MCP. Its tools are exposed as `parallel_web_search` and +`parallel_web_fetch`, so Tavily's existing `web_search` remains available when +both sources are configured. + [`setup.md`](./setup.md) documents each source, its overrides, and the full environment contract. diff --git a/agent/agent.py b/agent/agent.py index d8fd087..e6fe5eb 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -31,6 +31,7 @@ BASE_SYSTEM_PROMPT, DEFAULT_AGENT_DISPLAY_NAME, NO_WEB_SEARCH_TOOL_ADDENDUM, + PARALLEL_SEARCH_TOOL_ADDENDUM, WEB_SEARCH_TOOL_ADDENDUM, CODING_OFF_ADDENDUM, CODING_ON_ADDENDUM, @@ -157,7 +158,7 @@ def build_agent(): default="low", allowed=VALID_VERBOSITY_LEVELS, ) - has_web_search = bool(os.environ.get("TAVILY_API_KEY")) + has_tavily_search = bool(os.environ.get("TAVILY_API_KEY")) model_name = os.environ.get("OPENAI_MODEL", "gpt-5.5") llm = ChatOpenAI( model=model_name, @@ -174,22 +175,32 @@ def build_agent(): internal_tools = [ tool for tools in source_toolsets.values() for tool in tools ] + parallel_tool_names = { + tool.name for tool in source_toolsets.get("parallel", []) + } + has_parallel_search = { + "parallel_web_search", + "parallel_web_fetch", + }.issubset(parallel_tool_names) main_tools = ( [web_search, *internal_tools] - if has_web_search + if has_tavily_search else [*internal_tools] ) + search_prompt = "" + if has_tavily_search: + search_prompt += WEB_SEARCH_TOOL_ADDENDUM + if has_parallel_search: + search_prompt += PARALLEL_SEARCH_TOOL_ADDENDUM + if not search_prompt: + search_prompt = NO_WEB_SEARCH_TOOL_ADDENDUM agent_display_name = ( os.environ.get("AGENT_DISPLAY_NAME", DEFAULT_AGENT_DISPLAY_NAME).strip() or DEFAULT_AGENT_DISPLAY_NAME ) - system_prompt = build_base_system_prompt(agent_display_name) + ( - WEB_SEARCH_TOOL_ADDENDUM - if has_web_search - else NO_WEB_SEARCH_TOOL_ADDENDUM - ) - system_prompt = system_prompt + ( + system_prompt = build_base_system_prompt(agent_display_name) + search_prompt + system_prompt += ( CODING_ON_ADDENDUM if coding_on else CODING_OFF_ADDENDUM ) @@ -226,6 +237,7 @@ def build_agent(): "[AGENT] OpenTag Agent created " f"with model={model_name}, reasoning={reasoning_effort}, verbosity={verbosity}" ) + has_web_search = has_tavily_search or has_parallel_search print(f"[AGENT] web search: {'enabled' if has_web_search else 'disabled'}") print(f"[AGENT] coding: {'enabled' if coding_on else 'disabled'}") print(f"[AGENT] internal-source tools: {len(internal_tools)}") diff --git a/agent/internal_sources.py b/agent/internal_sources.py index 5c30551..1d2eb8d 100644 --- a/agent/internal_sources.py +++ b/agent/internal_sources.py @@ -1,4 +1,4 @@ -"""Optional GitHub, PostHog, Linear, and Notion MCP integrations.""" +"""Optional GitHub, PostHog, Linear, Notion, and Parallel MCP integrations.""" import asyncio import logging @@ -39,6 +39,12 @@ "url_env": "NOTION_MCP_URL", "default_url": None, }, + "parallel": { + "token_env": None, + "url_env": "PARALLEL_MCP_URL", + "default_url": None, + "tool_name_prefix": True, + }, } MCP_LOAD_TIMEOUT_SECONDS = 8.0 GITHUB_READ_TOOL_ALLOWLIST = frozenset( @@ -102,37 +108,41 @@ def _configured_connections( connections: dict[str, dict[str, Any]] = {} for name, config in MCP_SERVERS.items(): is_github = name == "github" - token = None if is_github else env.get(config.get("token_env", "")) + token_env = config.get("token_env") + token = env.get(token_env) if token_env else None configured_url = env.get(config["url_env"]) url = configured_url or config["default_url"] - if (is_github and github_provider is None) or (not is_github and not token): + if (is_github and github_provider is None) or (token_env and not token): if configured_url: logger.warning( "[TOOLS] skipping %s: %s must be set with %s", name, - config.get("token_env", "GitHub credentials"), + token_env or "GitHub credentials", config["url_env"], ) continue if not url: - logger.warning( - "[TOOLS] skipping %s: %s must be set with %s", - name, - config["url_env"], - config.get("token_env", "GitHub credentials"), - ) + if is_github or token_env: + logger.warning( + "[TOOLS] skipping %s: %s must be set with %s", + name, + config["url_env"], + token_env or "GitHub credentials", + ) continue headers = dict(config.get("headers", {})) if token: headers["Authorization"] = f"Bearer {token}" - connections[name] = { + connection: dict[str, Any] = { "transport": "streamable_http", "url": url, - "headers": headers, } + if headers: + connection["headers"] = headers if is_github: - connections[name]["auth"] = GitHubProviderAuth(github_provider) + connection["auth"] = GitHubProviderAuth(github_provider) + connections[name] = connection return connections @@ -151,9 +161,15 @@ async def _load_tools( for name, connection in connections.items(): try: confirmation = WriteConfirmationInterceptor() + client_options = ( + {"tool_name_prefix": True} + if MCP_SERVERS[name].get("tool_name_prefix") + else {} + ) client = MultiServerMCPClient( {name: connection}, tool_interceptors=[confirmation], + **client_options, ) tools = await asyncio.wait_for( client.get_tools(), diff --git a/agent/prompts/__init__.py b/agent/prompts/__init__.py index 64ac61f..a1dc32e 100644 --- a/agent/prompts/__init__.py +++ b/agent/prompts/__init__.py @@ -14,6 +14,7 @@ ) from .web_search import ( NO_WEB_SEARCH_TOOL_ADDENDUM, + PARALLEL_SEARCH_TOOL_ADDENDUM, WEB_SEARCH_TOOL_ADDENDUM, ) @@ -33,6 +34,7 @@ def build_base_system_prompt( "current_date_context", "current_date_prompt", "NO_WEB_SEARCH_TOOL_ADDENDUM", + "PARALLEL_SEARCH_TOOL_ADDENDUM", "WEB_SEARCH_TOOL_ADDENDUM", "CODING_ON_ADDENDUM", "CODING_OFF_ADDENDUM", diff --git a/agent/prompts/web_search.py b/agent/prompts/web_search.py index f2fb420..9802272 100644 --- a/agent/prompts/web_search.py +++ b/agent/prompts/web_search.py @@ -23,6 +23,18 @@ the useful sources rather than dumping raw results """ +PARALLEL_SEARCH_TOOL_ADDENDUM = """ + +Parallel live web tools are also available: +- Call parallel_web_search with a focused, non-empty objective and at least one + concise search query when current web evidence would improve the answer +- Search results include source excerpts that are usually enough to answer; + synthesize the evidence and cite the useful source URLs +- Use parallel_web_fetch for specific URLs or when search excerpts are + conflicting or clearly insufficient, not as a default follow-up to every + search +""" + NO_WEB_SEARCH_TOOL_ADDENDUM = """ You do NOT have a live web research tool available right now. Answer from your diff --git a/agent/tests/test_agent_configuration.py b/agent/tests/test_agent_configuration.py index e83747a..ef301b5 100644 --- a/agent/tests/test_agent_configuration.py +++ b/agent/tests/test_agent_configuration.py @@ -7,6 +7,7 @@ from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage from langchain_core.outputs import ChatGeneration, ChatResult +from langchain_core.tools import StructuredTool from langchain_openai import ChatOpenAI as RealChatOpenAI from pydantic import Field @@ -34,11 +35,27 @@ def with_config(self, config): return self -def build_with_captured_configuration(monkeypatch): +def build_with_captured_configuration( + monkeypatch, + *, + tavily=False, + parallel=False, + internal_tools=None, +): captured = {} monkeypatch.setenv("OPENAI_API_KEY", "sk-test") - monkeypatch.delenv("TAVILY_API_KEY", raising=False) + if tavily: + monkeypatch.setenv("TAVILY_API_KEY", "tvly-test") + else: + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + if parallel: + monkeypatch.setenv( + "PARALLEL_MCP_URL", + "https://search.parallel.ai/mcp", + ) + else: + monkeypatch.delenv("PARALLEL_MCP_URL", 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) @@ -48,7 +65,11 @@ def build_with_captured_configuration(monkeypatch): monkeypatch.delenv("GITHUB_APP_ID", raising=False) monkeypatch.delenv("GITHUB_APP_INSTALLATION_ID", raising=False) monkeypatch.delenv("GITHUB_APP_PRIVATE_KEY_BASE64", raising=False) - monkeypatch.setattr(agent_mod, "internal_source_toolsets", lambda _provider: {}) + monkeypatch.setattr( + agent_mod, + "internal_source_toolsets", + lambda _provider: {"parallel": internal_tools or []} if parallel else {}, + ) def fake_chat_openai(**kwargs): captured["model"] = kwargs @@ -86,6 +107,78 @@ def test_build_agent_accepts_valid_reasoning_and_verbosity_overrides(monkeypatch assert captured["model"]["verbosity"] == "medium" +def test_build_agent_uses_parallel_prompt_without_tavily(monkeypatch): + parallel_tools = [ + StructuredTool.from_function( + func=lambda: "results", + name="parallel_web_search", + description="Search the live web", + ), + StructuredTool.from_function( + func=lambda: "page", + name="parallel_web_fetch", + description="Fetch a web page", + ), + ] + + _, captured = build_with_captured_configuration( + monkeypatch, + parallel=True, + internal_tools=parallel_tools, + ) + + prompt = captured["agent"]["system_prompt"] + assert agent_mod.PARALLEL_SEARCH_TOOL_ADDENDUM in prompt + assert agent_mod.NO_WEB_SEARCH_TOOL_ADDENDUM not in prompt + assert captured["agent"]["tools"] == parallel_tools + + +def test_build_agent_does_not_advertise_parallel_when_discovery_fails( + monkeypatch, + capsys, +): + _, captured = build_with_captured_configuration( + monkeypatch, + parallel=True, + internal_tools=[], + ) + + prompt = captured["agent"]["system_prompt"] + assert agent_mod.PARALLEL_SEARCH_TOOL_ADDENDUM not in prompt + assert agent_mod.NO_WEB_SEARCH_TOOL_ADDENDUM in prompt + assert captured["agent"]["tools"] == [] + assert "[AGENT] web search: disabled" in capsys.readouterr().out + + +def test_build_agent_keeps_tavily_first_when_parallel_is_also_enabled( + monkeypatch, +): + parallel_tools = [ + StructuredTool.from_function( + func=lambda: "results", + name="parallel_web_search", + description="Search the live web", + ), + StructuredTool.from_function( + func=lambda: "page", + name="parallel_web_fetch", + description="Fetch a web page", + ), + ] + + _, captured = build_with_captured_configuration( + monkeypatch, + tavily=True, + parallel=True, + internal_tools=parallel_tools, + ) + + prompt = captured["agent"]["system_prompt"] + assert agent_mod.WEB_SEARCH_TOOL_ADDENDUM in prompt + assert agent_mod.PARALLEL_SEARCH_TOOL_ADDENDUM in prompt + assert captured["agent"]["tools"] == [agent_mod.web_search, *parallel_tools] + + def test_build_agent_uses_configured_display_name(monkeypatch): monkeypatch.setenv("AGENT_DISPLAY_NAME", "Kite") @@ -167,6 +260,7 @@ def _generate( monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False) monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + monkeypatch.delenv("PARALLEL_MCP_URL", raising=False) monkeypatch.delenv("DAYTONA_API_KEY", raising=False) monkeypatch.delenv("GITHUB_CODER_TOKEN", raising=False) monkeypatch.setattr(agent_mod, "ChatOpenAI", lambda **_kwargs: model) @@ -194,6 +288,7 @@ def _configure_minimal_environment(monkeypatch): monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False) monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + monkeypatch.delenv("PARALLEL_MCP_URL", raising=False) monkeypatch.delenv("DAYTONA_API_KEY", raising=False) monkeypatch.delenv("GITHUB_CODER_TOKEN", raising=False) monkeypatch.delenv("GITHUB_APP_ID", raising=False) diff --git a/agent/tests/test_health.py b/agent/tests/test_health.py index ffb962c..62ea6d7 100644 --- a/agent/tests/test_health.py +++ b/agent/tests/test_health.py @@ -1,9 +1,15 @@ from fastapi.testclient import TestClient +import pytest # Import before tests mutate environment variables. import agent as agent_mod # noqa: E402 +@pytest.fixture(autouse=True) +def clear_parallel_source(monkeypatch): + monkeypatch.delenv("PARALLEL_MCP_URL", raising=False) + + def test_health_ok(monkeypatch): monkeypatch.setenv("OPENAI_API_KEY", "sk-test") monkeypatch.delenv("TAVILY_API_KEY", raising=False) diff --git a/agent/tests/test_internal_sources.py b/agent/tests/test_internal_sources.py index b57811a..9573243 100644 --- a/agent/tests/test_internal_sources.py +++ b/agent/tests/test_internal_sources.py @@ -10,13 +10,19 @@ @pytest.fixture(autouse=True) -def clear_ambient_credentials(monkeypatch): - monkeypatch.delenv("GITHUB_PERSONAL_ACCESS_TOKEN", raising=False) - monkeypatch.delenv("GITHUB_CODER_TOKEN", raising=False) - monkeypatch.delenv("GITHUB_APP_ID", raising=False) - monkeypatch.delenv("GITHUB_APP_INSTALLATION_ID", raising=False) - monkeypatch.delenv("GITHUB_APP_PRIVATE_KEY_BASE64", raising=False) - monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False) +def clear_ambient_source_configuration(monkeypatch): + for name in ( + "GITHUB_PERSONAL_ACCESS_TOKEN", + "GITHUB_CODER_TOKEN", + "GITHUB_APP_ID", + "GITHUB_APP_INSTALLATION_ID", + "GITHUB_APP_PRIVATE_KEY_BASE64", + "POSTHOG_PERSONAL_API_KEY", + "LINEAR_API_KEY", + "NOTION_MCP_AUTH_TOKEN", + "PARALLEL_MCP_URL", + ): + monkeypatch.delenv(name, raising=False) def test_mcp_servers_are_configured_in_one_place(): @@ -46,6 +52,12 @@ def test_mcp_servers_are_configured_in_one_place(): "url_env": "NOTION_MCP_URL", "default_url": None, }, + "parallel": { + "token_env": None, + "url_env": "PARALLEL_MCP_URL", + "default_url": None, + "tool_name_prefix": True, + }, } @@ -192,6 +204,68 @@ def test_posthog_uses_hosted_read_only_mcp_with_personal_api_key(): } +def test_parallel_requires_an_explicit_url_and_sends_no_auth_headers(): + assert internal_sources._configured_connections({}) == {} + assert internal_sources._configured_connections( + {"PARALLEL_MCP_URL": "https://search.parallel.ai/mcp"} + ) == { + "parallel": { + "transport": "streamable_http", + "url": "https://search.parallel.ai/mcp", + } + } + + +def test_parallel_tools_use_the_adapter_name_prefix(monkeypatch): + clients = [] + + class FakeMCPClient: + def __init__( + self, + connections, + *, + tool_interceptors, + tool_name_prefix, + ): + self.connections = connections + self.tool_interceptors = tool_interceptors + self.tool_name_prefix = tool_name_prefix + clients.append(self) + + async def get_tools(self): + return [ + StructuredTool.from_function( + func=lambda: "results", + name="parallel_web_search", + description="Search the live web", + metadata={"readOnlyHint": True}, + ) + ] + + monkeypatch.setenv( + "PARALLEL_MCP_URL", + "https://search.parallel.ai/mcp", + ) + monkeypatch.setattr( + internal_sources, + "MultiServerMCPClient", + FakeMCPClient, + ) + + result = internal_sources.internal_source_tools() + + assert [tool.name for tool in result] == ["parallel_web_search"] + assert len(clients) == 1 + assert clients[0].connections == { + "parallel": { + "transport": "streamable_http", + "url": "https://search.parallel.ai/mcp", + } + } + assert clients[0].tool_name_prefix is True + assert "parallel_web_search" in clients[0].tool_interceptors[0]._read_only_tools + + @pytest.mark.parametrize( "env", [ diff --git a/agent/tests/test_prompts.py b/agent/tests/test_prompts.py index 5c78bb1..b6cf418 100644 --- a/agent/tests/test_prompts.py +++ b/agent/tests/test_prompts.py @@ -5,6 +5,7 @@ CODING_OFF_ADDENDUM, CODING_ON_ADDENDUM, NO_WEB_SEARCH_TOOL_ADDENDUM, + PARALLEL_SEARCH_TOOL_ADDENDUM, WEB_SEARCH_TOOL_ADDENDUM, current_date_context, ) @@ -51,6 +52,15 @@ def test_prompt_describes_direct_optional_web_search(): assert "do NOT have a live web research tool" in NO_WEB_SEARCH_TOOL_ADDENDUM +def test_prompt_describes_parallel_search_and_fetch_inputs(): + assert "parallel_web_search" in PARALLEL_SEARCH_TOOL_ADDENDUM + assert "non-empty objective" in PARALLEL_SEARCH_TOOL_ADDENDUM + assert "at least one" in PARALLEL_SEARCH_TOOL_ADDENDUM + assert "parallel_web_fetch" in PARALLEL_SEARCH_TOOL_ADDENDUM + assert "specific URLs" in PARALLEL_SEARCH_TOOL_ADDENDUM + assert "usually enough to answer" in PARALLEL_SEARCH_TOOL_ADDENDUM + + def test_current_date_context_uses_an_authoritative_utc_date(): now = datetime(2026, 8, 1, 15, 30, tzinfo=timezone.utc) diff --git a/app/railway.test.ts b/app/railway.test.ts index 02fb5f4..b59c01a 100644 --- a/app/railway.test.ts +++ b/app/railway.test.ts @@ -80,6 +80,7 @@ describe("Railway deployment graph", () => { LINEAR_API_KEY: { type: "preserve" }, NOTION_MCP_URL: { type: "preserve" }, NOTION_MCP_AUTH_TOKEN: { type: "preserve" }, + PARALLEL_MCP_URL: { type: "preserve" }, }); const runtime = resources.find(({ name }) => name === "runtime"); diff --git a/deployment/aws/README.md b/deployment/aws/README.md index 44ac2dd..625b826 100644 --- a/deployment/aws/README.md +++ b/deployment/aws/README.md @@ -115,6 +115,7 @@ These CDK context values become container environment variables: | `posthogMcpUrl` | `POSTHOG_MCP_URL` | Hosted read-only PostHog MCP | | `linearMcpUrl` | `LINEAR_MCP_URL` | Hosted Linear MCP | | `notionMcpUrl` | `NOTION_MCP_URL` | Unset | +| `parallelMcpUrl` | `PARALLEL_MCP_URL` | Unset; use `https://search.parallel.ai/mcp` to opt in | `githubAppPrivateKeySecretArn` optionally maps a separate raw Secrets Manager secret to `GITHUB_APP_PRIVATE_KEY_BASE64` on the agent container. diff --git a/deployment/aws/lib/opentag-stack.ts b/deployment/aws/lib/opentag-stack.ts index 56fc391..8106748 100644 --- a/deployment/aws/lib/opentag-stack.ts +++ b/deployment/aws/lib/opentag-stack.ts @@ -261,6 +261,10 @@ export class OpenTagStack extends cdk.Stack { "NOTION_MCP_URL", contextString(this, "notionMcpUrl", ""), ), + ...optionalEnvironment( + "PARALLEL_MCP_URL", + contextString(this, "parallelMcpUrl", ""), + ), OPENAI_MODEL: openAiModel, OPENAI_REASONING_EFFORT: openAiReasoningEffort, OPENAI_VERBOSITY: openAiVerbosity, diff --git a/deployment/aws/test/opentag-stack.test.ts b/deployment/aws/test/opentag-stack.test.ts index 62f38ae..e7e367c 100644 --- a/deployment/aws/test/opentag-stack.test.ts +++ b/deployment/aws/test/opentag-stack.test.ts @@ -154,6 +154,33 @@ test("allows supported non-secret environment overrides through context", () => }); }); +test("keeps Parallel MCP opt-in and propagates its configured URL", () => { + const defaultTemplate = Template.fromStack(stackWithContext()); + assert.doesNotMatch( + JSON.stringify(defaultTemplate.toJSON()), + /PARALLEL_MCP_URL/, + ); + + const configuredTemplate = Template.fromStack( + stackWithContext({ + parallelMcpUrl: "https://search.parallel.ai/mcp", + }), + ); + configuredTemplate.hasResourceProperties("AWS::ECS::TaskDefinition", { + ContainerDefinitions: Match.arrayWith([ + Match.objectLike({ + Environment: Match.arrayWith([ + { + Name: "PARALLEL_MCP_URL", + Value: "https://search.parallel.ai/mcp", + }, + ]), + Name: "agent", + }), + ]), + }); +}); + test("forwards both awslogs groups through the official Datadog Forwarder", () => { const template = Template.fromStack(stackWithContext()); diff --git a/setup.md b/setup.md index 7826442..95318d8 100644 --- a/setup.md +++ b/setup.md @@ -97,6 +97,7 @@ or Channel slug. | `LINEAR_MCP_URL` | No | Overrides the hosted Linear MCP URL | | `NOTION_MCP_AUTH_TOKEN` | No | Bearer token for a remote Notion MCP; requires `NOTION_MCP_URL` | | `NOTION_MCP_URL` | No | Remote Notion MCP endpoint; requires `NOTION_MCP_AUTH_TOKEN` | +| `PARALLEL_MCP_URL` | No | Enables no-account live web search and URL fetching; set to `https://search.parallel.ai/mcp` | | `CORS_ALLOW_ORIGINS` | No | Comma-separated allowed origins; defaults to `*` | | `SERVER_HOST` | No | Local bind host; defaults to `0.0.0.0` | | `SERVER_PORT` | No | Local/container port; defaults to `8123` | @@ -333,6 +334,15 @@ Notion is optional and remote-only, not a separate Railway service. Set both discovers the tools. If either value is absent OpenTag skips Notion without blocking startup. +### Parallel Search MCP + +Set `PARALLEL_MCP_URL=https://search.parallel.ai/mcp`, then restart +`pnpm agent` so OpenTag discovers `parallel_web_search` and +`parallel_web_fetch`. The remote endpoint is free and requires no account, API +key, or OAuth. The URL is the opt-in: when it is unset, OpenTag does not connect +to Parallel. Tavily's existing `web_search` remains available when both sources +are configured. + ## Railway The IaC file declares exactly: