From 3e3bd590624f86ad2f657f36dbe9b9a007c7b1ee Mon Sep 17 00:00:00 2001 From: Wess Cope Date: Mon, 8 Jun 2026 13:26:57 -0400 Subject: [PATCH] feat: tangle.webhooks.create MCP tool --- package.json | 2 +- src/mcp/http.ts | 1 + src/mcp/index.ts | 2 + src/mcp/tools/webhooks.ts | 65 +++++++++++++++++++++++++++++ src/webhooks/index.ts | 88 ++++++++++++++++++++++++++++----------- 5 files changed, 132 insertions(+), 26 deletions(-) create mode 100644 src/mcp/tools/webhooks.ts diff --git a/package.json b/package.json index 2740815..bf4f230 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tangle", - "version": "0.1.13", + "version": "0.1.14", "type": "module", "private": true, "scripts": { diff --git a/src/mcp/http.ts b/src/mcp/http.ts index c631b5d..315b913 100644 --- a/src/mcp/http.ts +++ b/src/mcp/http.ts @@ -55,6 +55,7 @@ const CATEGORY: Record = { "tangle.repos.set_mirror": "write", "tangle.users.me": "read", "tangle.users.search": "read", + "tangle.webhooks.create": "write", "tangle.webhooks.deliveries": "read", "tangle.webhooks.list": "read", } diff --git a/src/mcp/index.ts b/src/mcp/index.ts index dbe4bce..6b52511 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -5,6 +5,7 @@ import { browseTools } from "./tools/browse.ts" import { issueTools } from "./tools/issues.ts" import { pullTools } from "./tools/pulls.ts" import { miscTools } from "./tools/misc.ts" +import { webhookTools } from "./tools/webhooks.ts" // All Tangle-domain MCP tools, ready to mix with atlas's built-in // (db.query, migrate.*, health.check, …) tools in the entry script. @@ -14,6 +15,7 @@ export const tangleTools = (ctx: TangleMcpContext): Tool[] => [ ...issueTools(ctx), ...pullTools(ctx), ...miscTools(ctx), + ...webhookTools(ctx), ] export type { TangleMcpContext } from "./context.ts" diff --git a/src/mcp/tools/webhooks.ts b/src/mcp/tools/webhooks.ts new file mode 100644 index 0000000..6ec7b70 --- /dev/null +++ b/src/mcp/tools/webhooks.ts @@ -0,0 +1,65 @@ +import { defineTool } from "@atlas/mcp" +import { findRepo, resolveRepoAccess } from "../../permissions/index.ts" +import { createWebhook, VALID_EVENTS } from "../../webhooks/index.ts" +import type { TangleMcpContext } from "../context.ts" + +const requireUser = (ctx: TangleMcpContext): number => { + if (ctx.userId === null) throw new Error("This tool requires authentication. Set TANGLE_MCP_USER.") + return ctx.userId +} + +// Accept either a JSON array (some MCP clients send arrays even when the +// schema advertises a string) or a comma/space-separated string. Omitted +// -> ["push"], the sensible default for wiring a push integration. The +// createWebhook core does the real per-event validation. +const normalizeEvents = (events: unknown): string[] => { + if (events === undefined || events === null) return ["push"] + if (Array.isArray(events)) return events.map(e => String(e).trim()).filter(Boolean) + return String(events) + .split(/[\s,]+/) + .map(e => e.trim()) + .filter(Boolean) +} + +// Write-category webhook tools. `tangle.webhooks.create` calls the same +// in-process createWebhook core the REST route uses, so an agent can wire +// an outbound webhook (e.g. tangle -> kettle push) over MCP without +// touching the DB directly. The list/deliveries read tools live in +// misc.ts alongside the other small read helpers. +export const webhookTools = (ctx: TangleMcpContext) => [ + defineTool({ + name: "tangle.webhooks.create", + description: + "Create an outbound webhook on a repository (admin access required). " + + `events defaults to ["push"]; valid events: [${[...VALID_EVENTS].join(", ")}].`, + inputSchema: { + type: "object", + properties: { + owner: { type: "string" }, + repo: { type: "string", description: "The repository name." }, + url: { type: "string", description: "Target URL. http(s); http is allowed for LAN targets." }, + events: { + type: "string", + description: 'Comma-separated event subscriptions. Defaults to "push" when omitted.', + }, + secret: { type: "string", description: "Optional HMAC signing secret (X-Tangle-Signature)." }, + }, + required: ["owner", "repo", "url"], + }, + handler: async ({ owner, repo, url, events, secret }: any) => { + const userId = requireUser(ctx) + const found = await findRepo(ctx.db, String(owner), String(repo)) + if (!found) throw new Error(`Repo ${owner}/${repo} not found`) + const access = await resolveRepoAccess(ctx.db, found, userId) + if (!access.admin) throw new Error("Repo admin access required") + + const result = await createWebhook(ctx.db, found.id, userId, { + url: typeof url === "string" ? url : undefined, + events: normalizeEvents(events), + secret: typeof secret === "string" ? secret : null, + }) + if (!result.ok) throw new Error(result.message) + return result.webhook + }, + }), +] diff --git a/src/webhooks/index.ts b/src/webhooks/index.ts index 74d9dcd..fff78e3 100644 --- a/src/webhooks/index.ts +++ b/src/webhooks/index.ts @@ -7,7 +7,7 @@ import { apiError } from "../util/errors.ts" const authId = (c: any) => (c.assigns.auth as { id: number }).id -const VALID_EVENTS = new Set(["push", "issues", "pull_request", "release", "star", "status"]) +export const VALID_EVENTS = new Set(["push", "issues", "pull_request", "release", "star", "status"]) const validateEvents = (events: unknown): string[] | null => { if (!Array.isArray(events)) return null if (events.length === 0) return null @@ -31,6 +31,64 @@ const isHttpsOrLocal = (url: string): boolean => { } catch { return false } } +export type WebhookRecord = { + id: number + url: string + content_type: string + events: string + active: boolean + created_at: string +} + +export type CreateWebhookInput = { + url?: string + secret?: string | null + content_type?: string + contentType?: string + events?: unknown +} + +export type CreateWebhookResult = + | { ok: true; webhook: WebhookRecord } + | { ok: false; message: string } + +// In-process webhook creation shared by the REST route and the MCP +// `tangle.webhooks.create` tool. Callers are responsible for resolving +// the repo and enforcing admin access first; this only validates the +// payload and inserts the row. Errors come back as a discriminated +// result so each transport can map them to its own envelope (apiError +// for REST, a thrown Error for MCP) without this module importing either. +export const createWebhook = async ( + db: Connection, + repoId: number, + createdBy: number, + input: CreateWebhookInput, +): Promise => { + const url = input.url?.trim() + if (!url || !isHttpsOrLocal(url)) return { ok: false, message: "url must be a valid http(s) URL" } + const events = validateEvents(input.events) + if (!events) { + return { ok: false, message: `events must be a non-empty subset of [${[...VALID_EVENTS].join(", ")}]` } + } + const contentType = (input.content_type ?? input.contentType ?? "application/json").trim() + if (contentType !== "application/json" && contentType !== "application/x-www-form-urlencoded") { + return { ok: false, message: "content_type must be application/json or application/x-www-form-urlencoded" } + } + + const inserted = await db.execute( + from("webhooks").insert({ + repo_id: repoId, + url, + secret: input.secret?.trim() || null, + content_type: contentType, + events: JSON.stringify(events), + active: true, + created_by: createdBy, + }).returning("id", "url", "content_type", "events", "active", "created_at"), + ) as WebhookRecord[] + return { ok: true, webhook: inserted[0] } +} + export const webhookRoutes = (db: Connection, secret: string) => { const guard = pipeline(requireAuth({ secret, db })) const authed = pipeline(requireAuth({ secret, db }), parseJson) @@ -58,30 +116,10 @@ export const webhookRoutes = (db: Connection, secret: string) => { const access = await resolveRepoAccess(db, repo, userId) if (!access.admin) return apiError(c, "forbidden", "Repo admin access required") - const body = c.body as { url?: string; secret?: string; content_type?: string; contentType?: string; events?: unknown } - const url = body.url?.trim() - if (!url || !isHttpsOrLocal(url)) return apiError(c, "validation", "url must be a valid http(s) URL") - const events = validateEvents(body.events) - if (!events) { - return apiError(c, "validation", `events must be a non-empty subset of [${[...VALID_EVENTS].join(", ")}]`) - } - const contentType = (body.content_type ?? body.contentType ?? "application/json").trim() - if (contentType !== "application/json" && contentType !== "application/x-www-form-urlencoded") { - return apiError(c, "validation", "content_type must be application/json or application/x-www-form-urlencoded") - } - - const inserted = await db.execute( - from("webhooks").insert({ - repo_id: repo.id, - url, - secret: body.secret?.trim() || null, - content_type: contentType, - events: JSON.stringify(events), - active: true, - created_by: userId, - }).returning("id", "url", "content_type", "events", "active", "created_at"), - ) as Array - return json(c, 201, inserted[0]) + const body = c.body as CreateWebhookInput + const result = await createWebhook(db, repo.id, userId, body) + if (!result.ok) return apiError(c, "validation", result.message) + return json(c, 201, result.webhook) })), patch("/repos/:owner/:name/webhooks/:id", authed(async (c) => {