diff --git a/README.md b/README.md index 453e350..1478065 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,12 @@ GitHub webhook receiver and local MCP extension for GitHub notification workflow `github-webhook-mcp` receives GitHub webhook events, persists them to a local `events.json`, and exposes them to AI agents through MCP tools. It is designed for notification-style workflows where an AI can poll lightweight summaries, inspect a single event in detail, and mark handled events as processed. -The standard mode is standalone: while the local MCP server is running, it can also host a local webhook listener in the same process. - Detailed behavior, event metadata, trigger semantics, and file responsibilities live in [docs/0-requirements.md](docs/0-requirements.md). ## Features - Receives GitHub webhook events over HTTPS and persists them locally. - Exposes pending events to MCP clients through lightweight polling tools. -- Can run as a standalone local MCP + webhook listener without a separate Windows service. - Supports real-time `claude/channel` notifications in Claude Code. - Supports direct trigger mode for immediate Codex reactions per event. - Ships as a Node-based `.mcpb` desktop extension and as an `npx` MCP server. @@ -29,7 +26,6 @@ Download `mcp-server.mcpb` from [Releases](https://github.com/Liplus-Project/git 1. Open Claude Desktop → **Settings** → **Extensions** → **Advanced settings** → **Install Extension...** 2. Select the `.mcpb` file 3. Enter the path to your `events.json` when prompted -4. If you want the extension itself to receive GitHub webhooks, also fill in the local webhook port and webhook secret prompts ### Claude Desktop / Claude Code — npx @@ -42,10 +38,7 @@ Add to your Claude MCP config (`claude_desktop_config.json` or project settings) "command": "npx", "args": ["github-webhook-mcp"], "env": { - "EVENTS_JSON_PATH": "/path/to/events.json", - "WEBHOOK_PORT": "8080", - "WEBHOOK_SECRET": "your_secret", - "WEBHOOK_EVENT_PROFILE": "notifications" + "EVENTS_JSON_PATH": "/path/to/events.json" } } } @@ -61,9 +54,6 @@ args = ["github-webhook-mcp"] [mcp.github-webhook-mcp.env] EVENTS_JSON_PATH = "/path/to/events.json" -WEBHOOK_PORT = "8080" -WEBHOOK_SECRET = "your_secret" -WEBHOOK_EVENT_PROFILE = "notifications" ``` ### Python (legacy) @@ -81,68 +71,19 @@ WEBHOOK_EVENT_PROFILE = "notifications" ## Configuration -### Standard standalone mode - -When `WEBHOOK_PORT` is set, the Node.js MCP server starts a local webhook listener in the same process. -This is the recommended mode for lightweight local use and plugin-style distribution. - -### 1. Configure the local MCP server - -Set at least: - -- `EVENTS_JSON_PATH` -- `WEBHOOK_PORT` -- `WEBHOOK_SECRET` - -Optionally set: - -- `WEBHOOK_EVENT_PROFILE=notifications` - -### 2. Set up Cloudflare Tunnel - -```bash -cloudflared tunnel login -cloudflared tunnel create github-webhook-mcp -cp cloudflared/config.yml.example ~/.cloudflared/config.yml -# Edit config.yml with your tunnel ID and domain -cloudflared tunnel run -``` - -### 3. Configure the GitHub webhook - -- Payload URL: `https://webhook.yourdomain.com/webhook` -- Content type: `application/json` -- Secret: same value as `WEBHOOK_SECRET` -- Recommended event profile: - - Issues - - Issue comments - - Pull requests - - Pull request reviews - - Pull request review comments - - Check runs - - Workflow runs - - Discussions - - Discussion comments - -If your webhook is temporarily set to `Send me everything`, set `WEBHOOK_EVENT_PROFILE=notifications` and the embedded receiver will ignore noisy events such as `workflow_job` or `check_suite`. - -### Legacy Python receiver mode - -Use this mode only if you want a separate long-running receiver process or direct trigger queue behavior. - -#### 1. Install receiver dependencies +### 1. Install receiver dependencies ```bash pip install -r requirements.txt ``` -#### 2. Start the webhook receiver +### 2. Start the webhook receiver ```bash WEBHOOK_SECRET=your_secret python main.py webhook --port 8080 --event-profile notifications ``` -#### 3. Set up Cloudflare Tunnel +### 3. Set up Cloudflare Tunnel ```bash cloudflared tunnel login @@ -152,7 +93,7 @@ cp cloudflared/config.yml.example ~/.cloudflared/config.yml cloudflared tunnel run ``` -#### 4. Configure the GitHub webhook +### 4. Configure the GitHub webhook - Payload URL: `https://webhook.yourdomain.com/webhook` - Content type: `application/json` @@ -170,7 +111,7 @@ cloudflared tunnel run If your webhook is temporarily set to `Send me everything`, start the receiver with `--event-profile notifications` and it will ignore noisy events such as `workflow_job` or `check_suite`. -#### 5. Optional direct trigger mode +### 5. Optional direct trigger mode Use the bundled Codex wrapper if you want the webhook to launch `codex exec` immediately. @@ -190,7 +131,7 @@ python codex_reaction.py --workspace /path/to/workspace --resume-session Cloudflare Tunnel ──> local webhook event server (:8080, embedded) - | - v - events.json - ^ - | - Node.js MCP server (stdio, same process) - ^ - | - AI Agent (Codex / Claude) -``` - -標準モードでは、Node.js MCP サーバーが起動中のみローカル webhook event server を同居起動する。 -Webhook 受信と MCP 提供は同一実体で兼任してよく、イベントストアは既存の `events.json` をそのまま使う。 - -### Legacy service-style mode - ``` GitHub ──POST──> Cloudflare Tunnel ──> FastAPI :8080 ──persist──> events.json ^ @@ -51,21 +30,19 @@ GitHub ──POST──> Cloudflare Tunnel ──> FastAPI :8080 ──persist AI Agent (Codex / Claude) optional trigger command ``` -常駐サービスや direct trigger queue が必要な場合は、従来どおり Python webhook receiver を別プロセスで起動できる。 - -### Components +システムは二つのコンポーネントで構成される: -1. **Node.js standalone MCP server(標準)** — `npx github-webhook-mcp` または MCPB 経由 - - MCP stdio サーバーとして起動する - - `WEBHOOK_PORT` が設定されている場合、同一プロセスでローカル webhook event server を起動する - - `events.json` の読み書きを担当する - -2. **Legacy webhook receiver(Python)** — `python main.py webhook` +1. **Webhook 受信サーバー(Python)** — `python main.py webhook` - HTTP 受信、イベント永続化、optional な direct trigger queue を担当 - trigger command が設定されている場合、保存済みイベントごとに直列実行される -3. **Legacy MCP server(Python 互換)** — `python main.py mcp` - - 従来互換用の stdio MCP 実装 +2. **MCP ツールサーバー** — 二つの実装が存在する: + - **Python 実装:** `python main.py mcp` + - **Node.js 実装:** `mcp-server/` ディレクトリ(`npx github-webhook-mcp` または MCPB 経由) + +両 MCP 実装は同一の events.json を読み書きし、同一の5ツールを提供する。 +Python 実装は webhook 受信サーバーと同一エントリポイントで起動する。 +Node.js 実装は独立パッケージとして配布される(MCPB: Claude Desktop 向け、npx: Codex 向け)。 ## Functional Requirements @@ -78,7 +55,6 @@ GitHub ──POST──> Cloudflare Tunnel ──> FastAPI :8080 ──persist | F1.3 | 署名不一致時は HTTP 401 を返す | | F1.4 | シークレット未設定時は署名検証をスキップする | | F1.5 | `GET /health` でヘルスチェックを提供する(`{"status": "ok"}`) | -| F1.6 | Node.js standalone MCP server は、`WEBHOOK_PORT` 設定時に同一プロセスで `POST /webhook` と `GET /health` を提供できる | ### F2. イベントフィルタリング @@ -162,7 +138,7 @@ Python 実装と Node.js 実装の両方が、以下の同一ツールセット } ``` -### F5. Direct Trigger Execution(legacy Python receiver) +### F5. Direct Trigger Execution | ID | 要件 | |----|------| @@ -247,12 +223,8 @@ Python 実装と Node.js 実装の両方が、以下の同一ツールセット | N2.5 | trigger working directory | `--trigger-cwd` / `WEBHOOK_TRIGGER_CWD` | なし | | N2.6 | success 時に pending を維持するか | `--keep-pending-on-trigger-success` | false | | N2.7 | events.json パス(Node.js MCP) | `EVENTS_JSON_PATH` | `mcp-server/../events.json` | -| N2.8 | 埋め込み webhook listener の bind host(Node.js standalone) | `WEBHOOK_HOST` | `127.0.0.1` | -| N2.9 | 埋め込み webhook listener のポート(Node.js standalone) | `WEBHOOK_PORT` | 未設定(無効) | -| N2.10 | 埋め込み webhook listener のシークレット(Node.js standalone) | `WEBHOOK_SECRET` | なし(検証スキップ) | -| N2.11 | 埋め込み webhook listener のイベントプロファイル(Node.js standalone) | `WEBHOOK_EVENT_PROFILE` | `all` | -| N2.12 | 処理済みイベント保持日数 | `PURGE_AFTER_DAYS` | 1 | -| N2.13 | チャンネル通知の有効/無効 | `WEBHOOK_CHANNEL` | 有効(`0` で無効) | +| N2.8 | 処理済みイベント保持日数 | `PURGE_AFTER_DAYS` | 1 | +| N2.9 | チャンネル通知の有効/無効 | `WEBHOOK_CHANNEL` | 有効(`0` で無効) | 優先順位: CLI 引数 > 環境変数 > デフォルト Node.js MCP サーバーは環境変数のみで構成する(CLI 引数なし)。 diff --git a/mcp-server/manifest.json b/mcp-server/manifest.json index 226e287..2a90a4c 100644 --- a/mcp-server/manifest.json +++ b/mcp-server/manifest.json @@ -2,9 +2,9 @@ "manifest_version": "0.3", "name": "github-webhook-mcp", "display_name": "GitHub Webhook MCP", - "version": "0.4.0", - "description": "Browse pending GitHub webhook events and optionally receive them locally in standalone mode.", - "long_description": "GitHub Webhook MCP helps Claude review and react to GitHub notifications from a local event store. In standalone mode it can also host a local webhook listener in the same process, so the extension can receive and persist GitHub events while Claude is running.", + "version": "0.4.1", + "description": "Browse pending GitHub webhook events. Pairs with a webhook receiver that writes events.json.", + "long_description": "GitHub Webhook MCP helps Claude review and react to GitHub notifications from a local event store. It surfaces lightweight pending-event summaries, exposes full webhook payloads on demand, and lets users mark handled events as processed without exposing the event file directly.", "author": { "name": "Liplus Project", "url": "https://github.com/Liplus-Project" @@ -26,11 +26,7 @@ "${__dirname}/server/index.js" ], "env": { - "EVENTS_JSON_PATH": "${user_config.events_json_path}", - "WEBHOOK_PORT": "${user_config.webhook_port}", - "WEBHOOK_SECRET": "${user_config.webhook_secret}", - "WEBHOOK_EVENT_PROFILE": "notifications", - "WEBHOOK_HOST": "127.0.0.1" + "EVENTS_JSON_PATH": "${user_config.events_json_path}" } } }, @@ -40,18 +36,6 @@ "type": "string", "required": true, "title": "Events JSON Path" - }, - "webhook_port": { - "description": "Optional local port for the embedded webhook listener. Leave empty to disable receiving.", - "type": "string", - "required": false, - "title": "Webhook Port" - }, - "webhook_secret": { - "description": "Optional GitHub webhook secret used to verify incoming requests.", - "type": "string", - "required": false, - "title": "Webhook Secret" } }, "tools": [ diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 92241e4..3e168e1 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -1,12 +1,12 @@ { "name": "github-webhook-mcp", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-webhook-mcp", - "version": "0.4.0", + "version": "0.4.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/mcp-server/package.json b/mcp-server/package.json index da1fdb5..e8eec43 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "github-webhook-mcp", - "version": "0.4.0", + "version": "0.4.1", "description": "MCP server for browsing GitHub webhook events", "type": "module", "bin": { @@ -13,7 +13,7 @@ ], "scripts": { "start": "node server/index.js", - "test": "node --test test/*.test.js", + "test": "node --check server/index.js && node --check server/event-store.js", "pack:mcpb": "mcpb pack" }, "dependencies": { diff --git a/mcp-server/server/event-store.js b/mcp-server/server/event-store.js index 16851b3..11155a6 100644 --- a/mcp-server/server/event-store.js +++ b/mcp-server/server/event-store.js @@ -1,5 +1,4 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; -import { randomUUID } from "node:crypto"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import iconv from "iconv-lite"; @@ -59,7 +58,6 @@ export function load() { export function save(events) { const filePath = dataFilePath(); - mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, JSON.stringify(events, null, 2), PRIMARY_ENCODING); } @@ -84,20 +82,6 @@ export function getPending() { return load().filter((e) => !e.processed); } -export function addEvent(eventType, payload) { - const events = load(); - const event = { - id: randomUUID(), - type: eventType, - payload, - received_at: new Date().toISOString(), - processed: false, - }; - events.push(event); - save(events); - return event; -} - export function getEvent(eventId) { for (const event of load()) { if (event.id === eventId) return event; diff --git a/mcp-server/server/index.js b/mcp-server/server/index.js index f1b636c..6b16568 100644 --- a/mcp-server/server/index.js +++ b/mcp-server/server/index.js @@ -14,15 +14,9 @@ import { markDone, summarizeEvent, dataFilePath, - addEvent, } from "./event-store.js"; -import { startWebhookServer } from "./webhook-server.js"; const CHANNEL_ENABLED = process.env.WEBHOOK_CHANNEL !== "0"; -const WEBHOOK_HOST = process.env.WEBHOOK_HOST || "127.0.0.1"; -const WEBHOOK_PORT = Number.parseInt(process.env.WEBHOOK_PORT || "", 10); -const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || ""; -const WEBHOOK_EVENT_PROFILE = process.env.WEBHOOK_EVENT_PROFILE || "all"; const capabilities = { tools: {}, @@ -32,7 +26,7 @@ if (CHANNEL_ENABLED) { } const server = new Server( - { name: "github-webhook-mcp", version: "0.3.1" }, + { name: "github-webhook-mcp", version: "0.4.1" }, { capabilities, instructions: CHANNEL_ENABLED @@ -185,34 +179,6 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { // ── Start ─────────────────────────────────────────────────────────────────── -let embeddedWebhookServer = null; -let shuttingDown = false; - -async function shutdown(code = 0) { - if (shuttingDown) return; - shuttingDown = true; - if (embeddedWebhookServer) { - await new Promise((resolve) => embeddedWebhookServer.close(resolve)); - embeddedWebhookServer = null; - } - process.exit(code); -} - -if (Number.isFinite(WEBHOOK_PORT) && WEBHOOK_PORT > 0) { - embeddedWebhookServer = await startWebhookServer({ - host: WEBHOOK_HOST, - port: WEBHOOK_PORT, - secret: WEBHOOK_SECRET, - eventProfile: WEBHOOK_EVENT_PROFILE, - onEvent: async (eventType, payload) => addEvent(eventType, payload), - }); -} - -process.stdin.on("end", () => void shutdown(0)); -process.stdin.on("close", () => void shutdown(0)); -process.on("SIGINT", () => void shutdown(0)); -process.on("SIGTERM", () => void shutdown(0)); - const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/mcp-server/server/webhook-server.js b/mcp-server/server/webhook-server.js deleted file mode 100644 index a7491e3..0000000 --- a/mcp-server/server/webhook-server.js +++ /dev/null @@ -1,140 +0,0 @@ -import { createHmac, timingSafeEqual } from "node:crypto"; -import { createServer } from "node:http"; - -export const NOTIFICATION_EVENT_ACTIONS = { - issues: new Set(["assigned", "closed", "opened", "reopened", "unassigned"]), - issue_comment: new Set(["created"]), - pull_request: new Set([ - "assigned", - "closed", - "converted_to_draft", - "opened", - "ready_for_review", - "reopened", - "review_requested", - "review_request_removed", - "synchronize", - "unassigned", - ]), - pull_request_review: new Set(["dismissed", "submitted"]), - pull_request_review_comment: new Set(["created"]), - check_run: new Set(["completed"]), - workflow_run: new Set(["completed"]), - discussion: new Set(["answered", "closed", "created", "reopened"]), - discussion_comment: new Set(["created"]), -}; - -export function normalizeEventProfile(profile) { - const normalized = (profile || "all").trim().toLowerCase(); - if (normalized !== "all" && normalized !== "notifications") { - throw new Error(`Unknown event profile: ${profile}`); - } - return normalized; -} - -export function shouldStoreEvent(eventType, payload, profile) { - const normalized = normalizeEventProfile(profile); - if (normalized === "all") return true; - const actions = NOTIFICATION_EVENT_ACTIONS[eventType]; - if (!actions) return false; - return actions.has(payload?.action); -} - -function readRequestBody(req) { - return new Promise((resolve, reject) => { - const chunks = []; - req.on("data", (chunk) => chunks.push(chunk)); - req.on("end", () => resolve(Buffer.concat(chunks))); - req.on("error", reject); - }); -} - -function jsonResponse(res, statusCode, body) { - res.writeHead(statusCode, { - "content-type": "application/json; charset=utf-8", - }); - res.end(JSON.stringify(body)); -} - -function verifySignature(body, signatureHeader, secret) { - if (!secret) return true; - if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false; - const provided = Buffer.from(signatureHeader, "utf-8"); - const expected = Buffer.from( - `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`, - "utf-8", - ); - if (provided.length !== expected.length) return false; - return timingSafeEqual(provided, expected); -} - -export async function startWebhookServer({ - host, - port, - secret, - eventProfile, - onEvent, - logger = console, -}) { - const normalizedProfile = normalizeEventProfile(eventProfile); - const server = createServer(async (req, res) => { - try { - if (req.method === "GET" && req.url === "/health") { - jsonResponse(res, 200, { status: "ok" }); - return; - } - - if (req.method !== "POST" || req.url !== "/webhook") { - jsonResponse(res, 404, { detail: "Not found" }); - return; - } - - const body = await readRequestBody(req); - const signature = req.headers["x-hub-signature-256"]; - if (!verifySignature(body, Array.isArray(signature) ? signature[0] : signature, secret)) { - jsonResponse(res, 401, { detail: "Invalid signature" }); - return; - } - - let payload; - try { - payload = JSON.parse(body.toString("utf-8")); - } catch { - jsonResponse(res, 400, { detail: "Invalid JSON" }); - return; - } - - const eventType = Array.isArray(req.headers["x-github-event"]) - ? req.headers["x-github-event"][0] - : req.headers["x-github-event"] || ""; - - if (!shouldStoreEvent(eventType, payload, normalizedProfile)) { - jsonResponse(res, 200, { - ignored: true, - type: eventType, - profile: normalizedProfile, - }); - return; - } - - const event = await onEvent(eventType, payload); - jsonResponse(res, 200, { id: event.id, type: eventType }); - } catch (error) { - logger.error?.("[github-webhook-mcp] embedded webhook listener failed", error); - if (!res.headersSent) { - jsonResponse(res, 500, { detail: "internal_error" }); - } - } - }); - - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(port, host, resolve); - }); - - logger.error?.( - `[github-webhook-mcp] embedded webhook listener started at http://${host}:${port}`, - ); - - return server; -} diff --git a/mcp-server/test/webhook-server.test.js b/mcp-server/test/webhook-server.test.js deleted file mode 100644 index b56e637..0000000 --- a/mcp-server/test/webhook-server.test.js +++ /dev/null @@ -1,79 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createHmac } from "node:crypto"; -import { request } from "node:http"; -import { startWebhookServer, shouldStoreEvent } from "../server/webhook-server.js"; - -function httpRequest({ port, path, method = "GET", headers = {}, body = "" }) { - return new Promise((resolve, reject) => { - const req = request( - { - host: "127.0.0.1", - port, - path, - method, - headers, - }, - (res) => { - const chunks = []; - res.on("data", (chunk) => chunks.push(chunk)); - res.on("end", () => { - resolve({ - statusCode: res.statusCode, - body: Buffer.concat(chunks).toString("utf-8"), - }); - }); - }, - ); - req.on("error", reject); - if (body) req.write(body); - req.end(); - }); -} - -test("notifications profile filters noisy events", () => { - assert.equal(shouldStoreEvent("issues", { action: "opened" }, "notifications"), true); - assert.equal(shouldStoreEvent("workflow_job", { action: "completed" }, "notifications"), false); -}); - -test("embedded webhook listener verifies signature and stores allowed events", async () => { - const calls = []; - const server = await startWebhookServer({ - host: "127.0.0.1", - port: 18080, - secret: "test-secret", - eventProfile: "notifications", - onEvent: async (eventType, payload) => { - calls.push({ eventType, payload }); - return { id: "evt-1" }; - }, - logger: { error() {} }, - }); - - try { - const payload = JSON.stringify({ - action: "opened", - issue: { number: 1, title: "hello" }, - repository: { full_name: "Liplus-Project/github-webhook-mcp" }, - }); - const signature = `sha256=${createHmac("sha256", "test-secret").update(payload).digest("hex")}`; - - const response = await httpRequest({ - port: 18080, - path: "/webhook", - method: "POST", - headers: { - "content-type": "application/json", - "x-github-event": "issues", - "x-hub-signature-256": signature, - }, - body: payload, - }); - - assert.equal(response.statusCode, 200); - assert.equal(calls.length, 1); - assert.equal(calls[0].eventType, "issues"); - } finally { - await new Promise((resolve) => server.close(resolve)); - } -});