From aeca96c80d21eb974b498d997d12f1c90bfe32e0 Mon Sep 17 00:00:00 2001 From: liplus-lin-lay <259586417+liplus-lin-lay@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:10:22 +0900 Subject: [PATCH] feat(standalone): embed webhook listener in node mcp server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node MCP起動中のみローカルwebhook listenerを同居起動する構成へ寄せ、requirements・README・manifestもスタンドアロン前提に更新した。\n\n#37 --- README.md | 73 +++++++++++-- docs/0-requirements.md | 52 ++++++--- mcp-server/manifest.json | 22 +++- mcp-server/package.json | 2 +- mcp-server/server/event-store.js | 18 +++- mcp-server/server/index.js | 36 ++++++- mcp-server/server/webhook-server.js | 140 +++++++++++++++++++++++++ mcp-server/test/webhook-server.test.js | 79 ++++++++++++++ 8 files changed, 397 insertions(+), 25 deletions(-) create mode 100644 mcp-server/server/webhook-server.js create mode 100644 mcp-server/test/webhook-server.test.js diff --git a/README.md b/README.md index 1478065..453e350 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,15 @@ 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. @@ -26,6 +29,7 @@ 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 @@ -38,7 +42,10 @@ 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" + "EVENTS_JSON_PATH": "/path/to/events.json", + "WEBHOOK_PORT": "8080", + "WEBHOOK_SECRET": "your_secret", + "WEBHOOK_EVENT_PROFILE": "notifications" } } } @@ -54,6 +61,9 @@ 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) @@ -71,19 +81,68 @@ EVENTS_JSON_PATH = "/path/to/events.json" ## Configuration -### 1. Install receiver dependencies +### 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 ```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 @@ -93,7 +152,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` @@ -111,7 +170,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. @@ -131,7 +190,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 ^ @@ -30,19 +51,21 @@ GitHub ──POST──> Cloudflare Tunnel ──> FastAPI :8080 ──persist AI Agent (Codex / Claude) optional trigger command ``` -システムは二つのコンポーネントで構成される: +常駐サービスや direct trigger queue が必要な場合は、従来どおり Python webhook receiver を別プロセスで起動できる。 + +### Components -1. **Webhook 受信サーバー(Python)** — `python main.py webhook` +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` - HTTP 受信、イベント永続化、optional な direct trigger queue を担当 - trigger command が設定されている場合、保存済みイベントごとに直列実行される -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 向け)。 +3. **Legacy MCP server(Python 互換)** — `python main.py mcp` + - 従来互換用の stdio MCP 実装 ## Functional Requirements @@ -55,6 +78,7 @@ Node.js 実装は独立パッケージとして配布される(MCPB: Claude De | 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. イベントフィルタリング @@ -138,7 +162,7 @@ Python 実装と Node.js 実装の両方が、以下の同一ツールセット } ``` -### F5. Direct Trigger Execution +### F5. Direct Trigger Execution(legacy Python receiver) | ID | 要件 | |----|------| @@ -223,8 +247,12 @@ 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 | 処理済みイベント保持日数 | `PURGE_AFTER_DAYS` | 1 | -| N2.9 | チャンネル通知の有効/無効 | `WEBHOOK_CHANNEL` | 有効(`0` で無効) | +| 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` で無効) | 優先順位: CLI 引数 > 環境変数 > デフォルト Node.js MCP サーバーは環境変数のみで構成する(CLI 引数なし)。 diff --git a/mcp-server/manifest.json b/mcp-server/manifest.json index 70bb1be..4b5ef8a 100644 --- a/mcp-server/manifest.json +++ b/mcp-server/manifest.json @@ -3,8 +3,8 @@ "name": "github-webhook-mcp", "display_name": "GitHub Webhook MCP", "version": "0.3.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.", + "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.", "author": { "name": "Liplus Project", "url": "https://github.com/Liplus-Project" @@ -26,7 +26,11 @@ "${__dirname}/server/index.js" ], "env": { - "EVENTS_JSON_PATH": "${user_config.events_json_path}" + "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" } } }, @@ -36,6 +40,18 @@ "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.json b/mcp-server/package.json index 3b1fb2f..6b19e0c 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -13,7 +13,7 @@ ], "scripts": { "start": "node server/index.js", - "test": "node --test test/", + "test": "node --test test/*.test.js", "pack:mcpb": "mcpb pack" }, "dependencies": { diff --git a/mcp-server/server/event-store.js b/mcp-server/server/event-store.js index 11155a6..16851b3 100644 --- a/mcp-server/server/event-store.js +++ b/mcp-server/server/event-store.js @@ -1,4 +1,5 @@ -import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { randomUUID } from "node:crypto"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import iconv from "iconv-lite"; @@ -58,6 +59,7 @@ export function load() { export function save(events) { const filePath = dataFilePath(); + mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, JSON.stringify(events, null, 2), PRIMARY_ENCODING); } @@ -82,6 +84,20 @@ 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 3c0f40f..f1b636c 100644 --- a/mcp-server/server/index.js +++ b/mcp-server/server/index.js @@ -14,9 +14,15 @@ 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: {}, @@ -26,7 +32,7 @@ if (CHANNEL_ENABLED) { } const server = new Server( - { name: "github-webhook-mcp", version: "0.3.0" }, + { name: "github-webhook-mcp", version: "0.3.1" }, { capabilities, instructions: CHANNEL_ENABLED @@ -179,6 +185,34 @@ 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 new file mode 100644 index 0000000..a7491e3 --- /dev/null +++ b/mcp-server/server/webhook-server.js @@ -0,0 +1,140 @@ +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 new file mode 100644 index 0000000..b56e637 --- /dev/null +++ b/mcp-server/test/webhook-server.test.js @@ -0,0 +1,79 @@ +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)); + } +});