diff --git a/.railway/railway.ts b/.railway/railway.ts new file mode 100644 index 0000000..de623ed --- /dev/null +++ b/.railway/railway.ts @@ -0,0 +1,121 @@ +import { defineRailway, github, preserve, project, service } from "railway/iac"; + +// KiteBot on CopilotKit Intelligence — one-click Railway topology. +// Three services build from this repo; the Python agent uses rootDirectory "agent". +// Inter-service URLs use Railway reference variables (${{svc.VAR}}), resolved at +// deploy over private networking. SECRETS are declared with preserve() so applying +// never clobbers deployer-set values — set their actual values in the Railway UI +// (see README "Deploy to Railway"). +// +// Two topology invariants this file encodes: +// 1. Ports are pinned as explicit service variables (NOT left to Railway's +// auto-injected $PORT) so each service's listen port and the ${{svc.PORT}} +// its peers dial always agree. +// 2. Services reached over Railway private networking must bind :: (all +// interfaces) — private DNS (RAILWAY_PRIVATE_DOMAIN) resolves to IPv6, and +// legacy environments are IPv6-only. A service bound to 127.0.0.1/0.0.0.0 +// is unreachable by its peers. +const REPO = "CopilotKit/OpenTag"; + +export default defineRailway(() => { + // Notion MCP sidecar — streamable-HTTP MCP server. Its launcher + // (scripts/start-notion-mcp.ts) binds NOTION_MCP_PORT (default 3001) and does + // NOT read Railway's injected $PORT, so we pin NOTION_MCP_PORT here and have + // the agent dial that same variable. We also set NOTION_MCP_HOST=:: so the + // sidecar binds all interfaces (its upstream default is 127.0.0.1, which is + // unreachable across containers on the private network). + // + // Notion is an OPTIONAL research source: the agent runs fine (chat + UI + web + // research) without it and drops the Notion tools if this sidecar is + // unreachable. But if you deploy this service it REQUIRES NOTION_TOKEN and + // NOTION_MCP_AUTH_TOKEN — its launcher exits non-zero without them. To skip + // Notion entirely, remove this service from `resources` below and delete the + // agent's NOTION_MCP_URL / NOTION_MCP_AUTH_TOKEN wiring. + const notionMcp = service("notion-mcp", { + source: github(REPO), + start: "pnpm notion-mcp", + deploy: { + restartPolicyType: "ON_FAILURE", + restartPolicyMaxRetries: 5, + }, + env: { + NOTION_MCP_PORT: "3001", + NOTION_MCP_HOST: "::", + // secrets (set in Railway UI): + NOTION_TOKEN: preserve(), + NOTION_MCP_AUTH_TOKEN: preserve(), + }, + }); + + // Python deep-research agent — deepagents over AG-UI (uvicorn, /health). + // This service owns its build + deploy config here (single source of truth): + // there is deliberately NO agent/railway.toml, because Railway forbids a + // service being managed by both IaC and config-as-code at once. + const agent = service("agent", { + source: github(REPO, { rootDirectory: "agent" }), + build: { builder: "NIXPACKS" }, + deploy: { + // Bind :: so the agent is reachable over Railway private networking (see + // invariant 2 above). The Railway startCommand runs `uvicorn main:app` + // directly, so agent/main.py's own __main__ port logic does NOT run here — + // the port comes solely from this --port arg. The ${PORT:-8123} fallback + // keeps the bind valid even if PORT were unset; PORT is pinned in env below + // so this and ${{agent.PORT}} (dialed by channel) always agree. + startCommand: "uvicorn main:app --host :: --port ${PORT:-8123}", + healthcheckPath: "/health", + healthcheckTimeout: 300, + restartPolicyType: "ON_FAILURE", + restartPolicyMaxRetries: 5, + }, + env: { + // Pin PORT so uvicorn's bind and ${{agent.PORT}} (dialed by channel) agree. + PORT: "8123", + // internal research source (optional Notion), wired to the sidecar on its + // pinned NOTION_MCP_PORT over private networking. The shared bearer is + // referenced from notion-mcp so it has a single source of truth — set + // NOTION_MCP_AUTH_TOKEN once, on the notion-mcp service. + NOTION_MCP_URL: + "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.NOTION_MCP_PORT}}/mcp", + NOTION_MCP_AUTH_TOKEN: "${{notion-mcp.NOTION_MCP_AUTH_TOKEN}}", + // OPENAI_MODEL is intentionally NOT set here: the agent defaults to + // gpt-5.5 (agent/agent.py), and leaving it unmanaged means a deployer can + // override it in the Railway UI without a later `config apply` clobbering + // the change. Set OPENAI_MODEL in the agent service's Variables to change it. + // + // secrets (set in Railway UI): OPENAI_API_KEY required; others optional: + OPENAI_API_KEY: preserve(), + TAVILY_API_KEY: preserve(), + LINEAR_API_KEY: preserve(), + }, + }); + + // KiteBot channel host — runs the bot over the Intelligence Realtime Gateway. + const channel = service("channel", { + source: github(REPO), + start: "pnpm channel", + deploy: { + // Parity with agent/notion-mcp: restart the long-running channel host on + // crash instead of leaving KiteBot silently offline. + restartPolicyType: "ON_FAILURE", + restartPolicyMaxRetries: 5, + }, + env: { + // brain: points at the agent service over private networking. The agent + // pins PORT=8123, so ${{agent.PORT}} resolves to the port uvicorn binds. + AGENT_URL: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", + // INTELLIGENCE_CHANNEL_NAME is intentionally NOT set here: app/managed.ts + // already defaults it to "kitebot", and leaving it unmanaged means a + // deployer can set their own channel name in the Railway UI without a + // later `config apply` clobbering it. Set it in the channel service's + // Variables if your Intelligence channel is named something else. + // secrets (set in Railway UI): + INTELLIGENCE_GATEWAY_WS_URL: preserve(), + INTELLIGENCE_API_KEY: preserve(), + INTELLIGENCE_ORG_ID: preserve(), + INTELLIGENCE_PROJECT_ID: preserve(), + INTELLIGENCE_CHANNEL_ID: preserve(), + }, + }); + + return project("kitebot", { resources: [notionMcp, agent, channel] }); +}); diff --git a/README.md b/README.md index bfba6d3..a817c8d 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ OpenTag is a thin layer on top of a handful of CopilotKit packages. The `pnpm in | Package | When you need it | | --- | --- | | [`@copilotkit/channels-discord`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-discord) · [`-telegram`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-telegram) · [`-whatsapp`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-whatsapp) | Running on a platform other than Slack — one adapter per platform. | -| [`@copilotkit/channels-intelligence`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-intelligence) | Runs the bot over the CopilotKit Intelligence Realtime Gateway instead of holding platform tokens — see `app/managed.ts`. | +| [`@copilotkit/channels-intelligence`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-intelligence) | Runs the bot over the CopilotKit Intelligence Realtime Gateway instead of holding platform tokens — see `app/managed.ts`. **Required for the recommended `pnpm channel` (Intelligence Gateway) mode**; omit it only if you run self-hosted mode exclusively. | **1. Create a Slack app.** At [api.slack.com/apps](https://api.slack.com/apps?new_app=1) → *From a manifest* → paste [`slack-app-manifest.yaml`](./slack-app-manifest.yaml). Install it, @@ -70,7 +70,11 @@ CopilotKit Intelligence project for Intelligence mode. Step-by-step in **2. Set your secrets** in `.env` (`cp .env.example .env`): ```bash -OPENAI_API_KEY=sk-... # the agent runs on OpenAI's Responses API (required for web search) +OPENAI_API_KEY=sk-... # the TS runtime (runtime.ts) uses OpenAI's Responses API for web search; + # the Python agent/ uses Tavily instead (see "Deep research" below) + +# Optional integrations (see setup.md): +LINEAR_API_KEY=lin_api_... # optional — lets the agent file Linear tickets # Self-hosted mode: SLACK_BOT_TOKEN=xoxb-... @@ -137,7 +141,7 @@ To run it: ```bash cd agent && uv sync # requires uv: https://docs.astral.sh/uv/ pnpm agent # cd agent && uv run python main.py — serves over AG-UI on :8123 - # (port from SERVER_PORT/PORT env, default 8123) + # (port from PORT/SERVER_PORT env, default 8123) ``` Then point the bot at it instead of `runtime.ts` by setting in `.env`: @@ -152,6 +156,72 @@ With `agent/` in the mix, the local setup is now three pieces: the deep-research `:8200`) as before. `agent` and `runtime` are alternative brains for the same bot; run one or the other depending on what `AGENT_URL` targets. +## Deploy to Railway (one-click) + +The whole KiteBot stack deploys to [Railway](https://railway.com) as **three services**, all +built from this one repo and wired together automatically over Railway's private networking: + +| Service | What it is | Build | +| --- | --- | --- | +| `agent` | the Python deep-research backend (`agent/`) | nixpacks, `uvicorn`, `/health` (root dir `agent/`) | +| `notion-mcp` | the Notion MCP sidecar (`pnpm notion-mcp`) | Node | +| `channel` | the KiteBot channel host (`pnpm channel`) | Node | + +`channel` reaches `agent` via `AGENT_URL`, and `agent` reaches `notion-mcp` via `NOTION_MCP_URL` +— both wired with Railway reference variables in [`.railway/railway.ts`](./.railway/railway.ts), +so you don't set them by hand. + +**Deploy via Infrastructure-as-Code (recommended):** + +```bash +pnpm install # installs the `railway` SDK the CLI uses to evaluate .railway/railway.ts +npm i -g @railway/cli # or: brew install railway +railway login +railway init # create a new Railway project (or `railway link` to select an existing one) +railway config apply # from the repo root: provisions agent + notion-mcp + channel from .railway/railway.ts +``` + +**Set the secrets** (Railway → each service → *Variables*). The IaC declares them with +`preserve()` and never stores values — you fill them in: + +- **`agent`** — `OPENAI_API_KEY` (required); `TAVILY_API_KEY` (optional — enables web research); + `LINEAR_API_KEY` (optional). (`NOTION_MCP_AUTH_TOKEN` is **not** set here — the agent references + it from `notion-mcp`, so you set it once, below.) +- **`notion-mcp`** — `NOTION_TOKEN` and `NOTION_MCP_AUTH_TOKEN` (any strong string; the agent + reads the same value via a reference variable). **Both are required** for this service to + start — its launcher exits if either is missing. Don't want Notion? Remove the `notion-mcp` + service from `resources` in [`.railway/railway.ts`](./.railway/railway.ts) and delete the + agent's `NOTION_MCP_URL` / `NOTION_MCP_AUTH_TOKEN` lines; the agent still runs (chat, UI, and — + with `TAVILY_API_KEY` — web research). +- **`channel`** — `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`, + `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID` (from your CopilotKit Intelligence + project + channel). + +The inter-service URLs/ports are wired for you in [`.railway/railway.ts`](./.railway/railway.ts). +Two values are left unmanaged by the IaC so a UI override survives `railway config apply`: the +agent's `OPENAI_MODEL` (defaults to `gpt-5.5`) and the channel's `INTELLIGENCE_CHANNEL_NAME` +(defaults to `kitebot`). Set either in that service's *Variables* to change it — e.g. set +`INTELLIGENCE_CHANNEL_NAME` if your Intelligence channel isn't named `kitebot`. + +Applying the config creates the services and their wiring; **KiteBot goes live only once the +secrets are set and the `channel` service connects** — that's when your Intelligence dashboard +flips *Waiting for runtime → live*. + +> **Cold-start note (Notion):** the `agent` loads its Notion tools once, at startup. On a first +> cold deploy the `agent` can finish booting before `notion-mcp` is accepting connections, in +> which case Notion research is unavailable for that deployment — the agent logs a `WARNING` on +> its stderr (chat, UI, and Tavily web research still work). If KiteBot can't reach Notion after +> the first deploy, **redeploy the `agent` service** once `notion-mcp` is up. (A lazy/retrying +> MCP load would remove this race — tracked as a follow-up.) + +**"Deploy on Railway" button (optional):** to get a literal one-click button, publish this repo +as a [Railway template](https://docs.railway.com/templates/create) from your Railway account, +then drop the generated button in: + +```md +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template/) +``` + ## Don't want to host it yourself? Self-hosting means you run and scale the runtime, persistence, and inspection tooling yourself. diff --git a/agent/main.py b/agent/main.py index 884d32a..8b57c5a 100644 --- a/agent/main.py +++ b/agent/main.py @@ -23,11 +23,23 @@ version="0.1.0", ) -# Enable CORS for frontend communication -# Using "*" for demo purposes - allows any origin including localhost and Railway deployments +# Enable CORS for frontend communication. Defaults to "*" (any origin) for +# local/demo use; on Railway the agent is reached only over private networking +# by the channel service, so this is not a credential vector (allow_credentials +# is False). Set CORS_ALLOW_ORIGINS to a comma-separated allowlist to lock it +# down if the service is ever exposed publicly. +# Trailing `or ["*"]` so ANY blank CORS_ALLOW_ORIGINS — unset, empty, or a +# whitespace/comma-only value a deployer left while "resetting" — falls back to +# the permissive default rather than an empty allowlist that would block every +# origin. +_cors_origins = [ + o.strip() + for o in (os.getenv("CORS_ALLOW_ORIGINS") or "*").split(",") + if o.strip() +] or ["*"] app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=_cors_origins, allow_credentials=False, allow_methods=["*"], allow_headers=["*"], @@ -66,7 +78,7 @@ def health(): print("[SERVER] Deep Research Agent registered at /") except Exception as e: - print(f"[ERROR] Failed to build agent: {e}") + print(f"[ERROR] Failed to build agent: {e}", file=sys.stderr) raise @@ -74,16 +86,26 @@ def main(): """Run the server with uvicorn""" import uvicorn - host = os.getenv("SERVER_HOST", "0.0.0.0") - # Honor Railway's injected $PORT first, falling back to SERVER_PORT for local dev - raw_port = os.getenv("PORT") or os.getenv("SERVER_PORT", "8123") + # Local-dev default 0.0.0.0 (IPv4 all-interfaces) — accepts 127.0.0.1/ + # localhost clients on every platform. This __main__ path runs only for + # local `pnpm agent`; on Railway the startCommand binds `--host ::` for the + # IPv6 private network, so this default does not affect the deploy. (Avoid + # defaulting to :: here: on macOS/BSD a `::` socket may not accept IPv4, so + # a local client dialing 127.0.0.1 could be refused.) Override with SERVER_HOST. + host = os.getenv("SERVER_HOST") or "0.0.0.0" + # Local-dev entrypoint only: on Railway the startCommand runs `uvicorn + # main:app` directly, so this block is bypassed and the port comes from the + # startCommand's `--port ${PORT:-8123}`. Prefer PORT then SERVER_PORT here so + # `pnpm agent` matches that Railway behavior. + raw_port = os.getenv("PORT") or os.getenv("SERVER_PORT") or "8123" try: port = int(raw_port) if not (1 <= port <= 65535): raise ValueError("out of range") except ValueError: print( - f'[ERROR] Invalid PORT/SERVER_PORT: "{raw_port}" — must be an integer between 1 and 65535' + f'[ERROR] Invalid PORT/SERVER_PORT: "{raw_port}" — must be an integer between 1 and 65535', + file=sys.stderr, ) sys.exit(1) reload = os.getenv("AGENT_RELOAD", "").lower() in ("1", "true", "yes") diff --git a/agent/tools.py b/agent/tools.py index ff390e1..a1fdbd7 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -9,6 +9,7 @@ """ import os +import sys import asyncio from typing import Any from concurrent.futures import ThreadPoolExecutor @@ -229,14 +230,22 @@ async def _load_all() -> list: f"tool(s) from {name}" ) except asyncio.TimeoutError: + # This source WAS configured (its token/URL is set) but is + # unreachable — a real, silent degradation (the agent runs + # without its tools). Warn loudly on stderr so it's not confused + # with a source the operator intentionally left disabled. On + # Railway a cold-start race can cause this; redeploy once the + # sidecar is up (see README "Cold-start note"). print( - f"[TOOLS] internal_source_tools: {name} MCP timed out " - f"after 8s, skipping" + f"[TOOLS] WARNING: {name} is configured but its MCP server " + f"timed out after 8s — running WITHOUT {name} tools", + file=sys.stderr, ) except Exception as e: print( - f"[TOOLS] internal_source_tools: {name} MCP unavailable, " - f"skipping ({e})" + f"[TOOLS] WARNING: {name} is configured but its MCP server " + f"is unavailable — running WITHOUT {name} tools ({e})", + file=sys.stderr, ) return loaded @@ -245,5 +254,8 @@ async def _load_all() -> list: except Exception as e: # Belt-and-suspenders: even a failure in the event-loop plumbing # itself (not just an individual server) must not break startup. - print(f"[TOOLS] internal_source_tools: failed to load MCP tools ({e})") + print( + f"[TOOLS] WARNING: failed to load internal-source MCP tools ({e})", + file=sys.stderr, + ) return [] diff --git a/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md b/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md new file mode 100644 index 0000000..5998059 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md @@ -0,0 +1,241 @@ +# KiteBot Railway One-Click Template (Phase 3) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the KiteBot stack one-click deployable to Railway via a `.railway/railway.ts` Infrastructure-as-Code file defining 3 wired services (Python deep-research `agent`, `notion-mcp` sidecar, TS `channel` host), plus per-service build config and README deploy docs. + +**Architecture:** Railway IaC (`railway/iac` SDK) declares all 3 services from this one repo (isolated monorepo via per-service `rootDirectory`), their build/start/health, non-secret env + inter-service reference-variable wiring, and secrets via `preserve()` (deployer sets values in Railway). Applied with `railway config apply`. Secrets + the "Deploy on Railway" button + the live deploy are the deployer's steps (need `railway login`). + +**Tech Stack:** Railway IaC (`railway` npm SDK v3.5.7, `railway/iac`), TypeScript, nixpacks (agent + node services), the existing `pnpm channel` / `pnpm notion-mcp` scripts and the `agent/` uv project. + +> **Implementation note (updated after code review).** The shipped implementation diverged from the +> first-draft snippets below; the code blocks in this plan have been updated to match what landed. +> Key changes made during CR, all reflected in the authoritative [`.railway/railway.ts`](../../../.railway/railway.ts): +> - Services reached over private networking bind `::` (not `0.0.0.0`) — Railway private DNS is IPv6. +> The `notion-mcp` sidecar's launcher (`scripts/start-notion-mcp.ts`) gained a `NOTION_MCP_HOST` arg +> (default `127.0.0.1`) which the IaC sets to `::`. +> - Ports are pinned (`agent` `PORT=8123`, `notion-mcp` `NOTION_MCP_PORT=3001`) and dialed via +> `${{svc.}}` so listen/dial ports always agree. +> - **No `agent/railway.toml`** — Railway forbids a service being managed by both IaC and +> config-as-code, so the agent's build+deploy config lives entirely in the IaC (Task 2 changed). +> - `OPENAI_MODEL` is **not** managed by the IaC (the agent defaults to `gpt-5.5`), so a UI override +> survives `config apply`. `NOTION_MCP_AUTH_TOKEN` is set once on `notion-mcp` and referenced by the +> agent. `tsx` + `dotenv` + `@notionhq/notion-mcp-server` moved to `dependencies` (runtime-required). + +## Global Constraints +- Secrets are NEVER written into `.railway/railway.ts` or any committed file — declared via `preserve()` and set by the deployer in Railway. Only non-secret wiring/defaults are literal. +- Reference-variable wiring uses literal Railway `${{service.VAR}}` strings in env values (resolved by Railway at deploy) — service names: `agent`, `notion-mcp`, `channel`. +- Model default stays `gpt-5.5` (via the agent's own default, not pinned in the IaC — so it stays + overridable); channel name default `kitebot` (consistent with Phases 1–2). +- Do not disturb the Phase-1/2 gates: `pnpm check-types`, `pnpm test`, and `agent` pytest must stay green. `.railway/` is outside the root tsconfig `include`, so it won't affect `pnpm check-types`. + +--- + +## Task 1: Railway IaC definition + SDK dependency + +**Files:** +- Create: `.railway/railway.ts` +- Modify: `package.json` (add `railway` devDependency) + +**Interfaces:** +- Produces: a `.railway/railway.ts` that `railway config plan/apply` consumes; 3 services named `agent`, `notion-mcp`, `channel`. + +- [ ] **Step 1: Add the `railway` SDK devDependency (and reclassify runtime deps)** + +Run: `pnpm add -D railway@^3.5.7` +Expected: adds `railway` to `devDependencies`; `pnpm-lock.yaml` updates; resolves cleanly. +Also move `tsx`, `dotenv`, and `@notionhq/notion-mcp-server` from `devDependencies` to +`dependencies` — the `channel` and `notion-mcp` services run them at runtime (`tsx app/managed.ts`, +`import "dotenv/config"`, and the sidecar binary), so a production-only install must include them. + +- [ ] **Step 2: Create `.railway/railway.ts`** (final, as shipped) + +```ts +import { defineRailway, github, preserve, project, service } from "railway/iac"; + +// KiteBot on CopilotKit Intelligence — one-click Railway topology. See the file's +// own header for the two topology invariants (pinned ports; bind :: for private +// networking). SECRETS are declared with preserve(); non-secret wiring is literal. +const REPO = "CopilotKit/OpenTag"; + +export default defineRailway(() => { + // Notion MCP sidecar. Pin NOTION_MCP_PORT (the launcher binds it, not $PORT) and + // NOTION_MCP_HOST=:: (the upstream server defaults to 127.0.0.1, unreachable + // across containers). Optional feature; REQUIRES its two tokens if deployed. + const notionMcp = service("notion-mcp", { + source: github(REPO), + start: "pnpm notion-mcp", + deploy: { restartPolicyType: "ON_FAILURE", restartPolicyMaxRetries: 5 }, + env: { + NOTION_MCP_PORT: "3001", + NOTION_MCP_HOST: "::", + NOTION_TOKEN: preserve(), + NOTION_MCP_AUTH_TOKEN: preserve(), + }, + }); + + // Python deep-research agent. Build+deploy config lives here (single source of + // truth) — NO agent/railway.toml. Bind :: for private networking; pin PORT. + const agent = service("agent", { + source: github(REPO, { rootDirectory: "agent" }), + build: { builder: "NIXPACKS" }, + deploy: { + startCommand: "uvicorn main:app --host :: --port ${PORT:-8123}", + healthcheckPath: "/health", + healthcheckTimeout: 300, + restartPolicyType: "ON_FAILURE", + restartPolicyMaxRetries: 5, + }, + env: { + PORT: "8123", + // OPENAI_MODEL intentionally NOT set (agent defaults to gpt-5.5; stays overridable). + NOTION_MCP_URL: + "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.NOTION_MCP_PORT}}/mcp", + NOTION_MCP_AUTH_TOKEN: "${{notion-mcp.NOTION_MCP_AUTH_TOKEN}}", + OPENAI_API_KEY: preserve(), + TAVILY_API_KEY: preserve(), + LINEAR_API_KEY: preserve(), + }, + }); + + // KiteBot channel host — runs the bot over the Intelligence Realtime Gateway. + const channel = service("channel", { + source: github(REPO), + start: "pnpm channel", + deploy: { restartPolicyType: "ON_FAILURE", restartPolicyMaxRetries: 5 }, + env: { + AGENT_URL: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", + // INTELLIGENCE_CHANNEL_NAME left unmanaged (app/managed.ts defaults it to + // "kitebot") so a UI override survives config apply, like OPENAI_MODEL. + INTELLIGENCE_GATEWAY_WS_URL: preserve(), + INTELLIGENCE_API_KEY: preserve(), + INTELLIGENCE_ORG_ID: preserve(), + INTELLIGENCE_PROJECT_ID: preserve(), + INTELLIGENCE_CHANNEL_ID: preserve(), + }, + }); + + return project("kitebot", { resources: [notionMcp, agent, channel] }); +}); +``` + +- [ ] **Step 3: Type-check the IaC file against the real `railway/iac` types** + +Run: `pnpm exec tsc --noEmit --strict --module nodenext --moduleResolution nodenext --skipLibCheck .railway/railway.ts` +Expected: no errors (the `railway/iac` exports `defineRailway`, `github`, `preserve`, `project`, `service` per the SDK's `dist/iac/index.d.ts`). If `preserve` or `github` is not exported under this SDK version, check `node_modules/railway/dist/iac/index.d.ts` and adjust the import/usage to the actual export names; if composite `${{...}}` literal env strings are rejected by the env value type, keep them as plain strings (they are strings) — the type is `string`, so literals pass. + +- [ ] **Step 4: Confirm the Phase-1/2 gate is unaffected** + +Run: `pnpm check-types` +Expected: PASS (`.railway/` is not in the root tsconfig `include`, so this is unchanged from main). + +- [ ] **Step 5: Commit** + +```bash +git add .railway/railway.ts package.json pnpm-lock.yaml +git commit -m "feat(deploy): Railway IaC — agent + notion-mcp + channel services, wired" +``` + +--- + +## Task 2: Agent build/deploy config (in the IaC) + sidecar host binding + +> **Changed during CR.** The original plan created `agent/railway.toml`. That is wrong: Railway +> forbids a service being managed by both IaC and config-as-code, and `railway config plan` errors +> when it finds a `railway.toml` for an IaC-managed service. So the agent's build+deploy config +> lives entirely in `.railway/railway.ts` (Task 1's `build` + `deploy` blocks), and there is **no** +> `agent/railway.toml`. + +**Files:** +- Modify: `scripts/start-notion-mcp.ts` (add `NOTION_MCP_HOST` so the sidecar can bind `::` on Railway) + +**Interfaces:** +- Consumes: the `agent` service's `build`/`deploy` config declared in Task 1. Produces: a sidecar + launcher that binds a configurable host. + +- [ ] **Step 1: Add a `NOTION_MCP_HOST` arg to `scripts/start-notion-mcp.ts`** + +Validate it like the existing `NOTION_MCP_PORT` (it's passed to a `shell: true` spawn), default to +`127.0.0.1` (preserves local behavior = the upstream server's own default), and pass it as +`--host ` to the spawned `@notionhq/notion-mcp-server`. The IaC's `notion-mcp` service sets +`NOTION_MCP_HOST: "::"` so on Railway the sidecar is reachable over the IPv6 private network. + +- [ ] **Step 2: Confirm no `agent/railway.toml` exists** + +Run: `test ! -e agent/railway.toml && echo "no toml (correct)"` +Expected: `no toml (correct)`. + +- [ ] **Step 3: Commit** (folded into the CR fix commit) + +```bash +git add scripts/start-notion-mcp.ts +git commit -m "fix(deploy): sidecar binds NOTION_MCP_HOST (:: on Railway)" +``` + +--- + +## Task 3: README "Deploy to Railway" section + secrets checklist + +**Files:** +- Modify: `README.md` + +**Interfaces:** +- Consumes: the service names + secrets from Task 1. Produces: deployer-facing docs. + +- [ ] **Step 1: Add a "Deploy to Railway (one-click)" section to `README.md`** + +Insert a section covering, accurately to the 3 services: +- **What deploys:** 3 services from this repo — `agent` (Python deep-research, `rootDirectory: agent`), `notion-mcp` (`pnpm notion-mcp`), `channel` (`pnpm channel`); the `channel` reaches `agent` and `agent` reaches `notion-mcp` over Railway private networking (auto-wired by the IaC). +- **Deploy via IaC (recommended):** + ```bash + pnpm install # installs the `railway` SDK the CLI uses to evaluate .railway/railway.ts + npm i -g @railway/cli # or: brew install railway + railway login + railway init # create a new project (or `railway link` to select an existing one) + railway config apply # from the repo root: provisions agent + notion-mcp + channel from .railway/railway.ts + ``` +- **Set the secrets** (Railway → each service → Variables) — grouped checklist: + - `agent`: `OPENAI_API_KEY` (required), `TAVILY_API_KEY` (optional — enables web research), `LINEAR_API_KEY` (optional). (`NOTION_MCP_AUTH_TOKEN` is referenced from `notion-mcp`, not set here.) + - `notion-mcp`: `NOTION_TOKEN`, `NOTION_MCP_AUTH_TOKEN` (both required for the service to start; the agent reads the auth token via a reference variable). To skip Notion, remove this service from `resources` and the agent's `NOTION_MCP_URL`/`NOTION_MCP_AUTH_TOKEN`. + - `channel`: `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`, `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID` (from your CopilotKit Intelligence project/channel). `INTELLIGENCE_CHANNEL_NAME` defaults to `kitebot`. The agent's `OPENAI_MODEL` (default `gpt-5.5`) can be overridden in the `agent` service's Variables. +- **"Deploy on Railway" button (optional):** to get a literal one-click button, publish this repo as a Railway template (Railway dashboard → Templates → New, or `railway` template flow) — that requires your Railway account — then add the generated button: + `[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template/)` +- **Note:** applying the IaC creates the services + wiring; KiteBot goes live only once the secrets are set and the channel connects (dashboard flips *Waiting for runtime → live*). + +- [ ] **Step 2: Verify the section references only real service names + env vars** + +Run: `grep -nE "notion-mcp|INTELLIGENCE_(GATEWAY_WS_URL|API_KEY|ORG_ID|PROJECT_ID|CHANNEL_ID|CHANNEL_NAME)|OPENAI_API_KEY|NOTION_MCP_AUTH_TOKEN|railway config apply" README.md` +Expected: all present; cross-check names byte-match `.railway/railway.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs(deploy): README Deploy-to-Railway section + secrets checklist" +``` + +--- + +## Task 4: Full verification + +**Files:** none (verification only) + +- [ ] **Step 1: Phase-1/2 gates unaffected** + +Run: `pnpm check-types && pnpm test` and `cd agent && OPENAI_API_KEY=sk-dummy uv run pytest tests/ -q` +Expected: TS check-types PASS + all TS tests pass; agent tests pass. (This phase adds only deploy config + docs + a devDep; no source behavior changes.) + +- [ ] **Step 2: IaC file resolves against the SDK** + +Run: `pnpm exec tsc --noEmit --strict --module nodenext --moduleResolution nodenext --skipLibCheck .railway/railway.ts` +Expected: no errors. + +- [ ] **Step 3 (MANUAL — needs the deployer's Railway auth):** `railway login && railway link && railway config plan` validates the topology against a real Railway project, and `railway config apply` + setting the secrets deploys it live. Cannot run in this environment (unauthenticated; secrets are the deployer's). Flag for the maintainer. + +--- + +## Notes for the executor +- Secrets never get literal values in any committed file — `preserve()` only. If a reviewer sees a real key, that's a defect. +- If `railway/iac` under v3.5.7 differs from the documented API (export names, `github` options, env value types), adapt to the installed `node_modules/railway/dist/iac/index.d.ts` and note the deviation — the DSL shape here matches the Railway IaC reference but the SDK is the source of truth. +- The literal `${{svc.VAR}}` env strings are intentional (Railway reference variables); do not "resolve" them to typed refs unless the SDK requires it. +- Live deploy + the published "Deploy on Railway" button are out of scope (need the deployer's Railway login + secrets) — same live-creds gate as the Phase-2 smoke test. diff --git a/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md b/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md new file mode 100644 index 0000000..5eab32b --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md @@ -0,0 +1,116 @@ +# KiteBot Railway One-Click Template — Phase 3 + +**Date:** 2026-07-16 +**Branch:** new branch off `main` (Phases 1 & 2 merged in PR #6) +**Status:** Design — awaiting review + +## Context / north star + +Phases 1 & 2 (merged) gave OpenTag a KiteBot channel host on the Intelligence Gateway +(`app/managed.ts`) and a Python LangGraph deepagents deep-research backend (`agent/`). +Phase 3 makes the whole stack **one-click deployable to Railway** — the "one-click install" +objective. Decisions locked with the user: feature the **Python deep-research agent** as the +backend, **include the Notion MCP sidecar**, and deliver it as an **`.railway/railway.ts` +Infrastructure-as-Code** file (all services in one TypeScript definition) plus README +deploy/button steps. + +## Grounding (Railway mechanics — verified via Railway docs) + +- **Config-as-Code** (`railway.json`/`.toml`) is **per-service** (build/start/health) and its + path is absolute from repo root. +- **Infrastructure-as-Code** (`.railway/railway.ts`, `railway/iac` SDK) defines the **whole + project — every service + its build/start/health + env + inter-service wiring — in one + file**, applied with `railway config apply` (CLI, needs `railway login` + `railway init` to + create a project, or `railway link` to select an existing one). +- **Reference variables** wire services: a service reads another's private domain via + `svc.env.RAILWAY_PRIVATE_DOMAIN` (compiles to `${{svc.RAILWAY_PRIVATE_DOMAIN}}`); Railway + resolves `${{other.PORT}}`-style refs at deploy. +- **Isolated monorepo:** each service sets a `rootDirectory`; the Python `agent/` and the TS + root are independent build roots. +- A literal **"Deploy on Railway" button** requires a *published* template (created in a + Railway account) — **needs auth**, so this phase authors the repo config + button steps but + cannot publish the template or deploy live from this environment. +- Reference: the deep-agents showcase ships `railway.toml` (nixpacks + uvicorn + `/health`). + +## Target architecture — 3 services in `.railway/railway.ts` + +All three build from this repo (`source: github("CopilotKit/OpenTag")`), differing by +`rootDirectory` + start command: + +1. **`agent`** — Python deep-research service. `rootDirectory: "agent"`, nixpacks + (`build.builder: "NIXPACKS"`), `startCommand: "uvicorn main:app --host :: --port ${PORT:-8123}"` + (bind `::` for Railway's dual-stack/IPv6 private network), `healthcheckPath: "/health"`, + `healthcheckTimeout: 300`, `restartPolicyType: "ON_FAILURE"`. All build+deploy config lives + in `railway.ts`; there is **no** `agent/railway.toml` (Railway forbids a service being managed + by both IaC and config-as-code). +2. **`notion-mcp`** — Notion MCP sidecar. root repo, `start: "pnpm notion-mcp"`. The launcher + (`scripts/start-notion-mcp.ts`) binds `NOTION_MCP_PORT` (default `3001`) and does **not** read + Railway's `$PORT`, so the IaC pins `NOTION_MCP_PORT: "3001"` and the agent dials that same var. +3. **`channel`** — TS channel host. root repo, `start: "pnpm channel"`. Connects out to the + Intelligence gateway; no inbound public port needed. + +**Wiring (env in `railway.ts`, non-secret refs):** +- `channel.env.AGENT_URL = "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/"` (agent pins `PORT: "8123"`) +- `agent.env.NOTION_MCP_URL = "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.NOTION_MCP_PORT}}/mcp"` +- `agent.env.NOTION_MCP_AUTH_TOKEN = "${{notion-mcp.NOTION_MCP_AUTH_TOKEN}}"` (single source of truth — set once on `notion-mcp`) +- `OPENAI_MODEL` and `INTELLIGENCE_CHANNEL_NAME` are intentionally **not** set in `railway.ts`: the + code already defaults them (`gpt-5.5` in `agent/agent.py`, `"kitebot"` in `app/managed.ts`), and + leaving them unmanaged lets a deployer override them in the Railway UI without a later + `config apply` clobbering the change. + +**Secrets (deployer sets in the Railway UI; NOT stored in `railway.ts` — the IaC file +declares/preserves them, never contains values):** +- `agent`: `OPENAI_API_KEY` (required), `TAVILY_API_KEY` (optional), optional `LINEAR_API_KEY`. + (`NOTION_MCP_AUTH_TOKEN` is **not** deployer-set here — the agent reads it via a reference + variable from `notion-mcp`.) +- `notion-mcp`: `NOTION_TOKEN`, `NOTION_MCP_AUTH_TOKEN` (the agent references this value). +- `channel`: `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`, + `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID`. (`INTELLIGENCE_CHANNEL_NAME` is **not** a + secret and is **not** set in `railway.ts` — `app/managed.ts` defaults it to `"kitebot"`; a + deployer can override it in the Railway UI, per the Wiring note above.) + +## Artifacts + +- **`.railway/railway.ts`** — `defineRailway` returning `project("kitebot", { resources: [notionMcp, agent, channel] })` + with the three `service(...)` definitions, `rootDirectory`, build/start/health, the + reference-variable wiring above, non-secret env, and secrets via `preserve()` (so applying + doesn't clobber deployer-set values). +- **`scripts/start-notion-mcp.ts`** (modified) — a `NOTION_MCP_HOST` arg (default `127.0.0.1`, + set to `::` by the IaC) so the sidecar binds all interfaces and is reachable over Railway's + IPv6 private network. There is **no** `agent/railway.toml`: Railway forbids a service being + managed by both IaC and config-as-code, so the agent's build+deploy config lives only in + `.railway/railway.ts`. +- **`README.md` "Deploy to Railway"** — two documented paths: (a) `railway login && railway + init && railway config apply` (IaC; `railway link` instead of `init` for an existing project), + and (b) publish a Railway template from the repo → get + a "Deploy on Railway" button (steps + the button markdown, noting it needs the deployer's + Railway account). Plus the full **secrets checklist** grouped by service. +- **Dependency changes in `package.json`** — add the `railway` devDependency (so + `.railway/railway.ts` type-resolves and `railway config plan` works) and move `tsx`, `dotenv`, + and `@notionhq/notion-mcp-server` to `dependencies` (the deployed `channel`/`notion-mcp` + services run them, so a production-only install must include them). + +## Verification (definition of done) + +1. `.railway/railway.ts` type-checks against the `railway/iac` types (`tsc`/`railway config + plan --json` dry parse; the latter needs auth — mark manual). +2. The README deploy steps + secrets checklist are complete and accurate to the 3 services. +3. Phase 1/2 gates unaffected (`pnpm check-types`, `pnpm test`, agent pytest still green). + +## Out of scope / manual (needs the deployer's Railway auth + secrets) + +- **Publishing the template + the live deploy** — `railway login`, `railway config apply`, and + setting the secret values in Railway are the deployer's steps (this environment is + unauthenticated and cannot enter secrets). This is the same live-creds gate as the Phase-2 + smoke test. + +## Open questions / to verify in the plan + +1. **Composite reference-variable env in the IaC DSL** — whether `AGENT_URL`/`NOTION_MCP_URL` + are best expressed as literal `"...${{svc.VAR}}..."` strings (Railway resolves at deploy) + or via the DSL's typed `svc.env.*` refs (which may not compose into a URL string). Resolve + against the `railway/iac` reference during implementation; prefer the literal `${{...}}` + form if typed refs don't concatenate. +2. **`agent` builder** — nixpacks auto-detects the `uv` project, or an explicit Dockerfile is + needed (the showcase used nixpacks + a `startCommand`). Verify; fall back to a Dockerfile if + nixpacks can't resolve the `uv`/Python 3.12 toolchain. diff --git a/package.json b/package.json index 44c3431..de0de42 100644 --- a/package.json +++ b/package.json @@ -27,19 +27,20 @@ "@copilotkit/channels-ui": "^0.1.1", "@copilotkit/channels-whatsapp": "^0.0.2", "@copilotkit/runtime": "^1.62.3", + "@notionhq/notion-mcp-server": "^2.4.0", + "@slack/bolt": "^4.2.0", + "@slack/types": "^2.21.1", "@tanstack/ai": "^0.32.0", "@tanstack/ai-mcp": "^0.1.3", "@tanstack/ai-openai": "^0.15.2", - "@slack/bolt": "^4.2.0", - "@slack/types": "^2.21.1", + "dotenv": "^16.4.5", "playwright": "^1.49.0", + "tsx": "^4.19.2", "zod": "^3.25.76" }, "devDependencies": { - "@notionhq/notion-mcp-server": "^2.2.1", "@types/node": "^22.10.0", - "dotenv": "^16.4.5", - "tsx": "^4.19.2", + "railway": "^3.5.7", "typescript": "^5.6.3", "vitest": "^4.1.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e7f141..383baff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@copilotkit/runtime': specifier: ^1.62.3 version: 1.62.3(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76)) + '@notionhq/notion-mcp-server': + specifier: ^2.4.0 + version: 2.4.1(@cfworker/json-schema@4.1.1)(js-yaml@4.3.0) '@slack/bolt': specifier: ^4.2.0 version: 4.7.3(@types/express@5.0.6) @@ -50,25 +53,25 @@ importers: '@tanstack/ai-openai': specifier: ^0.15.2 version: 0.15.10(@tanstack/ai@0.32.0(@opentelemetry/api@1.9.1))(ws@8.21.1)(zod@3.25.76) + dotenv: + specifier: ^16.4.5 + version: 16.6.1 playwright: specifier: ^1.49.0 version: 1.61.1 + tsx: + specifier: ^4.19.2 + version: 4.23.1 zod: specifier: ^3.25.76 version: 3.25.76 devDependencies: - '@notionhq/notion-mcp-server': - specifier: ^2.2.1 - version: 2.4.1(@cfworker/json-schema@4.1.1)(js-yaml@4.3.0) '@types/node': specifier: ^22.10.0 version: 22.20.1 - dotenv: - specifier: ^16.4.5 - version: 16.6.1 - tsx: - specifier: ^4.19.2 - version: 4.23.1 + railway: + specifier: ^3.5.7 + version: 3.5.7 typescript: specifier: ^5.6.3 version: 5.9.3 @@ -1984,6 +1987,11 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + railway@3.5.7: + resolution: {integrity: sha512-2DdaAw2eSQuCtSn+/VuPNOdnbynxTVmRIQlefhc0maULZTr16Uy/5/FjumnI1lONZgflKG/WMGm9QmUdnApZFw==} + engines: {node: '>=22'} + hasBin: true + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -4538,6 +4546,12 @@ snapshots: quick-format-unescaped@4.0.4: {} + railway@3.5.7: + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2) + graphql: 16.14.2 + tsx: 4.23.1 + range-parser@1.2.1: {} range-parser@1.3.0: {} diff --git a/scripts/start-notion-mcp.ts b/scripts/start-notion-mcp.ts index 29d5260..eced358 100644 --- a/scripts/start-notion-mcp.ts +++ b/scripts/start-notion-mcp.ts @@ -1,13 +1,17 @@ /** - * Starts the official Notion MCP server as a Streamable-HTTP sidecar for - * the triage agent (see `runtime.ts`). Run with `pnpm notion-mcp`. + * Starts the official Notion MCP server as a Streamable-HTTP sidecar for the + * agent backends — the TS runtime (`runtime.ts`) and the Python deep-research + * agent (`agent/`); on Railway the Python agent is the sole consumer (see + * `.railway/railway.ts`). Run with `pnpm notion-mcp`. * - * Why a launcher instead of a raw npm script: the Notion server takes its - * Notion integration secret via the `NOTION_TOKEN` env var and its HTTP - * transport bearer via `AUTH_TOKEN` (or `--auth-token`). We keep the - * example's env in a single `.env` (`NOTION_TOKEN` + `NOTION_MCP_AUTH_TOKEN`) - * and map it here, so this works identically on Windows/macOS/Linux without - * shell-specific env interpolation. + * Why a launcher instead of a raw npm script: the two `.env` values need to be + * mapped to the two DIFFERENT things the Notion server expects — the Notion + * integration secret (`NOTION_TOKEN`) becomes the OUTBOUND `Authorization: + * Bearer` the server sends to Notion's API (passed via `OPENAPI_MCP_HEADERS`, + * see below), and `NOTION_MCP_AUTH_TOKEN` becomes the server's INBOUND HTTP + * transport bearer (`AUTH_TOKEN` / `--auth-token`) that the agent presents to + * reach this sidecar. Doing the mapping here works identically on + * Windows/macOS/Linux without shell-specific env interpolation. */ import "dotenv/config"; import { spawn } from "node:child_process"; @@ -30,14 +34,17 @@ if (!notionToken) { process.exit(1); } -// Port the sidecar listens on. Must agree with NOTION_MCP_URL in runtime.ts -// (default http://127.0.0.1:3001/mcp). Validated up front because it's -// passed as a `--port` arg to a `shell: true` spawn below — an unvalidated -// value with spaces/shell metacharacters could mangle or inject the command. -const rawPort = process.env["NOTION_MCP_PORT"]; +// Port the sidecar listens on. Must agree with NOTION_MCP_URL as dialed by +// runtime.ts (TS backend) AND by the Python agent/ backend on Railway — both +// default to http://127.0.0.1:3001/mcp. Validated up front because it's passed +// as a `--port` arg to the spawn below, which uses a shell on Windows +// (`shell: isWindows`) — an unvalidated value with spaces/shell metacharacters +// could mangle or inject the command there. An empty string (a bare +// `NOTION_MCP_PORT=` in .env) is treated as unset → default. +const rawPort = process.env["NOTION_MCP_PORT"] || undefined; if (rawPort !== undefined && !/^\d+$/.test(rawPort)) { console.error( - `[notion-mcp] NOTION_MCP_PORT must be an integer between 1 and 65535, got: ${JSON.stringify(rawPort)}`, + `[notion-mcp] NOTION_MCP_PORT must be a positive integer (1–65535), got: ${JSON.stringify(rawPort)}`, ); process.exit(1); } @@ -50,21 +57,47 @@ if (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) { } const port = String(portNumber); -// Notion's REST API requires a `Notion-Version` header. Authenticate via -// OPENAPI_MCP_HEADERS (carrying BOTH Authorization and Notion-Version) rather -// than NOTION_TOKEN: when NOTION_TOKEN is set the server builds its own auth -// header and omits the version, so the API rejects every call with -// 400 `missing_version`. We deliberately do NOT pass NOTION_TOKEN below. -const notionVersion = process.env["NOTION_VERSION"] ?? "2022-06-28"; +// Host the sidecar binds to. Defaults to loopback for local dev (matches the +// upstream server's own default). On Railway the `notion-mcp` service sets +// NOTION_MCP_HOST=:: so the sidecar listens on all interfaces and is reachable +// over Railway's (IPv6) private network — without it the server binds 127.0.0.1 +// and the agent's cross-container connection is refused. Validated because it's +// passed as a `--host` arg to the spawn below (which uses a shell on Windows, +// `shell: isWindows`), and to prevent a leading `-` being read as a flag. +const rawHost = process.env["NOTION_MCP_HOST"] || undefined; +// Must start with an alphanumeric or ":" (for IPv6 like "::") — this both keeps +// it shell-inert and prevents a leading-"-" value being consumed as a flag by +// the child CLI rather than as the host. +if (rawHost !== undefined && !/^[A-Za-z0-9:][A-Za-z0-9.:_-]*$/.test(rawHost)) { + console.error( + `[notion-mcp] NOTION_MCP_HOST must be a hostname or IP address starting ` + + `with a letter, digit, or ":" (e.g. "::", "0.0.0.0", "localhost"), got: ` + + `${JSON.stringify(rawHost)}`, + ); + process.exit(1); +} +const host = rawHost ?? "127.0.0.1"; + +// Carry the Notion secret as the outbound `Authorization: Bearer` via +// OPENAPI_MCP_HEADERS. We deliberately do NOT set `Notion-Version` here: the +// server (@notionhq/notion-mcp-server ≥ 2.4) sources the API version +// per-operation from its bundled OpenAPI spec, and a globally-configured +// `Notion-Version` header would OVERRIDE those per-operation defaults, pinning +// every call to one (soon-stale) version and breaking newer endpoints. Leaving +// it out lets each operation use the version its schema was written for. (Set +// NOTION_VERSION only if you must force a specific version for an older server.) +const forcedVersion = process.env["NOTION_VERSION"]; const openApiHeaders = JSON.stringify({ Authorization: `Bearer ${notionToken}`, - "Notion-Version": notionVersion, + ...(forcedVersion ? { "Notion-Version": forcedVersion } : {}), }); -// OPENAPI_MCP_HEADERS is the sole Notion auth source. NOTION_TOKEN must be -// absent from the child env (dotenv loaded it into process.env, so delete it -// after the spread) — if present, the server ignores OPENAPI_MCP_HEADERS and -// drops the Notion-Version header. +// OPENAPI_MCP_HEADERS is our Notion auth source. It takes precedence anyway +// (the server's parseHeadersFromEnv checks OPENAPI_MCP_HEADERS before falling +// back to NOTION_TOKEN), so deleting NOTION_TOKEN from the child env is +// defensive cleanup: it removes the unused fallback and suppresses the server's +// NOTION_TOKEN startup diagnostic (a /v1/users/me probe). dotenv loaded it into +// process.env, so delete it after the spread. const childEnv: NodeJS.ProcessEnv = { ...process.env, OPENAPI_MCP_HEADERS: openApiHeaders, @@ -72,6 +105,7 @@ const childEnv: NodeJS.ProcessEnv = { }; delete childEnv["NOTION_TOKEN"]; +const isWindows = process.platform === "win32"; const child = spawn( "npx", [ @@ -79,23 +113,39 @@ const child = spawn( "@notionhq/notion-mcp-server", "--transport", "http", + "--host", + host, "--port", port, ], { stdio: "inherit", - // shell:true so Windows resolves `npx` -> `npx.cmd`. Node refuses to - // spawn `.cmd`/`.bat` directly without a shell (CVE-2024-27980), which - // otherwise fails with `spawn EINVAL`. - shell: true, + // Windows only: shell:true so `npx` resolves to `npx.cmd`. Node refuses to + // spawn `.cmd`/`.bat` without a shell (CVE-2024-27980) → `spawn EINVAL`. On + // POSIX we spawn `npx` directly (no shell), which both removes the shell + // wrapper entirely and lets `detached` put the server in its own process + // group so we can signal the WHOLE tree on shutdown (see killTree below). + shell: isWindows, + detached: !isWindows, env: childEnv, }, ); -// code is null when the child died from a signal (SIGKILL/OOM/SIGSEGV, etc.) -// rather than a normal exit; map that to a non-zero status so a supervisor -// or healthcheck can detect the crash instead of seeing a false "success". -child.on("exit", (code) => process.exit(code ?? 1)); +// Set when WE forward a shutdown signal (below), so the exit handler can tell a +// clean, operator/platform-initiated stop from an abnormal termination. +let shuttingDown = false; +child.on("exit", (code, signal) => { + // A shutdown WE initiated (SIGINT/SIGTERM forwarded below) is clean. + if (shuttingDown) process.exit(0); + // Anything else is an unexpected termination of a long-running server — a + // FAILURE even on code 0 (e.g. the server exits after an unknown flag). Exit + // non-zero and log so Railway's ON_FAILURE restart fires and it's visible; + // preserve the child's own non-zero code when it has one. + console.error( + `[notion-mcp] sidecar exited unexpectedly (code=${code}, signal=${signal ?? "none"}) — treating as failure`, + ); + process.exit(code || 1); +}); // Without this, a failed spawn (e.g. `npx` not on PATH -> ENOENT, or EACCES) // surfaces as an uncaught 'error' event with a raw Node stack trace instead // of the clean, actionable messages this script prints for every other @@ -104,5 +154,24 @@ child.on("error", (err) => { console.error("[notion-mcp] failed to start the sidecar:", err.message); process.exit(1); }); -process.on("SIGINT", () => child.kill("SIGINT")); -process.on("SIGTERM", () => child.kill("SIGTERM")); +// Signal the whole child tree. On POSIX the child is a detached group leader, so +// process.kill(-pid) reaches npx AND the underlying node server; on Windows we +// fall back to child.kill (no POSIX process groups). Wrapped because the group +// may already be gone (ESRCH) by the time the escalation fires. +const killTree = (sig: NodeJS.Signals) => { + try { + if (!isWindows && child.pid) process.kill(-child.pid, sig); + else child.kill(sig); + } catch { + // already exited — nothing to signal + } +}; +// Forward shutdown signals, then escalate to SIGKILL if the tree hasn't exited +// within a grace window. `.unref()` so this timer never keeps us alive. +const shutdown = (sig: NodeJS.Signals) => { + shuttingDown = true; + killTree(sig); + setTimeout(() => killTree("SIGKILL"), 5000).unref(); +}; +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM"));