feat: phase 4 integrations — connectors, rybbit analytics, webhooks

- core.integrations (migration 00005) with AES-256-GCM-encrypted
  credentials (PRIMORA_ENCRYPTION_KEY, hex/base64, dev fallback);
  has_credentials serialization only — secrets never leave the server
- /projects/:id/integrations list/create/delete/test; health check hits
  the stored base URL with a 5s timeout and records status
- Rybbit connector: GET …/integrations/:id/analytics proxies
  /api/organizations + /api/sites/* server-side into a normalized shape
- core.webhooks + core.webhook_deliveries; CRUD, deliveries, test, and
  POST …/deploy-markers. Events: issue.created (new fingerprint on
  ingest), deploy.marker, webhook.test. X-Primora-Signature HMAC-SHA256
  over the raw body; in-process dispatcher, 3 attempts, quadratic
  backoff, 10s timeout, pending-scan on boot
- HTTPS enforced for public targets; HTTP allowed for loopback/private/
  self-hosted sinks
- Integrations page (connectors + webhooks tabs, inline analytics,
  delivery log, secret-shown-once), demo-mode stubs
- 12 new primora_* MCP tools
- workspace 0.4.0

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-09-16 18:47:31 +02:00
1 parent 8cfddf9184
commit e25eb92339
82 files changed
+5710 -61

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@primora/mcp",
"version": "0.3.0",
"version": "0.4.0",
"publishConfig": {
"access": "public"
},
@@ -22,7 +22,7 @@
"zod": "^4.1.5"
},
"devDependencies": {
"@primora/api-client": "0.3.0",
"@primora/api-client": "0.4.0",
"@types/node": "^24.0.0",
"esbuild": "^0.27.4",
"typescript": "^5.9.2"
+280 -1
View File
@@ -4,12 +4,14 @@ import { z } from "zod";
import {
CreateBucketRequest,
IntegrationsService,
OpenAPI,
OrganizationsService,
PlatformService,
ProjectsService,
StorageService,
TelemetryService,
WebhooksService,
} from "@primora/api-client";
import { configureClient, loadConfig, type McpConfig } from "./config.js";
@@ -69,7 +71,7 @@ async function apiFetch(path: string): Promise<Response> {
return res;
}
const server = new McpServer({ name: "primora", version: "0.2.0" });
const server = new McpServer({ name: "primora", version: "0.4.0" });
server.registerTool(
"primora_whoami",
@@ -453,4 +455,281 @@ server.registerTool(
},
);
/* ---------------------------------------------------------- */
/* integrations */
/* ---------------------------------------------------------- */
const integrationRef = z.string().describe("Integration id (see primora_list_integrations)");
server.registerTool(
"primora_list_integrations",
{
description: "List external service connectors attached to a project",
inputSchema: { projectId: z.string().optional() },
},
async ({ projectId: p }) => {
try {
return ok(await IntegrationsService.listIntegrations({ projectId: projectId(p) }));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_create_integration",
{
description:
"Attach an external service. Credentials are stored encrypted and never returned.",
inputSchema: {
name: z.string(),
baseUrl: z.string().describe("Root URL of the self-hosted instance"),
apiKey: z.string().optional().describe("Stored encrypted; never returned by any endpoint"),
siteId: z.string().optional().describe("Connector-specific target (Rybbit site id)"),
projectId: z.string().optional(),
},
},
async ({ name, baseUrl, apiKey, siteId, projectId: p }) => {
try {
return ok(
await IntegrationsService.createIntegration({
projectId: projectId(p),
requestBody: {
name,
type: "rybbit",
base_url: baseUrl,
api_key: apiKey,
site_id: siteId,
},
}),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_delete_integration",
{
description: "Remove a connector from the project",
inputSchema: { integrationId: integrationRef, projectId: z.string().optional() },
},
async ({ integrationId, projectId: p }) => {
try {
await IntegrationsService.deleteIntegration({ projectId: projectId(p), integrationId });
return ok({ deleted: integrationId });
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_test_integration",
{
description: "Run the connector health check against its base URL and update its status",
inputSchema: { integrationId: integrationRef, projectId: z.string().optional() },
},
async ({ integrationId, projectId: p }) => {
try {
return ok(await IntegrationsService.testIntegration({ projectId: projectId(p), integrationId }));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_integration_analytics",
{
description:
"Normalized analytics from a Rybbit connector — overview, series, top pages and referrers",
inputSchema: {
integrationId: integrationRef,
site: z.string().optional().describe("Rybbit site id override"),
days: z.number().int().optional().describe("Lookback window (default 30)"),
projectId: z.string().optional(),
},
},
async ({ integrationId, site, days, projectId: p }) => {
try {
return ok(
await IntegrationsService.getIntegrationAnalytics({
projectId: projectId(p),
integrationId,
site,
days,
}),
);
} catch (e) {
return fail(e);
}
},
);
/* ---------------------------------------------------------- */
/* webhooks */
/* ---------------------------------------------------------- */
const webhookRef = z.string().describe("Webhook id (see primora_list_webhooks)");
const webhookEvents = z
.array(z.enum(["issue.created", "deploy.marker", "webhook.test"]))
.describe("Event filter — empty means every event");
server.registerTool(
"primora_list_webhooks",
{
description: "List outbound webhooks for a project",
inputSchema: { projectId: z.string().optional() },
},
async ({ projectId: p }) => {
try {
return ok(await WebhooksService.listWebhooks({ projectId: projectId(p) }));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_create_webhook",
{
description:
"Create an outbound webhook. Omit secret to generate one — it is returned once.",
inputSchema: {
url: z.string().describe("HTTPS endpoint (http allowed only for loopback sinks)"),
secret: z.string().optional(),
events: webhookEvents.optional(),
enabled: z.boolean().optional(),
projectId: z.string().optional(),
},
},
async ({ url, secret, events, enabled, projectId: p }) => {
try {
return ok(
await WebhooksService.createWebhook({
projectId: projectId(p),
requestBody: { url, secret, events, enabled },
}),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_update_webhook",
{
description:
"Update a webhook — url, event filter, enabled flag, or rotate the secret (empty string regenerates it)",
inputSchema: {
webhookId: webhookRef,
url: z.string().optional(),
secret: z.string().optional(),
events: webhookEvents.optional(),
enabled: z.boolean().optional(),
projectId: z.string().optional(),
},
},
async ({ webhookId, url, secret, events, enabled, projectId: p }) => {
try {
return ok(
await WebhooksService.updateWebhook({
projectId: projectId(p),
webhookId,
requestBody: { url, secret, events, enabled },
}),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_delete_webhook",
{
description: "Delete a webhook and stop future deliveries",
inputSchema: { webhookId: webhookRef, projectId: z.string().optional() },
},
async ({ webhookId, projectId: p }) => {
try {
await WebhooksService.deleteWebhook({ projectId: projectId(p), webhookId });
return ok({ deleted: webhookId });
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_list_webhook_deliveries",
{
description: "Recent delivery attempts for a webhook — status, attempts, HTTP code, errors",
inputSchema: {
webhookId: webhookRef,
limit: z.number().int().optional(),
projectId: z.string().optional(),
},
},
async ({ webhookId, limit, projectId: p }) => {
try {
return ok(
await WebhooksService.listWebhookDeliveries({
projectId: projectId(p),
webhookId,
limit,
}),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_test_webhook",
{
description: "Queue a webhook.test delivery to verify the endpoint and signature",
inputSchema: { webhookId: webhookRef, projectId: z.string().optional() },
},
async ({ webhookId, projectId: p }) => {
try {
return ok(await WebhooksService.testWebhook({ projectId: projectId(p), webhookId }));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_create_deploy_marker",
{
description:
"Record a deployment (version/ref/environment) and fire the deploy.marker webhook event",
inputSchema: {
version: z.string().optional(),
ref: z.string().optional().describe("Git sha or tag"),
environment: z.string().optional(),
note: z.string().optional(),
projectId: z.string().optional(),
},
},
async ({ version, ref, environment, note, projectId: p }) => {
try {
if (!version && !ref) throw new Error("pass version or ref");
return ok(
await WebhooksService.createDeployMarker({
projectId: projectId(p),
requestBody: { version, ref, environment, note },
}),
);
} catch (e) {
return fail(e);
}
},
);
await server.connect(new StdioServerTransport());