diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..692bfa7 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +SERVER_NAME=MCP Terragon Bridge +ENVIRONMENT=development + +# Base URL to your Terragon/OpenTerra server (no trailing slash) +TERRAGON_BASE_URL=http://localhost:8001 + +# Provider + API key that Terragon/OpenTerra should use for the agent +TERRAGON_PROVIDER=anthropic +TERRAGON_API_KEY=sk-your-key +TERRAGON_MODEL=claude-3-7-sonnet-2025-02-19 +TERRAGON_CUSTOM_MODEL= + +# Optional: default workspace directory for agents +TERRAGON_WORKSPACE_DIR= + +# Optional: shared secret required by POST /poke +TERRAGON_POKE_SECRET=super-secret + diff --git a/README.md b/README.md index ac57b53..5aa5a74 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,158 @@ -# MCP Server Template +# MCP Terragon Bridge -A minimal [FastMCP](https://github.com/jlowin/fastmcp) server template for Render deployment with streamable HTTP transport. +Minimal [FastMCP](https://github.com/jlowin/fastmcp) server template extended with a `POST /poke` endpoint that can spin up Terragon/OpenTerra agents. Designed for easy local testing and one‑click Render deployment. [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/InteractionCo/mcp-server-template) -## Local Development +— This repo is based on the upstream MCP template and adds a Terragon integration layer. — -### Setup +## What You Get -Fork the repo, then run: +- MCP over streamable HTTP at `/mcp` +- Health check at `/healthz` +- `GET /poke` to verify configuration +- `POST /poke` to instruct Terragon/OpenTerra to execute a task (spawn an agent) + +## Quick Start (Local) + +1) Create and activate an environment ```bash git clone cd mcp-server-template -conda create -n mcp-server python=3.13 -conda activate mcp-server +uv venv || python -m venv .venv +source .venv/bin/activate pip install -r requirements.txt ``` -### Test +2) Set Terragon/OpenTerra config + +At minimum, set a provider and API key that your Terragon/OpenTerra accepts. If you’re using the open‑source OpenTerra server (recommended for quick testing), it exposes `POST /api/agent/execute` by default. + +```bash +export TERRAGON_BASE_URL="http://localhost:8001" # or your hosted Terragon/OpenTerra URL +export TERRAGON_PROVIDER="anthropic" # e.g. anthropic | openrouter | moonshot +export TERRAGON_API_KEY="sk-your-key" # key for the provider selected above +export TERRAGON_MODEL="claude-3-7-sonnet-2025-02-19" # optional, can be overridden per request +# optional hardening for /poke +export TERRAGON_POKE_SECRET="super-secret" # require X-Poke-Secret header +``` + +3) Run the server ```bash python src/server.py -# then in another terminal run: +``` + +4) Test MCP via Inspector + +```bash npx @modelcontextprotocol/inspector ``` +Open http://localhost:3000 and connect to `http://localhost:8000/mcp` using “Streamable HTTP” transport (NOTE the `/mcp`). + +5) Try the Poke endpoint -Open http://localhost:3000 and connect to `http://localhost:8000/mcp` using "Streamable HTTP" transport (NOTE THE `/mcp`!). +```bash +curl -s http://localhost:8000/poke | jq . + +curl -s -X POST http://localhost:8000/poke \ + -H "Content-Type: application/json" \ + -H "X-Poke-Secret: super-secret" \ + -d '{ + "instruction": "Create a simple REST API endpoint using FastAPI", + "workspaceDir": "/tmp/agent-work", + "settings": { + "provider": "anthropic", + "apiKey": "sk-your-key", + "model": "claude-3-7-sonnet-2025-02-19" + } + }' | jq . +``` -## Deployment +If everything is configured, this will call your Terragon/OpenTerra at `TERRAGON_BASE_URL/api/agent/execute` and return its response. -### Option 1: One-Click Deploy -Click the "Deploy to Render" button above. +## Using OpenTerra Locally (Optional) -### Option 2: Manual Deployment +If you don’t have access to Terragon, you can run the open‑source equivalent (OpenTerra): + +```bash +git clone https://github.com/KaiStephens/OpenTerra +cd OpenTerra +pip install -r requirements.txt +python web_ui.py # starts a FastAPI server with /api/agent/execute +``` + +Then set `TERRAGON_BASE_URL=http://localhost:8001` (or whatever port OpenTerra uses) and use the `POST /poke` example above. + +## Deployment (Render) + +### One‑Click Deploy +Click the “Deploy to Render” button above. + +### Manual 1. Fork this repository 2. Connect your GitHub account to Render 3. Create a new Web Service on Render 4. Connect your forked repository -5. Render will automatically detect the `render.yaml` configuration +5. Add environment variables (`TERRAGON_BASE_URL`, `TERRAGON_PROVIDER`, `TERRAGON_API_KEY`, etc.) +6. Render will apply `render.yaml` + +Your MCP endpoint will be available at `https://your-service.onrender.com/mcp` and Poke at `https://your-service.onrender.com/poke`. + +## Configuration + +Environment variables used by the integration: + +- `SERVER_NAME` – Optional display name for this server +- `TERRAGON_BASE_URL` – Base URL to your Terragon/OpenTerra (e.g. `https://terragon.example.com`) +- `TERRAGON_PROVIDER` – Provider id expected by Terragon/OpenTerra (e.g. `anthropic`, `openrouter`) +- `TERRAGON_API_KEY` – API key that Terragon/OpenTerra will pass to the provider +- `TERRAGON_MODEL` – Default model to use if not overridden in the request +- `TERRAGON_CUSTOM_MODEL` – Optional custom model identifier +- `TERRAGON_WORKSPACE_DIR` – Optional default workspace directory for agents +- `TERRAGON_POKE_SECRET` – If set, `POST /poke` requires header `X-Poke-Secret` to match + +You can override these per request by including a `settings` object (supports `baseUrl`, `provider`, `apiKey`, `model`, `customModel`) in the `POST /poke` body. -Your server will be available at `https://your-service-name.onrender.com/mcp` (NOTE THE `/mcp`!) +## API: Poke -## Customization +- `GET /poke` → returns `{ ok: true, message, config }` with API key redacted +- `POST /poke` → spins up a Terragon/OpenTerra agent via `POST {base}/api/agent/execute` -Add more tools by decorating functions with `@mcp.tool`: +Request body: + +```json +{ + "instruction": "", + "workspaceDir": "", + "settings": { + "provider": "anthropic", + "apiKey": "sk-...", + "model": "claude-3-7-sonnet-2025-02-19", + "customModel": "optional-custom" + } +} +``` + +Response: pass‑through of Terragon/OpenTerra execute payload wrapped in `{ ok, result }`, or `{ ok: false, error }` on errors. + +## Customize MCP Tools + +Add more tools by decorating functions with `@mcp.tool` in `src/server.py`. ```python @mcp.tool def calculate(x: float, y: float, operation: str) -> float: - """Perform basic arithmetic operations.""" if operation == "add": return x + y elif operation == "multiply": return x * y - # ... + raise ValueError("unsupported operation") +``` + +## Notes + +- The integration assumes a Terragon/OpenTerra‑compatible endpoint at `/api/agent/execute`. +- If your Terragon differs, set `TERRAGON_BASE_URL` accordingly and adjust request mapping in `src/server.py` as needed. ``` diff --git a/examples.http b/examples.http new file mode 100644 index 0000000..39ae77b --- /dev/null +++ b/examples.http @@ -0,0 +1,21 @@ +### Health check +GET http://localhost:8000/healthz + +### Poke config +GET http://localhost:8000/poke + +### Spawn agent via Poke (replace values) +POST http://localhost:8000/poke +Content-Type: application/json +X-Poke-Secret: super-secret + +{ + "instruction": "Create a README explaining how to run tests in this project", + "workspaceDir": "/tmp/agent-work", + "settings": { + "provider": "anthropic", + "apiKey": "sk-replace", + "model": "claude-3-7-sonnet-2025-02-19" + } +} + diff --git a/render.yaml b/render.yaml index 6d2f5a1..5e65b5f 100644 --- a/render.yaml +++ b/render.yaml @@ -9,3 +9,15 @@ services: envVars: - key: ENVIRONMENT value: production + - key: SERVER_NAME + value: MCP Terragon Bridge + - key: TERRAGON_BASE_URL + sync: false + - key: TERRAGON_PROVIDER + value: anthropic + - key: TERRAGON_MODEL + value: claude-3-7-sonnet-2025-02-19 + - key: TERRAGON_API_KEY + sync: false + - key: TERRAGON_POKE_SECRET + sync: false diff --git a/requirements.txt b/requirements.txt index acf64e9..fe279df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ fastmcp>=2.12.0 uvicorn>=0.35.0 +httpx>=0.27.0 diff --git a/src/server.py b/src/server.py index 770ef59..5ba66b9 100644 --- a/src/server.py +++ b/src/server.py @@ -1,30 +1,190 @@ #!/usr/bin/env python3 import os +import json +import uuid +from typing import Any, Dict, Optional + from fastmcp import FastMCP +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +import httpx + + +SERVER_NAME = os.environ.get("SERVER_NAME", "MCP Terragon Bridge") +mcp = FastMCP(SERVER_NAME) + + +def _env(name: str, default: Optional[str] = None) -> Optional[str]: + return os.environ.get(name, default) + + +def terragon_settings_from_env() -> Dict[str, Optional[str]]: + return { + "base_url": _env("TERRAGON_BASE_URL", _env("OPENTERRA_BASE_URL", "http://localhost:8001")), + "provider": _env("TERRAGON_PROVIDER", "anthropic"), + "api_key": _env("TERRAGON_API_KEY"), + "model": _env("TERRAGON_MODEL", "claude-3-7-sonnet-2025-02-19"), + "custom_model": _env("TERRAGON_CUSTOM_MODEL", ""), + "workspace_dir": _env("TERRAGON_WORKSPACE_DIR"), + "poke_secret": _env("TERRAGON_POKE_SECRET"), + } + + +async def _call_terragon_execute( + instruction: str, + *, + base_url: str, + provider: str, + api_key: str, + model: Optional[str] = None, + custom_model: Optional[str] = None, + workspace_dir: Optional[str] = None, + timeout: float = 60.0, +) -> Dict[str, Any]: + """ + Call Terragon/OpenTerra's execute endpoint to spin up an agent for a task. + + Expects a compatible API at `{base_url}/api/agent/execute` like OpenTerra. + """ + payload: Dict[str, Any] = { + "taskId": str(uuid.uuid4()), + "instruction": instruction, + "settings": { + "provider": provider, + "apiKey": api_key, + }, + } + if model: + payload["settings"]["model"] = model + if custom_model: + payload["settings"]["customModel"] = custom_model + if workspace_dir: + payload["workspaceDir"] = workspace_dir + + url = base_url.rstrip("/") + "/api/agent/execute" + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(url, json=payload) + resp.raise_for_status() + return resp.json() -mcp = FastMCP("Sample MCP Server") @mcp.tool(description="Greet a user by name with a welcome message from the MCP server") def greet(name: str) -> str: - return f"Hello, {name}! Welcome to our sample MCP server running on Heroku!" + return f"Hello, {name}! Welcome to {SERVER_NAME}!" + -@mcp.tool(description="Get information about the MCP server including name, version, environment, and Python version") +@mcp.tool( + description="Get information about this server including name, version, environment, and Python version", +) def get_server_info() -> dict: return { - "server_name": "Sample MCP Server", - "version": "1.0.0", + "server_name": SERVER_NAME, + "version": "1.1.0", "environment": os.environ.get("ENVIRONMENT", "development"), - "python_version": os.sys.version.split()[0] + "python_version": os.sys.version.split()[0], + "poke_enabled": True, } + +@mcp.custom_route("/healthz", methods=["GET"], name="health") +async def health(_: Request) -> Response: + return JSONResponse({"ok": True, "service": SERVER_NAME}) + + +@mcp.custom_route("/poke", methods=["GET"], name="poke_get") +async def poke_get(_: Request) -> Response: + """Simple GET to verify the endpoint is available.""" + cfg = terragon_settings_from_env() + redacted_cfg = {**cfg, "api_key": "***" if cfg.get("api_key") else None, "poke_secret": "***" if cfg.get("poke_secret") else None} + return JSONResponse({"ok": True, "message": "Poke ready", "config": redacted_cfg}) + + +@mcp.custom_route("/poke", methods=["POST"], name="poke_post") +async def poke_post(request: Request) -> Response: + """ + Spin up a Terragon/OpenTerra agent to perform a task. + + Request body JSON: + { + "instruction": "string (required)", + "workspaceDir": "string (optional)", + "settings": { # optional overrides + "provider": "anthropic|openrouter|moonshot|...", + "apiKey": "...", + "model": "...", + "customModel": "..." + } + } + Provide shared secret via header `X-Poke-Secret` if TERRAGON_POKE_SECRET is set. + """ + cfg = terragon_settings_from_env() + + # Optional shared-secret auth + expected_secret = cfg.get("poke_secret") + if expected_secret: + provided = request.headers.get("X-Poke-Secret") or (request.query_params.get("secret") if request.query_params else None) + if not provided or provided != expected_secret: + return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) + + try: + body = await request.json() + except json.JSONDecodeError: + return JSONResponse({"ok": False, "error": "Invalid JSON"}, status_code=400) + + if not isinstance(body, dict): + return JSONResponse({"ok": False, "error": "Body must be an object"}, status_code=400) + + instruction = body.get("instruction") + if not instruction or not isinstance(instruction, str): + return JSONResponse({"ok": False, "error": "'instruction' is required"}, status_code=400) + + overrides: Dict[str, Any] = body.get("settings") or {} + workspace_dir = body.get("workspaceDir") or overrides.get("workspaceDir") or cfg.get("workspace_dir") + + base_url = overrides.get("baseUrl") or cfg["base_url"] + provider = overrides.get("provider") or cfg.get("provider") + api_key = overrides.get("apiKey") or cfg.get("api_key") + model = overrides.get("model") or cfg.get("model") + custom_model = overrides.get("customModel") or cfg.get("custom_model") + + missing = [k for k, v in {"baseUrl": base_url, "provider": provider, "apiKey": api_key}.items() if not v] + if missing: + return JSONResponse({"ok": False, "error": f"Missing required config: {', '.join(missing)}"}, status_code=400) + + try: + result = await _call_terragon_execute( + instruction, + base_url=str(base_url), + provider=str(provider), + api_key=str(api_key), + model=str(model) if model else None, + custom_model=str(custom_model) if custom_model else None, + workspace_dir=str(workspace_dir) if workspace_dir else None, + ) + # Normalize a minimal response shape + return JSONResponse({ + "ok": True, + "result": result, + }) + except httpx.HTTPStatusError as e: + return JSONResponse({ + "ok": False, + "error": f"Terragon API error: {e.response.status_code}", + "details": e.response.text, + }, status_code=502) + except Exception as e: # noqa: BLE001 + return JSONResponse({"ok": False, "error": str(e)}, status_code=500) + + if __name__ == "__main__": port = int(os.environ.get("PORT", 8000)) host = "0.0.0.0" - - print(f"Starting FastMCP server on {host}:{port}") - + + print(f"Starting FastMCP server on {host}:{port} with MCP at /mcp and custom routes /poke, /healthz") + mcp.run( transport="http", host=host, - port=port + port=port, )