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/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", 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..c88f27c7 100644 --- a/github/server/lib/installation-map.ts +++ b/github/server/lib/installation-map.ts @@ -1,42 +1,116 @@ /** - * 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(); +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 setInstallationMapping( - installationId: number, - connectionId: string, -): void { - installationMap.set(installationId, connectionId); +export interface InstallationStore { + get(installationId: number): Promise; + set(installationId: number, connectionId: string): Promise; + removeByConnection(connectionId: string): Promise; } -export function getConnectionForInstallation( - installationId: number, -): string | undefined { - return installationMap.get(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); + } + } + } } -export function removeConnectionMappings(connectionId: string): void { - for (const [installationId, connId] of installationMap) { - if (connId === connectionId) { - installationMap.delete(installationId); +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 { + // 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 { + 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 +145,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 97922938..5c0a172b 100644 --- a/github/server/main.ts +++ b/github/server/main.ts @@ -1,130 +1,171 @@ /** - * 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, refreshAccessToken, } 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 || ""; +type Runtime = ReturnType< + typeof withRuntime +>; + const REQUESTED_SCOPES = "repo read:org read:user"; -function assertOAuthCredentials(): void { - if (!GITHUB_CLIENT_ID || !GITHUB_CLIENT_SECRET) { +/** + * Lazily read OAuth credentials — on Workers, process.env isn't populated + * at module-init time, so we must resolve per-call. + */ +function getOAuthCredentials(): { clientId: string; clientSecret: string } { + 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.", ); } + return { clientId, clientSecret }; } -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", REQUESTED_SCOPES); - - if (state) { - url.searchParams.set("state", state); - } - - return url.toString(); - }, - - exchangeCode: async ({ code, redirect_uri }) => { - assertOAuthCredentials(); - - const tokenResponse = await exchangeCodeForToken( - code, - GITHUB_CLIENT_ID, - GITHUB_CLIENT_SECRET, - redirect_uri, - ); - - return { - access_token: tokenResponse.access_token, - token_type: tokenResponse.token_type, - expires_in: tokenResponse.expires_in, - refresh_token: tokenResponse.refresh_token, - refresh_token_expires_in: tokenResponse.refresh_token_expires_in, - // GitHub App user-to-server tokens don't echo `scope` — fall back to - // the scopes we requested so the mesh can store/display them per RFC 6749 §5.1. - scope: tokenResponse.scope || REQUESTED_SCOPES, - }; - }, - - refreshToken: async (refreshToken) => { - assertOAuthCredentials(); - - const tokenResponse = await refreshAccessToken( - refreshToken, - GITHUB_CLIENT_ID, - GITHUB_CLIENT_SECRET, - ); - - return { - access_token: tokenResponse.access_token, - token_type: tokenResponse.token_type, - expires_in: tokenResponse.expires_in, - refresh_token: tokenResponse.refresh_token, - refresh_token_expires_in: tokenResponse.refresh_token_expires_in, - scope: tokenResponse.scope || REQUESTED_SCOPES, - }; - }, - }, - - 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; +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", REQUESTED_SCOPES); + + if (state) { + url.searchParams.set("state", state); + } + + return url.toString(); + }, + + exchangeCode: async ({ code, redirect_uri }) => { + const { clientId, clientSecret } = getOAuthCredentials(); + + const tokenResponse = await exchangeCodeForToken( + code, + clientId, + clientSecret, + redirect_uri, + ); + + return { + access_token: tokenResponse.access_token, + token_type: tokenResponse.token_type, + expires_in: tokenResponse.expires_in, + refresh_token: tokenResponse.refresh_token, + refresh_token_expires_in: tokenResponse.refresh_token_expires_in, + // GitHub App user-to-server tokens don't echo `scope` — fall + // back to the scopes we requested so the mesh can store / + // display them per RFC 6749 §5.1. + scope: tokenResponse.scope || REQUESTED_SCOPES, + }; + }, + + refreshToken: async (refreshToken) => { + const { clientId, clientSecret } = getOAuthCredentials(); + + const tokenResponse = await refreshAccessToken( + refreshToken, + clientId, + clientSecret, + ); + + return { + access_token: tokenResponse.access_token, + token_type: tokenResponse.token_type, + expires_in: tokenResponse.expires_in, + refresh_token: tokenResponse.refresh_token, + refresh_token_expires_in: tokenResponse.refresh_token_expires_in, + scope: tokenResponse.scope || REQUESTED_SCOPES, + }; + }, + }, + + 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: ExecutionContext, +): 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, ctx); } // Proxy MCP resource requests to upstream @@ -139,23 +180,14 @@ 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 ║ -╚══════════════════════════════════════════════════════════╝ - -🚀 Server listening on http://localhost:${port}/mcp + const runtime = await getRuntime(); + return runtime.fetch( + req, + env, + ctx as unknown as Parameters[2], + ); +} -📋 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..b33443e2 100644 --- a/github/server/types/env.ts +++ b/github/server/types/env.ts @@ -12,9 +12,33 @@ 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..bb5df84b 100644 --- a/github/server/webhook.ts +++ b/github/server/webhook.ts @@ -5,48 +5,153 @@ * and routes them to the correct connection. */ -import { getConnectionForInstallation } from "./lib/installation-map.ts"; +import { getInstallationStore } from "./lib/installation-map.ts"; import { verifyGitHubWebhook } from "./lib/webhook.ts"; -import { triggers } from "./lib/trigger-store.ts"; +import type { Env } from "./types/env.ts"; -const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET || ""; +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"; + const hookId = req.headers.get("x-github-hook-id") || "unknown"; + + console.log( + `[Webhook] ← delivery=${deliveryId} event=${eventHeader} hook=${hookId}`, + ); -export async function handleGitHubWebhook(req: Request): 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) { + 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 connectionId = getConnectionForInstallation(installationId); + 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"; - // Notify Mesh — the SDK handles credential lookup and delivery - triggers.notify( + console.log( + `[Webhook] → delivery=${deliveryId} event=${fullEventType} subject=${subject} ` + + `installation=${installationId} connection=${connectionId} ` + + `sender=${payload.sender?.login ?? "?"} action=${payload.action ?? "-"}`, + ); + + // 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, @@ -55,7 +160,9 @@ export async function handleGitHubWebhook(req: Request): Promise { action: payload.action, payload, }, + deliveryId, ); + ctx.waitUntil(deliveryPromise); return Response.json({ ok: true, event: fullEventType, subject }); } 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..3f4688e7 --- /dev/null +++ b/github/wrangler.toml @@ -0,0 +1,25 @@ +name = "github-mcp" +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 + +[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"