Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tangle",
"version": "0.1.13",
"version": "0.1.14",
"type": "module",
"private": true,
"scripts": {
Expand Down
1 change: 1 addition & 0 deletions src/mcp/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const CATEGORY: Record<string, Category> = {
"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",
}
Expand Down
2 changes: 2 additions & 0 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -14,6 +15,7 @@ export const tangleTools = (ctx: TangleMcpContext): Tool[] => [
...issueTools(ctx),
...pullTools(ctx),
...miscTools(ctx),
...webhookTools(ctx),
]

export type { TangleMcpContext } from "./context.ts"
Expand Down
65 changes: 65 additions & 0 deletions src/mcp/tools/webhooks.ts
Original file line number Diff line number Diff line change
@@ -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
},
}),
]
88 changes: 63 additions & 25 deletions src/webhooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<CreateWebhookResult> => {
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)
Expand Down Expand Up @@ -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<unknown>
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) => {
Expand Down
Loading