diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..2b69ab91 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [ "**" ] + pull_request: + branches: [ "**" ] + +jobs: + lint: + name: Lint Markdown and YAML + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Markdown Lint + uses: avto-dev/markdown-lint@v1 + with: + config: .markdownlint.yaml + args: | + **/*.md + + - name: YAML Lint + uses: ibiqlik/action-yamllint@v3 + with: + file_or_dir: . + config_file: .yamllint + strict: true diff --git a/.github/workflows/cloudflare-deploy.yml b/.github/workflows/cloudflare-deploy.yml new file mode 100644 index 00000000..71e15e72 --- /dev/null +++ b/.github/workflows/cloudflare-deploy.yml @@ -0,0 +1,32 @@ +name: Deploy Cloudflare Worker (Custom MCP UI) + +on: + workflow_dispatch: + push: + branches: [ "feature/customizations" ] + paths: + - 'custom-mcp/cloudflare/**' + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install wrangler + run: npm i -g wrangler@^3.90.0 + + - name: Deploy + working-directory: agents/compose-for-agents/custom-mcp/cloudflare + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: wrangler deploy --var GATEWAY_URL:${{ secrets.MCP_GATEWAY_URL }} diff --git a/.github/workflows/compose-ci.yml b/.github/workflows/compose-ci.yml new file mode 100644 index 00000000..8d1e0c25 --- /dev/null +++ b/.github/workflows/compose-ci.yml @@ -0,0 +1,46 @@ +name: Compose CI + +on: + push: + branches: [ "**" ] + pull_request: + branches: [ "**" ] + +jobs: + compose-validate: + name: Compose config validation (custom-mcp) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Docker version + run: docker version + - name: Compose config + run: | + docker compose -f custom-mcp/compose.yaml -f custom-mcp/compose.ci.yaml config + working-directory: agents/compose-for-agents + + e2e-health: + name: E2E health check (custom-mcp) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Start stack + run: | + docker compose -f custom-mcp/compose.yaml -f custom-mcp/compose.ci.yaml up -d + working-directory: agents/compose-for-agents + - name: Wait for health + run: | + for i in $(seq 1 30); do + if curl -fsS http://localhost:8811/health; then + exit 0 + fi + sleep 2 + done + echo "Gateway health check failed" >&2 + docker compose -f custom-mcp/compose.yaml -f custom-mcp/compose.ci.yaml logs || true + exit 1 + working-directory: agents/compose-for-agents + - name: Teardown + if: always() + run: docker compose -f custom-mcp/compose.yaml -f custom-mcp/compose.ci.yaml down -v --remove-orphans + working-directory: agents/compose-for-agents diff --git a/.gitignore b/.gitignore index 20ce30f1..3e275a3a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ /.cursor/ **/init-secrets.sh **/secret.openai-api-key +**/.mcp.env +**/postgres_url diff --git a/README.md b/README.md index 9ace689b..d10770bc 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ The demos support using OpenAI models instead of running models locally with Doc | [ADK](https://github.com/google/adk-python) Sock Store Agent | Multi-Agent | qwen3 | MongoDb, Brave, Curl, | [./adk-sock-shop](./adk-sock-shop/) | [compose.yaml](./adk-sock-shop/compose.yaml) | | [Langchaingo](https://github.com/tmc/langchaingo) DuckDuckGo Search | Single Agent | gemma3 | duckduckgo | [./langchaingo](./langchaingo) | [compose.yaml](./langchaingo/compose.yaml) | | [MinionS](https://github.com/HazyResearch/minions) Cost-Efficient Local-Remote Collaboration | Local-Remote Protocol | qwen3(local), gpt-4o(remote) | | [./minions](./minions) | [docker-compose.minions.yml](https://github.com/HazyResearch/minions/blob/main/apps/minions-docker/docker-compose.minions.yml) | +| Custom MCP Gateway (this repo) | Gateway + Tools | none | duckduckgo, github-official (extensible) | [./custom-mcp](./custom-mcp) | [compose.yaml](./custom-mcp/compose.yaml) | ## License diff --git a/custom-mcp/README.md b/custom-mcp/README.md new file mode 100644 index 00000000..9c93a1b0 --- /dev/null +++ b/custom-mcp/README.md @@ -0,0 +1,48 @@ +# Custom MCP Gateway Stack + +This example wires a Docker MCP Gateway with a flexible set of MCP servers and secrets loaded from a `.mcp.env` file. + +Prerequisites +- Docker Desktop 4.43+ or Docker Engine with Compose v2.38.1+ +- Optional: Docker Model Runner if you plan to use local models + +Setup +1. cd custom-mcp +2. cp mcp.env.example .mcp.env +3. Fill in required secrets (e.g., GITHUB_TOKEN for `github-official`). + +Run +- Start the stack: + docker compose up --build + +- The gateway will listen on port 8811. A health endpoint is available at: + http://localhost:8811/health + +Modify servers +- Edit compose.yaml and add or remove `--servers=...` entries on the mcp-gateway service. +- If a server needs credentials, add them to `.mcp.env` and reference via `--secrets=...` if required. + +Included servers & secrets +- duckduckgo: No secret required (optional app name via DUCKDUCKGO_APP_NAME) +- github-official: Requires GITHUB_TOKEN in .mcp.env +- brave: Requires BRAVE_API_KEY in .mcp.env +- wikipedia-mcp: No secret required +- postgres with SQL query tool: + - Create a file named `postgres_url` in this directory containing the DSN, e.g. + postgres://user:password@host:5432/dbname + - This is mounted as a secret named `database-url` and used by the gateway. + +Notes +- The example includes a minimal curl-based `mcp-client` container that checks gateway health. +- To integrate with an agent, set its MCP server URL to the gateway endpoint, e.g. `http://mcp-gateway:8811/sse` for SSE transport. + +Agent integration patterns +- LangGraph/LangChain: set MCP endpoint env (e.g., MCP_SERVER_URL=http://mcp-gateway:8811/sse) in your agent container. +- Agno/ADK: point to MCP gateway URL (SSE or streaming) as shown in existing examples in this repo. +- UI frontends (e.g., Vercel AI SDK demo): configure your backend/agent server to call the gateway; avoid exposing gateway publicly. + +Troubleshooting +- 401/403 from servers: ensure tokens (e.g., GITHUB_TOKEN, BRAVE_API_KEY) are present in .mcp.env and mapped via --secrets if required. +- Postgres errors: verify postgres_url content is a valid DSN and the DB is reachable from the gateway container. +- Port already in use: change ports mapping under mcp-gateway, e.g., "8812:8811", and curl http://localhost:8812/health. +- CI differences: CI uses compose.ci.yaml override with only duckduckgo and wikipedia (no secrets, no Docker API socket). diff --git a/custom-mcp/Taskfile.yaml b/custom-mcp/Taskfile.yaml new file mode 100644 index 00000000..dbb2cea1 --- /dev/null +++ b/custom-mcp/Taskfile.yaml @@ -0,0 +1,27 @@ +version: "3" + +tasks: + up: + desc: Start custom MCP stack + cmds: + - docker compose up --build + dir: . + + down: + desc: Stop and remove containers + cmds: + - docker compose down -v --remove-orphans + dir: . + + build: + desc: Build images + cmds: + - docker compose build + dir: . + + clean: + desc: Clean everything + cmds: + - docker compose down -v --remove-orphans + - docker builder prune -f + dir: . diff --git a/custom-mcp/cloudflare/.gitignore b/custom-mcp/cloudflare/.gitignore new file mode 100644 index 00000000..b24e7b91 --- /dev/null +++ b/custom-mcp/cloudflare/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.dist/ diff --git a/custom-mcp/cloudflare/README.md b/custom-mcp/cloudflare/README.md new file mode 100644 index 00000000..c8235ad6 --- /dev/null +++ b/custom-mcp/cloudflare/README.md @@ -0,0 +1,23 @@ +# Cloudflare Deployment (Wrangler) + +This folder deploys a Cloudflare Worker UI that interacts with your MCP Gateway. It includes bindings for KV, R2, D1, and Durable Objects. You can later connect a Cloudflare Container service for the gateway, or reference a public gateway URL. + +Setup +1) Install Wrangler: npm i -g wrangler +2) Copy wrangler.toml and set: + - account_id (or pass via env) + - vars.GATEWAY_URL (public URL of your MCP Gateway) + - Fill resource bindings (KV namespace id, R2 bucket name, D1 database info) +3) Create resources: + - KV: wrangler kv namespace create UI_STATE + - R2: wrangler r2 bucket create + - D1: wrangler d1 create MCP_DB + - DO: first deploy will create migration v1 for SessionDurable +4) Preview: wrangler dev +5) Deploy: wrangler deploy + +Optional: Cloudflare Containers (Beta) +- If you run your MCP Gateway on Cloudflare Containers as service "mcp-gateway", add a service binding in wrangler.toml and adjust worker to call that service instead of external GATEWAY_URL. + +Security +- Do not expose your MCP Gateway publicly without authentication. Consider placing it behind Cloudflare Access and calling from the Worker using service bindings or mTLS. diff --git a/custom-mcp/cloudflare/package.json b/custom-mcp/cloudflare/package.json new file mode 100644 index 00000000..c5d37676 --- /dev/null +++ b/custom-mcp/cloudflare/package.json @@ -0,0 +1,12 @@ +{ + "name": "custom-mcp-ui-cf", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "wrangler": "^3.90.0" + } +} \ No newline at end of file diff --git a/custom-mcp/cloudflare/src/worker.ts b/custom-mcp/cloudflare/src/worker.ts new file mode 100644 index 00000000..f1cde26d --- /dev/null +++ b/custom-mcp/cloudflare/src/worker.ts @@ -0,0 +1,108 @@ +export interface Env { + GATEWAY_URL: string; + UI_STATE: KVNamespace; + ASSETS_BUCKET: R2Bucket; + MCP_DB: D1Database; + SESSION_DO: DurableObjectNamespace; + // MCP_GATEWAY: Service; // if using service binding to container +} + +export default { + fetch: async (req: Request, env: Env, ctx: ExecutionContext): Promise => { + const url = new URL(req.url); + + // Basic router + if (url.pathname === "/") { + return new Response(htmlPage(), { headers: { "content-type": "text/html; charset=utf-8" } }); + } + + if (url.pathname === "/api/health") { + try { + const resp = await fetch(`${env.GATEWAY_URL}/health`, { + headers: { "accept": "text/plain" }, + }); + return new Response(await resp.text(), { status: resp.status, headers: { "content-type": "text/plain" } }); + } catch (e: any) { + return Response.json({ error: e?.message ?? String(e) }, { status: 500 }); + } + } + + if (url.pathname === "/api/config") { + const servers = (await env.UI_STATE.get("servers")) ?? "duckduckgo,github-official,brave,wikipedia-mcp,postgres"; + return Response.json({ gateway: env.GATEWAY_URL, servers: servers.split(",") }); + } + + if (url.pathname === "/api/session") { + // Example Durable Object-backed session counter + const id = env.SESSION_DO.idFromName("global"); + const stub = env.SESSION_DO.get(id); + return await stub.fetch(req); + } + + return new Response("Not found", { status: 404 }); + } +}; + +export class SessionDurable { + state: DurableObjectState; + env: Env; + constructor(state: DurableObjectState, env: Env) { + this.state = state; + this.env = env; + } + async fetch(_req: Request) { + const key = "counter"; + let val = Number((await this.state.storage.get(key)) || 0) + 1; + await this.state.storage.put(key, val); + return Response.json({ counter: val }); + } +} + +function htmlPage(): string { + return ` + + + +Custom MCP Gateway UI (Cloudflare) + + + +

Custom MCP Gateway UI (Cloudflare)

+
+

This Worker lets you verify gateway health and view configured servers via KV.

+
+ + + +
+

Health

+
(click Check Health)
+

Config

+
(click Load Config)
+

Session

+
(click Session Counter)
+
+ +`; +} diff --git a/custom-mcp/cloudflare/wrangler.toml b/custom-mcp/cloudflare/wrangler.toml new file mode 100644 index 00000000..4d1dfbaa --- /dev/null +++ b/custom-mcp/cloudflare/wrangler.toml @@ -0,0 +1,41 @@ +name = "custom-mcp-ui" +main = "src/worker.ts" +compatibility_date = "2024-11-05" +# Uncomment and set your account ID or pass via env +# account_id = "YOUR_ACCOUNT_ID" + +[vars] +# Public base URL used by the Worker to reach your MCP Gateway +# If your gateway is publicly reachable, set e.g. https://gateway.example.com +# Otherwise, if using Cloudflare Containers service bindings, we will switch to service bindings later. +GATEWAY_URL = "https://YOUR_GATEWAY_URL" + +[durable_objects] +bindings = [ + { name = "SESSION_DO", class_name = "SessionDurable" } +] + +[[migrations]] +tag = "v1" +new_classes = ["SessionDurable"] + +[[kv_namespaces]] +binding = "UI_STATE" +id = "" +# preview_id = "" # optional + +[[r2_buckets]] +binding = "ASSETS_BUCKET" +bucket_name = "" + +[[d1_databases]] +binding = "MCP_DB" +database_name = "" +database_id = "" + +# If using service bindings to a Cloudflare Container named "mcp-gateway", +# you can uncomment the following once provisioned. +# [services] +# bindings = [ +# { binding = "MCP_GATEWAY", service = "mcp-gateway" } +# ] diff --git a/custom-mcp/compose.ci.yaml b/custom-mcp/compose.ci.yaml new file mode 100644 index 00000000..3cf75f9d --- /dev/null +++ b/custom-mcp/compose.ci.yaml @@ -0,0 +1,12 @@ +services: + mcp-gateway: + # In CI, avoid using Docker API socket and any external secrets. + # Only enable servers that require no credentials. + use_api_socket: false + ports: + - "8811:8811" + command: + - --transport=sse + - --servers=duckduckgo + - --servers=wikipedia-mcp + secrets: [] diff --git a/custom-mcp/compose.yaml b/custom-mcp/compose.yaml new file mode 100644 index 00000000..4e7cf724 --- /dev/null +++ b/custom-mcp/compose.yaml @@ -0,0 +1,58 @@ +services: + # Minimal test client to verify the MCP Gateway is reachable. + mcp-client: + image: curlimages/curl:8.11.1 + depends_on: + - mcp-gateway + command: ["/bin/sh", "-lc", "curl -sS http://mcp-gateway:8811/health || sleep 3600"] + + ui: + build: + context: ./ui + environment: + - MCP_GATEWAY_URL=http://mcp-gateway:8811 + - MCP_SERVERS=duckduckgo,github-official,brave,wikipedia-mcp,postgres + ports: + - "8088:8080" + depends_on: + - mcp-gateway + + mcp-gateway: + # Secures and launches MCP servers via the Docker API socket + image: docker/mcp-gateway:latest + use_api_socket: true + ports: + - "8811:8811" + command: + # switch between streaming or sse depending on your agent + - --transport=sse + # secrets can be provided multiple times and referenced per server + - --secrets=docker-desktop:/run/secrets/mcp_secret + # For Postgres DSN + - --secrets=/run/secrets/database-url + # Enable the servers you need. Add/remove as desired. + # Built-in servers supported by gateway include e.g. duckduckgo, github-official, postgres + - --servers=duckduckgo + - --servers=github-official + - --servers=brave + - --servers=wikipedia-mcp + # Postgres server and SQL tool (requires database-url secret file) + - --servers=postgres + - --tools=query + secrets: + - mcp_secret + - database-url + +# Example model configuration if you want to co-run with Docker Model Runner +# and point an agent to it. Not strictly required for MCP Gateway testing. +# models: +# qwen3-small: +# model: ai/qwen3:8B-Q4_0 +# context_size: 15000 + +# Mount the secrets file used by MCP servers +secrets: + mcp_secret: + file: ./.mcp.env + database-url: + file: ./postgres_url diff --git a/custom-mcp/mcp.env.example b/custom-mcp/mcp.env.example new file mode 100644 index 00000000..7a340222 --- /dev/null +++ b/custom-mcp/mcp.env.example @@ -0,0 +1,9 @@ +# Copy this file to .mcp.env and fill in the values you need +# Common MCP servers +GITHUB_TOKEN= +DUCKDUCKGO_APP_NAME=docker-compose-for-agents +BRAVE_API_KEY= + +# Postgres DSN is read from a separate secret file named "postgres_url" in this directory. +# Example content: +# postgres://user:password@host:5432/dbname diff --git a/custom-mcp/ui/Dockerfile b/custom-mcp/ui/Dockerfile new file mode 100644 index 00000000..5955ac89 --- /dev/null +++ b/custom-mcp/ui/Dockerfile @@ -0,0 +1,8 @@ +FROM node:20-alpine +WORKDIR /app +COPY package.json package-lock.json* pnpm-lock.yaml* /app/ +RUN npm install --omit=dev || true +COPY . /app +EXPOSE 8080 +ENV PORT=8080 +CMD ["npm", "start"] diff --git a/custom-mcp/ui/package.json b/custom-mcp/ui/package.json new file mode 100644 index 00000000..a3df09eb --- /dev/null +++ b/custom-mcp/ui/package.json @@ -0,0 +1,13 @@ +{ + "name": "custom-mcp-ui", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.19.2", + "node-fetch": "^3.3.2" + } +} \ No newline at end of file diff --git a/custom-mcp/ui/public/index.html b/custom-mcp/ui/public/index.html new file mode 100644 index 00000000..10a7d338 --- /dev/null +++ b/custom-mcp/ui/public/index.html @@ -0,0 +1,56 @@ + + + + + + Custom MCP Gateway UI + + + +

Custom MCP Gateway UI

+
+

+ This UI lets you verify the gateway health and see which MCP servers are configured. +

+
+ + +
+

Health

+
(click Check Health)
+

Config

+
(click Load Config)
+
+ + + + diff --git a/custom-mcp/ui/server.js b/custom-mcp/ui/server.js new file mode 100644 index 00000000..a8a4dc21 --- /dev/null +++ b/custom-mcp/ui/server.js @@ -0,0 +1,26 @@ +import express from 'express'; +import fetch from 'node-fetch'; + +const app = express(); +const PORT = process.env.PORT || 8080; +const GATEWAY_URL = process.env.MCP_GATEWAY_URL || 'http://mcp-gateway:8811'; + +app.use(express.static('public')); + +app.get('/api/health', async (req, res) => { + try { + const r = await fetch(`${GATEWAY_URL}/health`); + const text = await r.text(); + res.status(r.status).type('text/plain').send(text); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.get('/api/config', (req, res) => { + res.json({ gateway: GATEWAY_URL, servers: process.env.MCP_SERVERS?.split(',') || [] }); +}); + +app.listen(PORT, () => { + console.log(`UI listening on http://localhost:${PORT}`); +});