From 67fb42d68691b1f237c75651624f84eb9621d1fa Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Thu, 23 Apr 2026 09:37:20 -0300 Subject: [PATCH 1/7] feat(github): migrate to cloudflare workers Moves the github MCP off kubernetes-bun and onto CF Workers via `wrangler deploy`. Removes it from deploy.json / the shared `deco deploy` pipeline and adds a dedicated `deploy-github.yml` workflow. Key changes to make it isolate-safe: - Installation map + trigger state migrated from in-memory / Mesh StudioKV to a Workers KV binding (`INSTALLATIONS`) with `installation:` and `triggers:` prefixes - All module-level `process.env` reads moved into lazy closures (Workers doesn't populate `process.env` at module init) - Upstream MCP tool discovery deferred to first request (previously a top-level `await`) and runtime construction cached per isolate - Webhook + OAuth closures now pull secrets per-request Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/SECRETS.md | 26 ++++ .github/workflows/deploy-github.yml | 44 ++++++ bun.lock | 23 +-- deploy.json | 9 -- github/.gitignore | 2 + github/package.json | 15 +- github/server/lib/github-app-auth.ts | 23 ++- github/server/lib/installation-map.ts | 113 +++++++++++--- github/server/lib/mcp-proxy.ts | 52 ++++--- github/server/lib/trigger-store.ts | 54 +++++-- github/server/main.ts | 202 ++++++++++++++------------ github/server/tools/index.ts | 19 ++- github/server/types/env.ts | 31 +++- github/server/webhook.ts | 17 ++- github/tsconfig.json | 4 +- github/wrangler.toml | 21 +++ 16 files changed, 439 insertions(+), 216 deletions(-) create mode 100644 .github/workflows/deploy-github.yml create mode 100644 github/wrangler.toml diff --git a/.github/workflows/SECRETS.md b/.github/workflows/SECRETS.md index 8898542b..9ab7ce14 100644 --- a/.github/workflows/SECRETS.md +++ b/.github/workflows/SECRETS.md @@ -42,6 +42,32 @@ This document lists all secrets required to deploy MCPs via GitHub Actions. - `pages_read_engagement` - Read associated pages - `business_management` - Access business accounts +### MCP: `github` (Cloudflare Workers — `deploy-github.yml`) +Unlike the other MCPs, github deploys directly via `wrangler deploy` in +its own workflow. The GitHub Action only needs Cloudflare credentials: + +- **`CLOUDFLARE_API_TOKEN`**: Workers deploy token (create at + https://dash.cloudflare.com/profile/api-tokens with "Edit Cloudflare + Workers" template) +- **`CLOUDFLARE_ACCOUNT_ID`**: your Cloudflare account id + +Application secrets are stored directly on the worker via +`wrangler secret put` — one-time setup, not passed through Actions. +Bulk upload via `wrangler secret bulk .secrets.json` (gitignored): + +``` +cd github +bunx wrangler secret bulk .secrets.json +``` + +Required keys in `.secrets.json`: `GITHUB_APP_ID`, `GITHUB_PRIVATE_KEY`, +`GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GITHUB_WEBHOOK_SECRET`. + +Trigger state persists in the `INSTALLATIONS` Workers KV namespace +(`triggers:*` prefix), so no Mesh/Studio credentials are needed. + +Obtain the GitHub values at https://github.com/settings/apps → your app. + ## How to Add Secrets on GitHub 1. Go to your repository on GitHub diff --git a/.github/workflows/deploy-github.yml b/.github/workflows/deploy-github.yml new file mode 100644 index 00000000..e1d89082 --- /dev/null +++ b/.github/workflows/deploy-github.yml @@ -0,0 +1,44 @@ +name: Deploy GitHub MCP + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - "github/**" + - ".github/workflows/deploy-github.yml" + +jobs: + deploy: + runs-on: ubuntu-latest + defaults: + run: + working-directory: github + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache Bun dependencies + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-github-${{ hashFiles('github/bun.lock', 'github/package.json') }} + restore-keys: ${{ runner.os }}-bun-github- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Type check + run: bun run check + continue-on-error: true + + - name: Deploy to Cloudflare Workers + run: bunx wrangler deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/bun.lock b/bun.lock index e3c59719..5baef0e8 100644 --- a/bun.lock +++ b/bun.lock @@ -230,10 +230,11 @@ "zod": "^4.0.0", }, "devDependencies": { + "@cloudflare/workers-types": "^4.20251014.0", "@decocms/mcps-shared": "1.0.0", "@types/node": "^22.0.0", - "deco-cli": "^0.29.0", "typescript": "^5.7.2", + "wrangler": "^4.28.0", }, }, "github-repo-reports": { @@ -3987,8 +3988,6 @@ "github/@types/node": ["@types/node@22.19.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw=="], - "github/deco-cli": ["deco-cli@0.29.0", "", { "dependencies": { "@deco-cx/warp-node": "0.3.16", "@modelcontextprotocol/sdk": "1.26.0", "@supabase/ssr": "0.6.1", "@supabase/supabase-js": "2.50.0", "chalk": "^5.3.0", "commander": "^12.0.0", "glob": "^10.3.10", "ignore": "^7.0.5", "inquirer": "^9.2.15", "inquirer-search-checkbox": "^1.0.0", "inquirer-search-list": "^1.2.6", "jose": "^6.0.11", "json-schema-to-typescript": "^15.0.4", "object-hash": "^3.0.0", "prettier": "^3.6.2", "semver": "^7.6.0", "smol-toml": "^1.3.4", "zod": "^4.0.0" }, "bin": { "deco": "dist/cli.js", "deconfig": "dist/deconfig.js" } }, "sha512-+t37Ic/tA65e4nzOKZTHojjASTz14HVKZTLAZtG9vQdC+N6OQoLeL8GBZXXDTmm1gTg4SR+04ytWiTLX29tEwA=="], - "github/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "github-repo-reports/@decocms/runtime": ["@decocms/runtime@1.2.5", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.1.1", "@modelcontextprotocol/sdk": "1.25.2", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-0s02lfj/O7nTAc7FTmFsA+lZpUDnapjQHnRYrQXItLKrbJvjSnfoq5V8HA1Npv5HelBvsVk7QQHaW8pSN/l37w=="], @@ -4491,10 +4490,6 @@ "github/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "github/deco-cli/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="], - - "github/deco-cli/@supabase/supabase-js": ["@supabase/supabase-js@2.50.0", "", { "dependencies": { "@supabase/auth-js": "2.70.0", "@supabase/functions-js": "2.4.4", "@supabase/node-fetch": "2.6.15", "@supabase/postgrest-js": "1.19.4", "@supabase/realtime-js": "2.11.10", "@supabase/storage-js": "2.7.1" } }, "sha512-M1Gd5tPaaghYZ9OjeO1iORRqbTWFEz/cF3pPubRnMPzA+A8SiUsXXWDP+DWsASZcjEcVEcVQIAF38i5wrijYOg=="], - "google-apps-script/@decocms/runtime/@decocms/bindings": ["@decocms/bindings@1.2.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.25.3", "@tanstack/react-router": "1.139.7", "react": "^19.2.0", "zod": "^4.0.0", "zod-from-json-schema": "^0.5.2" } }, "sha512-+4/VOOVERB8UixGKmN0VkLazxeMAahbG0A9xOYTPL+MJIAM30htrLG2aHI2Dm5ASgccAD4bW5RuLqv2PDFZZPA=="], "google-apps-script/@decocms/runtime/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], @@ -4979,18 +4974,6 @@ "github-repo-reports/@decocms/runtime/@decocms/bindings/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], - "github/deco-cli/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], - - "github/deco-cli/@supabase/supabase-js/@supabase/auth-js": ["@supabase/auth-js@2.70.0", "", { "dependencies": { "@supabase/node-fetch": "^2.6.14" } }, "sha512-BaAK/tOAZFJtzF1sE3gJ2FwTjLf4ky3PSvcvLGEgEmO4BSBkwWKu8l67rLLIBZPDnCyV7Owk2uPyKHa0kj5QGg=="], - - "github/deco-cli/@supabase/supabase-js/@supabase/functions-js": ["@supabase/functions-js@2.4.4", "", { "dependencies": { "@supabase/node-fetch": "^2.6.14" } }, "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA=="], - - "github/deco-cli/@supabase/supabase-js/@supabase/postgrest-js": ["@supabase/postgrest-js@1.19.4", "", { "dependencies": { "@supabase/node-fetch": "^2.6.14" } }, "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw=="], - - "github/deco-cli/@supabase/supabase-js/@supabase/realtime-js": ["@supabase/realtime-js@2.11.10", "", { "dependencies": { "@supabase/node-fetch": "^2.6.13", "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "ws": "^8.18.2" } }, "sha512-SJKVa7EejnuyfImrbzx+HaD9i6T784khuw1zP+MBD7BmJYChegGxYigPzkKX8CK8nGuDntmeSD3fvriaH0EGZA=="], - - "github/deco-cli/@supabase/supabase-js/@supabase/storage-js": ["@supabase/storage-js@2.7.1", "", { "dependencies": { "@supabase/node-fetch": "^2.6.14" } }, "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA=="], - "google-apps-script/@decocms/runtime/@decocms/bindings/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], "google-big-query/@decocms/runtime/@decocms/bindings/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], @@ -5425,8 +5408,6 @@ "gemini-pro-vision/@decocms/runtime/@mastra/core/ai-v5/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.10", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-T1gZ76gEIwffep6MWI0QNy9jgoybUHE7TRaHB5k54K8mF91ciGFlbtCGxDYhMH3nCRergKwYFIDeFF0hJSIQHQ=="], - "github/deco-cli/@supabase/supabase-js/@supabase/realtime-js/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], - "grain/@decocms/runtime/@deco/mcp/@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "grain/@decocms/runtime/@deco/mcp/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], diff --git a/deploy.json b/deploy.json index 151aa7e9..64b6704c 100644 --- a/deploy.json +++ b/deploy.json @@ -8,15 +8,6 @@ "shared/**" ] }, - "github": { - "site": "github-mcp", - "entrypoint": "./dist/server/main.js", - "platformName": "kubernetes-bun", - "watch": [ - "github/**", - "shared/**" - ] - }, "openrouter": { "site": "openrouter", "entrypoint": "./dist/server/main.js", diff --git a/github/.gitignore b/github/.gitignore index 4f6a3bbd..a89ddb6a 100644 --- a/github/.gitignore +++ b/github/.gitignore @@ -4,4 +4,6 @@ dist .env.* !.env.example !server/.env.example +.secrets.json +.wrangler/ diff --git a/github/package.json b/github/package.json index 813bdcef..8b33cf5a 100644 --- a/github/package.json +++ b/github/package.json @@ -5,14 +5,10 @@ "private": true, "type": "module", "scripts": { - "dev": "bun run --env-file=server/.env --hot server/main.ts", - "configure": "deco configure", - "gen": "deco gen --output=shared/deco.gen.ts", + "dev": "bunx wrangler dev", "check": "tsc --noEmit", - "build:server": "NODE_ENV=production bun build server/main.ts --target=bun --outfile=dist/server/main.js", - "build": "bun run build:server", - "publish": "cat app.json | deco registry publish -w /shared/deco -y", - "dev:link": "deco link -p 3004 -- PORT=3004 bun run dev" + "build": "bunx wrangler deploy --dry-run --outdir=dist", + "deploy": "bunx wrangler deploy" }, "dependencies": { "@decocms/bindings": "^1.4.0", @@ -21,10 +17,11 @@ "zod": "^4.0.0" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20251014.0", "@decocms/mcps-shared": "1.0.0", "@types/node": "^22.0.0", - "deco-cli": "^0.29.0", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "wrangler": "^4.28.0" }, "engines": { "node": ">=22.0.0" diff --git a/github/server/lib/github-app-auth.ts b/github/server/lib/github-app-auth.ts index fdf78638..131c4728 100644 --- a/github/server/lib/github-app-auth.ts +++ b/github/server/lib/github-app-auth.ts @@ -3,13 +3,13 @@ * * Generates a JWT from GITHUB_APP_ID + GITHUB_PRIVATE_KEY, * then exchanges it for an installation access token. - * Used at startup to discover upstream MCP tools. + * + * Env vars are read lazily (per call) so this works on Cloudflare Workers + * where process.env isn't populated at module-init time. */ import crypto from "node:crypto"; -const GITHUB_APP_ID = process.env.GITHUB_APP_ID || ""; - function normalizePrivateKey(rawKey: string): string { let key = rawKey.trim(); @@ -82,10 +82,6 @@ function normalizePrivateKey(rawKey: string): string { return key; } -const GITHUB_PRIVATE_KEY = normalizePrivateKey( - process.env.GITHUB_PRIVATE_KEY || "", -); - function base64url(data: Buffer | string): string { const buf = typeof data === "string" ? Buffer.from(data) : data; return buf.toString("base64url"); @@ -96,7 +92,10 @@ function base64url(data: Buffer | string): string { * Valid for 10 minutes (GitHub's maximum). */ function createAppJWT(): string { - if (!GITHUB_APP_ID || !GITHUB_PRIVATE_KEY) { + const appId = process.env.GITHUB_APP_ID || ""; + const privateKey = normalizePrivateKey(process.env.GITHUB_PRIVATE_KEY || ""); + + if (!appId || !privateKey) { throw new Error( "GitHub App credentials not configured. " + "Set GITHUB_APP_ID and GITHUB_PRIVATE_KEY environment variables.", @@ -109,7 +108,7 @@ function createAppJWT(): string { JSON.stringify({ iat: now - 60, // 60s clock skew allowance exp: now + 600, // 10 minutes - iss: GITHUB_APP_ID, + iss: appId, }), ); @@ -118,7 +117,7 @@ function createAppJWT(): string { try { const signingKey = crypto.createPrivateKey({ - key: GITHUB_PRIVATE_KEY, + key: privateKey, format: "pem", }); signature = crypto @@ -126,8 +125,8 @@ function createAppJWT(): string { .update(signingInput) .sign(signingKey, "base64url"); } catch (error) { - const hasPemHeader = GITHUB_PRIVATE_KEY.includes("-----BEGIN"); - const keyLen = GITHUB_PRIVATE_KEY.length; + const hasPemHeader = privateKey.includes("-----BEGIN"); + const keyLen = privateKey.length; throw new Error( `Invalid GITHUB_PRIVATE_KEY (length=${keyLen}, hasPemHeader=${hasPemHeader}). ` + "Expected a GitHub App PEM private key, " + diff --git a/github/server/lib/installation-map.ts b/github/server/lib/installation-map.ts index 6f372d7c..be9e61ac 100644 --- a/github/server/lib/installation-map.ts +++ b/github/server/lib/installation-map.ts @@ -1,42 +1,110 @@ /** - * In-memory mapping from GitHub installation ID to Mesh connection ID. + * Installation → Connection ID mapping store. * - * Populated during onChange when we discover which installations - * the user's OAuth token has access to. + * Backed by Workers KV when available (durable across isolates), with an + * in-memory Map fallback for local dev. The KV binding is injected per-request + * from env.INSTALLATIONS. */ -const installationMap = new Map(); - -export function setInstallationMapping( - installationId: number, - connectionId: string, -): void { - installationMap.set(installationId, connectionId); +interface KVNamespaceLike { + get(key: string): Promise; + put(key: string, value: string): Promise; + delete(key: string): Promise; + list(options?: { + prefix?: string; + cursor?: string; + }): Promise<{ + keys: Array<{ name: string }>; + list_complete: boolean; + cursor?: string; + }>; } -export function getConnectionForInstallation( - installationId: number, -): string | undefined { - return installationMap.get(installationId); +export interface InstallationStore { + get(installationId: number): Promise; + set(installationId: number, connectionId: string): Promise; + removeByConnection(connectionId: string): Promise; } -export function removeConnectionMappings(connectionId: string): void { - for (const [installationId, connId] of installationMap) { - if (connId === connectionId) { - installationMap.delete(installationId); +class MemoryInstallationStore implements InstallationStore { + private map = new Map(); + + async get(installationId: number): Promise { + return this.map.get(installationId); + } + + async set(installationId: number, connectionId: string): Promise { + this.map.set(installationId, connectionId); + } + + async removeByConnection(connectionId: string): Promise { + for (const [id, conn] of this.map) { + if (conn === connectionId) { + this.map.delete(id); + } } } } +class KvInstallationStore implements InstallationStore { + // KV keys: + // `installation:${installationId}` → connectionId + // `connection:${connectionId}:${installationId}` → "1" (reverse index) + constructor(private kv: KVNamespaceLike) {} + + async get(installationId: number): Promise { + const v = await this.kv.get(`installation:${installationId}`); + return v ?? undefined; + } + + async set(installationId: number, connectionId: string): Promise { + await Promise.all([ + this.kv.put(`installation:${installationId}`, connectionId), + this.kv.put(`connection:${connectionId}:${installationId}`, "1"), + ]); + } + + async removeByConnection(connectionId: string): Promise { + const prefix = `connection:${connectionId}:`; + let cursor: string | undefined; + do { + const { + keys, + list_complete, + cursor: nextCursor, + } = await this.kv.list({ prefix, cursor }); + await Promise.all( + keys.flatMap((k) => { + const installationId = k.name.slice(prefix.length); + return [ + this.kv.delete(`installation:${installationId}`), + this.kv.delete(k.name), + ]; + }), + ); + cursor = list_complete ? undefined : nextCursor; + } while (cursor); + } +} + +const memoryStore = new MemoryInstallationStore(); + +export function getInstallationStore( + kv: KVNamespaceLike | undefined, +): InstallationStore { + return kv ? new KvInstallationStore(kv) : memoryStore; +} + /** - * Fetch the user's GitHub App installations and store mappings. + * Fetch the user's GitHub App installations and persist mappings. + * Swaps mappings atomically after successful fetch of all pages. */ export async function captureInstallationMappings( token: string, connectionId: string, + store: InstallationStore, ): Promise { try { - // Fetch all pages first, then swap mappings atomically const allInstallations: Array<{ id: number; account: { login: string } }> = []; let page = 1; @@ -71,11 +139,10 @@ export async function captureInstallationMappings( page++; } - // Only clear old mappings after all pages fetched successfully - removeConnectionMappings(connectionId); + await store.removeByConnection(connectionId); for (const installation of allInstallations) { - setInstallationMapping(installation.id, connectionId); + await store.set(installation.id, connectionId); console.log( `[Installation] Mapped ${installation.id} (${installation.account.login}) → ${connectionId}`, ); diff --git a/github/server/lib/mcp-proxy.ts b/github/server/lib/mcp-proxy.ts index 77b6b075..8ff2fcac 100644 --- a/github/server/lib/mcp-proxy.ts +++ b/github/server/lib/mcp-proxy.ts @@ -105,31 +105,37 @@ function jsonSchemaToZod(inputSchema?: { type ToolsDef = Awaited>["tools"]; /** - * Discover upstream tool definitions at startup using a GitHub App - * installation token. Throws on failure — the server should not boot - * if tool discovery fails. + * Discover upstream tool definitions lazily using a GitHub App installation + * token. On Cloudflare Workers, secrets aren't populated at module-init + * time, so we defer the first call until the fetch handler runs and cache + * the result for the isolate's lifetime. On failure, the promise is reset + * so the next request retries rather than permanently failing the isolate. */ -async function discoverUpstreamToolDefs(): Promise { - console.log("[MCP Proxy] Discovering upstream tools at startup..."); - const token = await getAppInstallationToken(); - const client = await connectUpstreamClient(token); - try { - const result = await client.listTools(); - console.log(`[MCP Proxy] Discovered ${result.tools.length} upstream tools`); - return result.tools; - } finally { - client.close().catch(() => {}); +let upstreamToolDefsPromise: Promise | null = null; + +export function getUpstreamToolDefs(): Promise { + if (!upstreamToolDefsPromise) { + upstreamToolDefsPromise = (async () => { + console.log("[MCP Proxy] Discovering upstream tools..."); + const token = await getAppInstallationToken(); + const client = await connectUpstreamClient(token); + try { + const result = await client.listTools(); + console.log( + `[MCP Proxy] Discovered ${result.tools.length} upstream tools`, + ); + return result.tools; + } finally { + client.close().catch(() => {}); + } + })().catch((err) => { + upstreamToolDefsPromise = null; + throw err; + }); } + return upstreamToolDefsPromise; } -/** - * Top-level promise that resolves to the upstream tool definitions. - * Awaited in tools/index.ts before the server starts accepting requests. - * If this fails, the server process crashes — by design. - */ -export const upstreamToolDefsReady: Promise = - discoverUpstreamToolDefs(); - // ============================================================================ // Upstream tool creation // ============================================================================ @@ -147,8 +153,8 @@ export function buildUpstreamTools( description: toolDef.description || `GitHub tool: ${toolDef.name}`, inputSchema: jsonSchemaToZod(toolDef.inputSchema as any), execute: async ({ context }, ctx) => { - const currentToken = (ctx as AppContext).env.MESH_REQUEST_CONTEXT - ?.authorization; + const currentToken = (ctx as unknown as AppContext).env + .MESH_REQUEST_CONTEXT?.authorization; if (!currentToken) { throw new Error("GitHub authorization token not found"); } diff --git a/github/server/lib/trigger-store.ts b/github/server/lib/trigger-store.ts index 31079987..8868d90c 100644 --- a/github/server/lib/trigger-store.ts +++ b/github/server/lib/trigger-store.ts @@ -1,14 +1,50 @@ import { createTriggers } from "@decocms/runtime/triggers"; -import { StudioKV } from "@decocms/runtime/trigger-storage"; import { z } from "zod"; -const storage = - process.env.MESH_URL && process.env.MESH_API_KEY - ? new StudioKV({ - url: process.env.MESH_URL, - apiKey: process.env.MESH_API_KEY, - }) - : undefined; +interface KVNamespaceLike { + get(key: string): Promise; + put(key: string, value: string): Promise; + delete(key: string): Promise; +} + +interface TriggerState { + credentials: { callbackUrl: string; callbackToken: string }; + activeTriggerTypes: string[]; +} + +// TriggerStorage backed by the same Workers KV namespace used for +// installation mappings (prefix `triggers:` to keep the data disjoint). +// +// The KV binding is per-request, but trigger-store is a module-level +// singleton. We thread the current binding through a module-local +// variable set at the top of each fetch handler — safe because all +// concurrent requests on the same isolate share the same env/bindings. +let currentKV: KVNamespaceLike | undefined; + +export function setTriggerKV(kv: KVNamespaceLike | undefined): void { + currentKV = kv; +} + +const triggerStorage = { + async get(connectionId: string): Promise { + if (!currentKV) return null; + const raw = await currentKV.get(`triggers:${connectionId}`); + if (!raw) return null; + try { + return JSON.parse(raw) as TriggerState; + } catch { + return null; + } + }, + async set(connectionId: string, state: TriggerState): Promise { + if (!currentKV) return; + await currentKV.put(`triggers:${connectionId}`, JSON.stringify(state)); + }, + async delete(connectionId: string): Promise { + if (!currentKV) return; + await currentKV.delete(`triggers:${connectionId}`); + }, +}; export const triggers = createTriggers({ definitions: [ @@ -101,5 +137,5 @@ export const triggers = createTriggers({ }), }, ], - storage, + storage: triggerStorage, }); diff --git a/github/server/main.ts b/github/server/main.ts index 7eae97c9..5440a355 100644 --- a/github/server/main.ts +++ b/github/server/main.ts @@ -1,97 +1,128 @@ /** - * GitHub MCP Server + * GitHub MCP Server — Cloudflare Workers entrypoint * * OAuth proxy that exposes the full GitHub MCP toolset (30+ tools) * through GitHub App OAuth authentication. + * + * Secrets come from wrangler (exposed via process.env under nodejs_compat) + * and are read lazily per-request because they aren't populated at module + * init time on Workers. */ import type { Registry } from "@decocms/mcps-shared/registry"; -import { serve } from "@decocms/mcps-shared/serve"; import { withRuntime } from "@decocms/runtime"; import { exchangeCodeForToken } from "./lib/github-client.ts"; -import { captureInstallationMappings } from "./lib/installation-map.ts"; +import { + captureInstallationMappings, + getInstallationStore, +} from "./lib/installation-map.ts"; import { handleProxiedRequest } from "./lib/mcp-proxy.ts"; -import { tools } from "./tools/index.ts"; +import { setTriggerKV } from "./lib/trigger-store.ts"; +import { getTools } from "./tools/index.ts"; import { type Env, StateSchema } from "./types/env.ts"; import { handleGitHubWebhook } from "./webhook.ts"; -const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || ""; -const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || ""; - -const runtime = withRuntime({ - oauth: { - mode: "PKCE", - authorizationServer: "https://github.com", - - authorizationUrl: (callbackUrl) => { - const callbackUrlObj = new URL(callbackUrl); - const state = callbackUrlObj.searchParams.get("state"); - - // Remove state from redirect_uri — pass it as a separate param - callbackUrlObj.searchParams.delete("state"); - const redirectUri = callbackUrlObj.toString(); - - const url = new URL("https://github.com/login/oauth/authorize"); - url.searchParams.set("client_id", GITHUB_CLIENT_ID); - url.searchParams.set("redirect_uri", redirectUri); - url.searchParams.set("scope", "repo read:org read:user"); - - if (state) { - url.searchParams.set("state", state); - } - - return url.toString(); - }, - - exchangeCode: async ({ code, redirect_uri }) => { - if (!GITHUB_CLIENT_ID || !GITHUB_CLIENT_SECRET) { - throw new Error( - "GitHub OAuth credentials not configured. " + - "Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET environment variables.", - ); - } - - const tokenResponse = await exchangeCodeForToken( - code, - GITHUB_CLIENT_ID, - GITHUB_CLIENT_SECRET, - redirect_uri, - ); - - return { - access_token: tokenResponse.access_token, - token_type: tokenResponse.token_type, - }; - }, - }, - - configuration: { - onChange: async (env) => { - const token = env.MESH_REQUEST_CONTEXT?.authorization; - const connectionId = env.MESH_REQUEST_CONTEXT?.connectionId; - if (token && connectionId) { - await captureInstallationMappings(token, connectionId); - } - }, - state: StateSchema, - }, - - tools, - prompts: [], -}); - -const port = process.env.PORT || 8001; +type Runtime = ReturnType< + typeof withRuntime +>; + +let runtimePromise: Promise | null = null; + +async function getRuntime(): Promise { + if (!runtimePromise) { + runtimePromise = (async () => { + const tools = await getTools(); + + return withRuntime({ + oauth: { + mode: "PKCE", + authorizationServer: "https://github.com", + + authorizationUrl: (callbackUrl) => { + const clientId = process.env.GITHUB_CLIENT_ID || ""; + const callbackUrlObj = new URL(callbackUrl); + const state = callbackUrlObj.searchParams.get("state"); + + // Remove state from redirect_uri — pass it as a separate param + callbackUrlObj.searchParams.delete("state"); + const redirectUri = callbackUrlObj.toString(); + + const url = new URL("https://github.com/login/oauth/authorize"); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("scope", "repo read:org read:user"); + + if (state) { + url.searchParams.set("state", state); + } + + return url.toString(); + }, + + exchangeCode: async ({ code, redirect_uri }) => { + const clientId = process.env.GITHUB_CLIENT_ID || ""; + const clientSecret = process.env.GITHUB_CLIENT_SECRET || ""; + + if (!clientId || !clientSecret) { + throw new Error( + "GitHub OAuth credentials not configured. " + + "Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET environment variables.", + ); + } + + const tokenResponse = await exchangeCodeForToken( + code, + clientId, + clientSecret, + redirect_uri, + ); + + return { + access_token: tokenResponse.access_token, + token_type: tokenResponse.token_type, + }; + }, + }, + + configuration: { + onChange: async (env) => { + const token = env.MESH_REQUEST_CONTEXT?.authorization; + const connectionId = env.MESH_REQUEST_CONTEXT?.connectionId; + if (token && connectionId) { + const store = getInstallationStore(env.INSTALLATIONS); + await captureInstallationMappings(token, connectionId, store); + } + }, + state: StateSchema, + }, + + tools, + prompts: [], + }); + })().catch((err) => { + // Reset on failure so the next request can retry (e.g. transient + // GitHub App auth or upstream discovery failure). + runtimePromise = null; + throw err; + }); + } + return runtimePromise; +} /** - * Wrap runtime.fetch to intercept MCP resource requests before the SDK handles them. + * Intercept webhook and MCP resource requests before they reach runtime.fetch. * The Deco runtime doesn't support resources natively, so we proxy them upstream. */ -const wrappedFetch: typeof runtime.fetch = async (req, env, ctx) => { +async function handle(req: Request, env: Env, ctx: unknown): Promise { + // Make the KV binding visible to the trigger store's module-level + // storage for this request. + setTriggerKV(env.INSTALLATIONS); + const url = new URL(req.url); // GitHub webhook endpoint (unauthenticated — signature-verified instead) if (req.method === "POST" && url.pathname === "/webhooks/github") { - return handleGitHubWebhook(req); + return handleGitHubWebhook(req, env); } // Proxy MCP resource requests to upstream @@ -106,23 +137,10 @@ const wrappedFetch: typeof runtime.fetch = async (req, env, ctx) => { if (proxied) return proxied; } - return runtime.fetch(req, env, ctx); -}; - -serve(wrappedFetch); - -console.log(` -╔══════════════════════════════════════════════════════════╗ -║ GitHub MCP Server Started ║ -╠══════════════════════════════════════════════════════════╣ -║ OAuth proxy for the official GitHub MCP Server ║ -╚══════════════════════════════════════════════════════════╝ + const runtime = await getRuntime(); + return runtime.fetch(req, env, ctx as Parameters[2]); +} -🚀 Server listening on http://localhost:${port}/mcp - -📋 Environment Variables: - GITHUB_APP_ID - GitHub App ID - GITHUB_PRIVATE_KEY - GitHub App private key (PEM) - GITHUB_CLIENT_ID - GitHub App Client ID (OAuth) - GITHUB_CLIENT_SECRET - GitHub App Client Secret (OAuth) -`); +export default { + fetch: handle, +}; diff --git a/github/server/tools/index.ts b/github/server/tools/index.ts index 0304f614..86e9531f 100644 --- a/github/server/tools/index.ts +++ b/github/server/tools/index.ts @@ -1,14 +1,19 @@ /** * GitHub MCP Tools * - * Upstream tools are discovered at startup via GitHub App auth. - * Trigger tools come from the @decocms/runtime triggers SDK. - * Both are resolved before the server starts accepting requests. + * Upstream tools are discovered lazily on first request (needs env/secrets + * which aren't available at module-init on Cloudflare Workers). Trigger + * tools come from the @decocms/runtime triggers SDK and are static. */ -import { upstreamToolDefsReady, buildUpstreamTools } from "../lib/mcp-proxy.ts"; +import { buildUpstreamTools, getUpstreamToolDefs } from "../lib/mcp-proxy.ts"; import { triggers } from "../lib/trigger-store.ts"; -const toolDefs = await upstreamToolDefsReady; - -export const tools = [...buildUpstreamTools(toolDefs), ...triggers.tools()]; +/** + * Resolve the full tool set. Cached for the isolate's lifetime once + * upstream discovery succeeds (caching happens inside getUpstreamToolDefs). + */ +export async function getTools() { + const toolDefs = await getUpstreamToolDefs(); + return [...buildUpstreamTools(toolDefs), ...triggers.tools()]; +} diff --git a/github/server/types/env.ts b/github/server/types/env.ts index 9f379728..11d54669 100644 --- a/github/server/types/env.ts +++ b/github/server/types/env.ts @@ -12,9 +12,36 @@ import { z } from "zod"; */ export const StateSchema = z.object({}); +interface KVNamespace { + get(key: string): Promise; + put(key: string, value: string): Promise; + delete(key: string): Promise; + list(options?: { + prefix?: string; + cursor?: string; + }): Promise<{ + keys: Array<{ name: string }>; + list_complete: boolean; + cursor?: string; + }>; +} + /** - * Environment type combining Deco bindings with shared Registry + * Environment type combining Deco bindings with shared Registry + Workers + * bindings. INSTALLATIONS is the KV namespace used for two prefixes: + * - `installation:*` — GitHub installation id → Mesh connection id + * - `triggers:*` — connection id → trigger subscription state + * + * GitHub secrets arrive via `wrangler secret put` and are exposed through + * `process.env` under `nodejs_compat`. */ -export type Env = DefaultEnv; +export type Env = DefaultEnv & { + INSTALLATIONS?: KVNamespace; + GITHUB_APP_ID?: string; + GITHUB_PRIVATE_KEY?: string; + GITHUB_CLIENT_ID?: string; + GITHUB_CLIENT_SECRET?: string; + GITHUB_WEBHOOK_SECRET?: string; +}; export type { Registry }; diff --git a/github/server/webhook.ts b/github/server/webhook.ts index 769c3b50..c22e70d4 100644 --- a/github/server/webhook.ts +++ b/github/server/webhook.ts @@ -5,20 +5,22 @@ * and routes them to the correct connection. */ -import { getConnectionForInstallation } from "./lib/installation-map.ts"; -import { verifyGitHubWebhook } from "./lib/webhook.ts"; +import { getInstallationStore } from "./lib/installation-map.ts"; import { triggers } from "./lib/trigger-store.ts"; +import { verifyGitHubWebhook } from "./lib/webhook.ts"; +import type { Env } from "./types/env.ts"; -const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET || ""; - -export async function handleGitHubWebhook(req: Request): Promise { +export async function handleGitHubWebhook( + req: Request, + env: Env, +): Promise { const rawBody = await req.text(); const signatureHeader = req.headers.get("x-hub-signature-256"); const { verified, payload } = await verifyGitHubWebhook( rawBody, signatureHeader, - GITHUB_WEBHOOK_SECRET, + process.env.GITHUB_WEBHOOK_SECRET || "", ); if (!verified || !payload) { @@ -30,7 +32,8 @@ export async function handleGitHubWebhook(req: Request): Promise { return Response.json({ ok: true, skipped: "no_installation_id" }); } - const connectionId = getConnectionForInstallation(installationId); + const store = getInstallationStore(env.INSTALLATIONS); + const connectionId = await store.get(installationId); if (!connectionId) { return Response.json({ ok: true, skipped: "no_mapping" }); } diff --git a/github/tsconfig.json b/github/tsconfig.json index 8d5e43f6..3a21ee45 100644 --- a/github/tsconfig.json +++ b/github/tsconfig.json @@ -23,11 +23,11 @@ "noUncheckedSideEffectImports": true, /* Types */ "types": [ - "@types/node" + "@types/node", + "@cloudflare/workers-types" ] }, "include": [ "server" ] } - diff --git a/github/wrangler.toml b/github/wrangler.toml new file mode 100644 index 00000000..b83fa1e7 --- /dev/null +++ b/github/wrangler.toml @@ -0,0 +1,21 @@ +name = "github-mcp" +main = "server/main.ts" +compatibility_date = "2025-06-17" +compatibility_flags = ["nodejs_compat"] + +[observability] +enabled = true + +[observability.logs] +enabled = true +invocation_logs = true + +# Durable mapping from GitHub installation ID to Mesh connection ID. +# Isolates on Workers are ephemeral, so an in-memory Map would lose +# mappings between webhook deliveries and cold starts. +# +# Create with: bunx wrangler kv namespace create INSTALLATIONS +# Then paste the returned id below. +[[kv_namespaces]] +binding = "INSTALLATIONS" +id = "c81656fe0e4347d39205c0f2103ca5c9" From eeeb73cc023e315208aa35af9ab6d9600d1da93b Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Thu, 23 Apr 2026 09:42:00 -0300 Subject: [PATCH 2/7] style(github): fix formatting flagged by oxfmt Co-Authored-By: Claude Opus 4.7 (1M context) --- github/server/lib/installation-map.ts | 5 +---- github/server/types/env.ts | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/github/server/lib/installation-map.ts b/github/server/lib/installation-map.ts index be9e61ac..f3c3b6af 100644 --- a/github/server/lib/installation-map.ts +++ b/github/server/lib/installation-map.ts @@ -10,10 +10,7 @@ interface KVNamespaceLike { get(key: string): Promise; put(key: string, value: string): Promise; delete(key: string): Promise; - list(options?: { - prefix?: string; - cursor?: string; - }): Promise<{ + list(options?: { prefix?: string; cursor?: string }): Promise<{ keys: Array<{ name: string }>; list_complete: boolean; cursor?: string; diff --git a/github/server/types/env.ts b/github/server/types/env.ts index 11d54669..b33443e2 100644 --- a/github/server/types/env.ts +++ b/github/server/types/env.ts @@ -16,10 +16,7 @@ interface KVNamespace { get(key: string): Promise; put(key: string, value: string): Promise; delete(key: string): Promise; - list(options?: { - prefix?: string; - cursor?: string; - }): Promise<{ + list(options?: { prefix?: string; cursor?: string }): Promise<{ keys: Array<{ name: string }>; list_complete: boolean; cursor?: string; From 244760d66c135d981784a273e71ed9f5cdf6ec9c Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Thu, 23 Apr 2026 09:45:36 -0300 Subject: [PATCH 3/7] chore(github): log every webhook delivery in/out for debugging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emits [Webhook] log lines on ingress, on signature failure, on skip (no installation / no mapping), and on successful notify — keyed by x-github-delivery so a single event can be traced end to end in Cloudflare observability. Co-Authored-By: Claude Opus 4.7 (1M context) --- github/server/webhook.ts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/github/server/webhook.ts b/github/server/webhook.ts index c22e70d4..b10847a2 100644 --- a/github/server/webhook.ts +++ b/github/server/webhook.ts @@ -14,6 +14,14 @@ export async function handleGitHubWebhook( req: Request, env: Env, ): Promise { + const deliveryId = req.headers.get("x-github-delivery") || "unknown"; + const eventHeader = req.headers.get("x-github-event") || "unknown"; + const hookId = req.headers.get("x-github-hook-id") || "unknown"; + + console.log( + `[Webhook] ← delivery=${deliveryId} event=${eventHeader} hook=${hookId}`, + ); + const rawBody = await req.text(); const signatureHeader = req.headers.get("x-hub-signature-256"); @@ -24,28 +32,44 @@ export async function handleGitHubWebhook( ); if (!verified || !payload) { + console.warn( + `[Webhook] ✗ delivery=${deliveryId} rejected: invalid signature (sig_present=${Boolean( + signatureHeader, + )}, body_bytes=${rawBody.length})`, + ); return Response.json({ error: "Invalid signature" }, { status: 401 }); } const installationId = payload.installation?.id; if (!installationId) { + console.log( + `[Webhook] ⚠ delivery=${deliveryId} skipped: no installation_id in payload`, + ); return Response.json({ ok: true, skipped: "no_installation_id" }); } const store = getInstallationStore(env.INSTALLATIONS); const connectionId = await store.get(installationId); if (!connectionId) { + console.log( + `[Webhook] ⚠ delivery=${deliveryId} skipped: no mapping for installation=${installationId}`, + ); return Response.json({ ok: true, skipped: "no_mapping" }); } - const eventType = req.headers.get("x-github-event") || "unknown"; const fullEventType = payload.action - ? `github.${eventType}.${payload.action}` - : `github.${eventType}`; + ? `github.${eventHeader}.${payload.action}` + : `github.${eventHeader}`; const subject = payload.repository?.full_name || payload.organization?.login || "unknown"; + console.log( + `[Webhook] → delivery=${deliveryId} event=${fullEventType} subject=${subject} ` + + `installation=${installationId} connection=${connectionId} ` + + `sender=${payload.sender?.login ?? "?"} action=${payload.action ?? "-"}`, + ); + // Notify Mesh — the SDK handles credential lookup and delivery triggers.notify( connectionId, From 884cae5739ca5f39cd5e089ffee3901cb3aeb2c3 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Thu, 23 Apr 2026 09:48:07 -0300 Subject: [PATCH 4/7] chore(github): route custom domain github-mcp.decocms.com to worker Co-Authored-By: Claude Opus 4.7 (1M context) --- github/wrangler.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/github/wrangler.toml b/github/wrangler.toml index b83fa1e7..3f4688e7 100644 --- a/github/wrangler.toml +++ b/github/wrangler.toml @@ -3,6 +3,10 @@ main = "server/main.ts" compatibility_date = "2025-06-17" compatibility_flags = ["nodejs_compat"] +routes = [ + { pattern = "github-mcp.decocms.com", custom_domain = true }, +] + [observability] enabled = true From d877fc24c36fea6975a7421104ea40204654c555 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Thu, 23 Apr 2026 09:54:20 -0300 Subject: [PATCH 5/7] chore(github): point registry connection url to the new worker domain Co-Authored-By: Claude Opus 4.7 (1M context) --- github/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/github/app.json b/github/app.json index 738d52c8..5df99714 100644 --- a/github/app.json +++ b/github/app.json @@ -4,7 +4,7 @@ "friendlyName": "GitHub", "connection": { "type": "HTTP", - "url": "https://sites-github-mcp.decocache.com/mcp" + "url": "https://github-mcp.decocms.com/mcp" }, "description": "OAuth proxy for the official GitHub MCP Server — authenticates via GitHub App OAuth and exposes 30+ tools (repos, issues, PRs, code search, and more)", "icon": "https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png", From 3ff6f8114f48ae1229cc1b4b414d98e5c47fdf4b Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Thu, 23 Apr 2026 10:01:09 -0300 Subject: [PATCH 6/7] fix(github): keep webhook delivery alive via ctx.waitUntil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit triggers.notify() is fire-and-forget — on Workers the internal fetch to Mesh got cancelled as soon as we returned the HTTP response, silently dropping events. Replaced with a direct Mesh callback delivery that reads trigger credentials from KV and returns an awaitable Promise, which we hand to ctx.waitUntil so Workers keeps the isolate alive until the POST completes. Threads ExecutionContext through handle() → webhook handler. Also logs every success / failure per delivery id. Co-Authored-By: Claude Opus 4.7 (1M context) --- github/server/main.ts | 14 +++++-- github/server/webhook.ts | 88 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/github/server/main.ts b/github/server/main.ts index 3b62654d..ffa22df6 100644 --- a/github/server/main.ts +++ b/github/server/main.ts @@ -147,7 +147,11 @@ async function getRuntime(): Promise { * Intercept webhook and MCP resource requests before they reach runtime.fetch. * The Deco runtime doesn't support resources natively, so we proxy them upstream. */ -async function handle(req: Request, env: Env, ctx: unknown): Promise { +async function handle( + req: Request, + env: Env, + ctx: ExecutionContext, +): Promise { // Make the KV binding visible to the trigger store's module-level // storage for this request. setTriggerKV(env.INSTALLATIONS); @@ -156,7 +160,7 @@ async function handle(req: Request, env: Env, ctx: unknown): Promise { // GitHub webhook endpoint (unauthenticated — signature-verified instead) if (req.method === "POST" && url.pathname === "/webhooks/github") { - return handleGitHubWebhook(req, env); + return handleGitHubWebhook(req, env, ctx); } // Proxy MCP resource requests to upstream @@ -172,7 +176,11 @@ async function handle(req: Request, env: Env, ctx: unknown): Promise { } const runtime = await getRuntime(); - return runtime.fetch(req, env, ctx as Parameters[2]); + return runtime.fetch( + req, + env, + ctx as unknown as Parameters[2], + ); } export default { diff --git a/github/server/webhook.ts b/github/server/webhook.ts index b10847a2..bb5df84b 100644 --- a/github/server/webhook.ts +++ b/github/server/webhook.ts @@ -6,13 +6,88 @@ */ import { getInstallationStore } from "./lib/installation-map.ts"; -import { triggers } from "./lib/trigger-store.ts"; import { verifyGitHubWebhook } from "./lib/webhook.ts"; import type { Env } from "./types/env.ts"; +interface CallbackCredentials { + callbackUrl: string; + callbackToken: string; +} + +interface TriggerState { + credentials: CallbackCredentials; + activeTriggerTypes: string[]; +} + +/** + * Direct delivery to Mesh, awaitable (unlike `triggers.notify()` which + * is fire-and-forget and loses in-flight fetches once the Worker response + * returns). Reads trigger credentials from the same KV the trigger SDK + * writes to (`triggers:${connectionId}`). + */ +async function deliverToMesh( + env: Env, + connectionId: string, + type: string, + data: Record, + deliveryId: string, +): Promise { + if (!env.INSTALLATIONS) { + console.warn( + `[Webhook] ⚠ delivery=${deliveryId} no INSTALLATIONS binding — skipping mesh notify`, + ); + return; + } + + const raw = await env.INSTALLATIONS.get(`triggers:${connectionId}`); + if (!raw) { + console.log( + `[Webhook] ⚠ delivery=${deliveryId} no trigger credentials for connection=${connectionId} — skipping mesh notify`, + ); + return; + } + + let state: TriggerState; + try { + state = JSON.parse(raw) as TriggerState; + } catch (err) { + console.error( + `[Webhook] ✗ delivery=${deliveryId} corrupted trigger state for connection=${connectionId}:`, + err, + ); + return; + } + + try { + const res = await fetch(state.credentials.callbackUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${state.credentials.callbackToken}`, + }, + body: JSON.stringify({ type, data }), + }); + if (!res.ok) { + console.error( + `[Webhook] ✗ delivery=${deliveryId} mesh callback returned ${res.status} ${res.statusText}`, + ); + } else { + console.log( + `[Webhook] ✓ delivery=${deliveryId} mesh callback delivered (${res.status})`, + ); + } + } catch (err) { + console.error( + `[Webhook] ✗ delivery=${deliveryId} mesh callback fetch failed:`, + err, + ); + } +} + export async function handleGitHubWebhook( req: Request, env: Env, + ctx: ExecutionContext, ): Promise { const deliveryId = req.headers.get("x-github-delivery") || "unknown"; const eventHeader = req.headers.get("x-github-event") || "unknown"; @@ -70,10 +145,13 @@ export async function handleGitHubWebhook( `sender=${payload.sender?.login ?? "?"} action=${payload.action ?? "-"}`, ); - // Notify Mesh — the SDK handles credential lookup and delivery - triggers.notify( + // Hand the delivery to Workers' post-response task queue so it isn't + // cancelled when we return below. On local dev (no ctx.waitUntil) we + // just let it run — Bun/Node won't terminate the process mid-fetch. + const deliveryPromise = deliverToMesh( + env, connectionId, - fullEventType as Parameters[1], + fullEventType, { event: fullEventType, subject, @@ -82,7 +160,9 @@ export async function handleGitHubWebhook( action: payload.action, payload, }, + deliveryId, ); + ctx.waitUntil(deliveryPromise); return Response.json({ ok: true, event: fullEventType, subject }); } From c0242cd39c265aca79f4b10d05109674b7d5f552 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Thu, 23 Apr 2026 10:03:44 -0300 Subject: [PATCH 7/7] fix(github): evict stale reverse-index when reassigning installation owner If two users share a GitHub App installation, the second OAuth would overwrite installation: but leave the first user's connection:: entry behind. A later removeByConnection(oldConn) then deleted the live forward mapping for the new owner. set() now reads the current owner first and cleans up its reverse- index entry in the same batch. Co-Authored-By: Claude Opus 4.7 (1M context) --- github/server/lib/installation-map.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/github/server/lib/installation-map.ts b/github/server/lib/installation-map.ts index f3c3b6af..c88f27c7 100644 --- a/github/server/lib/installation-map.ts +++ b/github/server/lib/installation-map.ts @@ -55,10 +55,19 @@ class KvInstallationStore implements InstallationStore { } async set(installationId: number, connectionId: string): Promise { - await Promise.all([ + // Read the existing owner first so we can tear down its reverse-index + // entry — otherwise a later removeByConnection(oldOwner) would match + // the stale connection:${oldOwner}:${id} key and wipe the forward + // mapping we're about to write for the new owner. + const existing = await this.kv.get(`installation:${installationId}`); + const ops: Promise[] = [ this.kv.put(`installation:${installationId}`, connectionId), this.kv.put(`connection:${connectionId}:${installationId}`, "1"), - ]); + ]; + if (existing && existing !== connectionId) { + ops.push(this.kv.delete(`connection:${existing}:${installationId}`)); + } + await Promise.all(ops); } async removeByConnection(connectionId: string): Promise {