Skip to content

Commit a80ac05

Browse files
JITSU-66: MCP tools for syncs, test/debug, and remaining management-API surface (#1385)
Follow-up to #1371 (config CRUD + live events) and the JITSU-66 API-key auth commits. Implements the remaining tool surface from `JITSU-66`. ## What's added Three new DI service classes (same pattern as `ConfigObjectsService` / `EventsLogService`; constructor-injected `prisma` / `pgPool` / `clickhouse`, shared helpers like `scheduleSync` reused rather than reimplemented), plus 16 new MCP tools on top of them: **`SyncService`** (`lib/server/sync-service.ts`, from `sources/*` routes) - `run_sync` (with `fullSync` / `ignoreRunning`), `cancel_sync`, `list_sync_tasks`, `get_sync_logs` (structured rows, ClickHouse with Postgres fallback — the non-streaming counterpart of `sources/logs.ts`) - `get_connector_spec`, `discover_streams` — async-poll on the sync controller; tool descriptions document the poll pattern - `check_source_credentials` / `get_source_check_result` — the async `/sources/check` flow, using the service's stored (unmasked) config; the storage key is derived server-side and workspace-scoped - `get_sync_state` / `reset_sync_state` (single stream or all; refuses while the sync is running) **`DebugService`** (`lib/server/debug-service.ts`, from `config/[type]/test.ts`, `function/run.ts`, `profile-builder/run.ts`) - `test_connection` — synchronous bulker `/test`; accepts an existing object `id` or a full `config` (masked secrets resolved from the stored object) - `run_function` / `run_profile_builder` — debug runs on the workspace functions server; `code`/`settings` default to the stored draft/config so an agent can exercise what's deployed **`ReportsService`** (`lib/server/reports-service.ts`, from `reports/*` + `profile-builder/stats.ts`) - `get_event_stat`, `get_sync_stat`, `get_profile_builder_stats` `registerTools` now also receives the inbound request (scheduleSync derives the app base URL and EE quota auth from it). ## Tool annotations All 25 tools (the 9 existing + 16 new) carry standard MCP tool annotations (`readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint`, spec rev 2025-03-26) so clients can skip confirmation for safe tools and warn on dangerous ones. Without them the spec defaults make every tool look destructive. The split: - **read-only** (13): all `list_*` / `get_*` / `query_*` tools - **safe, repeatable probes** (4): `get_connector_spec`, `discover_streams`, `check_source_credentials`, `test_connection` — trigger work but write only internal caches; marked open-world (the connector contacts the user's source/destination) - **sandboxed runs** (2): `run_function`, `run_profile_builder` — persist nothing, but execute user function code (open-world) - **mutating, non-destructive** (3): `create_resource`, `run_sync`, `cancel_sync` - **destructive** (3): `update_resource`, `delete_resource`, `reset_sync_state` The annotations also drive runtime behavior: tools not marked read-only are rejected while maintenance mode is active, mirroring the write block `createRoute` applies to HTTP routes. ## Tests No unit tests yet: the console has no harness for testing service classes against real databases, and mocking prisma/pg is not worth it. `JITSU-90` tracks a testcontainers-based harness (real Postgres + ClickHouse); tests for these services will come with it. The existing console suite passes (49). The HTTP routes are intentionally untouched — migrating them onto these services is `JITSU-91`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L3JqeGCtMegoGyVMrwT8Pa
2 parents 36ea45b + b1a4636 commit a80ac05

5 files changed

Lines changed: 1343 additions & 14 deletions

File tree

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import type { PrismaClient } from "@prisma/client";
2+
import { getErrorMessage, randomId, requireDefined, rpc } from "juava";
3+
import { SessionUser } from "../schema";
4+
import { verifyAccess, verifyAccessWithRole } from "../api";
5+
import { ApiError } from "../shared/errors";
6+
import { getConfigObjectType, parseObject } from "../schema/config-objects";
7+
import { containsMaskedSecrets, unmaskSecretsFromOriginal } from "../schema/secrets";
8+
import { httpAgent, httpsAgent } from "./http-agent";
9+
import { getServerEnv } from "./serverEnv";
10+
import { getServerLog } from "./log";
11+
12+
const log = getServerLog("debug-service");
13+
14+
export interface DebugServiceDeps {
15+
prisma: PrismaClient;
16+
}
17+
18+
// Class priority order: premium > dedicated > free (same as function/run.ts).
19+
const classPriority = ["premium", "dedicated", "free"];
20+
21+
export type FunctionRunResult = {
22+
error?: { name: string; message: string; stack?: string; retryPolicy?: any };
23+
dropped?: boolean;
24+
result?: any;
25+
store: Record<string, any>;
26+
logs: any[];
27+
meta?: any;
28+
backend?: string;
29+
};
30+
31+
const runtimeNotReady = (what: string): FunctionRunResult => ({
32+
error: {
33+
name: "FunctionRuntimeNotReady",
34+
message: `${what} for this workspace is being initialized. Please try again in a few minutes. If this message persists, please contact support.`,
35+
},
36+
store: {},
37+
logs: [],
38+
});
39+
40+
/**
41+
* Test/debug operations extracted from `config/[type]/test.ts` (bulker credential test),
42+
* `function/run.ts`, and `profile-builder/run.ts` so the MCP server shares one implementation
43+
* of secret unmasking, input filtering, and functions-server dispatch. When `code` isn't
44+
* supplied, the run methods load the stored draft/code of the referenced function so an agent
45+
* can exercise what's deployed without round-tripping the source.
46+
*/
47+
export class DebugService {
48+
private readonly prisma: PrismaClient;
49+
50+
constructor(deps: DebugServiceDeps) {
51+
this.prisma = deps.prisma;
52+
}
53+
54+
/**
55+
* Synchronous credential test via bulker `/test` (mirrors `config/[type]/test.ts`). Either
56+
* pass a full `config` (masked secrets are unmasked from the stored object referenced by
57+
* `config.id`/`config.cloneId`) or just `id` to test an existing object as stored.
58+
*/
59+
async testConnection(
60+
user: SessionUser,
61+
workspaceId: string,
62+
type: string,
63+
opts: { config?: any; id?: string }
64+
): Promise<any> {
65+
await verifyAccess(user, workspaceId);
66+
const serverEnv = getServerEnv();
67+
const bulkerURLEnv = requireDefined(serverEnv.BULKER_URL, "env BULKER_URL is not defined");
68+
const bulkerAuthKey = serverEnv.BULKER_AUTH_KEY ?? "";
69+
const isHttps = bulkerURLEnv.startsWith("https://");
70+
const workspace = requireDefined(
71+
await this.prisma.workspace.findFirst({ where: { id: workspaceId } }),
72+
`Workspace ${workspaceId} not found`
73+
);
74+
let body = opts.config;
75+
if (!body) {
76+
const id = requireDefined(opts.id, "either `config` or `id` must be provided");
77+
const existing = await this.prisma.configurationObject.findFirst({
78+
where: { id, workspaceId, type, deleted: false },
79+
});
80+
if (!existing) {
81+
throw new ApiError(`${type} with id ${id} does not exist`, {}, { status: 404 });
82+
}
83+
body = { ...(existing.config as any), id };
84+
}
85+
body = { ...body, workspaceId, type, id: body.id ?? randomId() };
86+
const configObjectType = getConfigObjectType(type);
87+
let object = parseObject(type, body);
88+
if (containsMaskedSecrets(object)) {
89+
const existingEntity = await this.prisma.configurationObject.findFirst({
90+
where: { id: body.cloneId || body.id, workspaceId },
91+
});
92+
if (existingEntity?.config) {
93+
log.atInfo().log(`Unmasking secrets for ${type} test: ${body.id}`);
94+
object = unmaskSecretsFromOriginal(object, existingEntity.config as any);
95+
}
96+
}
97+
object = await configObjectType.inputFilter(object, "create", workspace);
98+
99+
const options: any = {
100+
method: "POST",
101+
agent: (isHttps ? httpsAgent : httpAgent)(),
102+
headers: { "Content-Type": "application/json" },
103+
body: JSON.stringify(object),
104+
};
105+
if (bulkerAuthKey) {
106+
options.headers["Authorization"] = `Bearer ${bulkerAuthKey}`;
107+
}
108+
try {
109+
const response = await fetch(bulkerURLEnv + "/test", options);
110+
return await response.json();
111+
} catch (e) {
112+
throw new ApiError(`failed to fetch bulker API: ${getErrorMessage(e)}`, {}, { status: 500 });
113+
}
114+
}
115+
116+
/** Mirrors `function/run.ts`. `code` omitted → the stored function's draft (falls back to code). */
117+
async runFunction(
118+
user: SessionUser,
119+
workspaceId: string,
120+
opts: {
121+
functionId: string;
122+
code?: string;
123+
event: any;
124+
variables?: any;
125+
store?: any;
126+
userAgent?: string;
127+
}
128+
): Promise<FunctionRunResult> {
129+
await verifyAccessWithRole(user, workspaceId, "editEntities");
130+
let code = opts.code;
131+
let functionName: string | undefined;
132+
if (code === undefined) {
133+
const func = await this.prisma.configurationObject.findFirst({
134+
where: { id: opts.functionId, workspaceId, type: "function", deleted: false },
135+
});
136+
if (!func) {
137+
throw new ApiError(`function with id ${opts.functionId} does not exist`, {}, { status: 404 });
138+
}
139+
const cfg = func.config as any;
140+
code = requireDefined(cfg.draft ?? cfg.code, `function ${opts.functionId} has no code`);
141+
functionName = cfg.name;
142+
}
143+
const deploymentId = await this.getDeploymentId(workspaceId);
144+
if (!deploymentId) {
145+
return runtimeNotReady("Function runtime");
146+
}
147+
const url = this.functionsServerUrl(deploymentId, "/udfrun");
148+
log.atInfo().log(`Running function ${opts.functionId} for workspace ${workspaceId} via ${url}`);
149+
return await rpc(url, {
150+
method: "POST",
151+
body: {
152+
functionId: opts.functionId,
153+
functionName,
154+
code,
155+
event: opts.event,
156+
variables: opts.variables ?? {},
157+
store: opts.store ?? {},
158+
userAgent: opts.userAgent,
159+
workspaceId,
160+
},
161+
headers: this.rotorAuthHeaders(),
162+
});
163+
}
164+
165+
/**
166+
* Mirrors `profile-builder/run.ts`. `code`/`settings`/`version` omitted → loaded from the
167+
* stored profile builder (its function's draft/code and `connectionOptions`).
168+
*/
169+
async runProfileBuilder(
170+
user: SessionUser,
171+
workspaceId: string,
172+
opts: {
173+
profileBuilderId: string;
174+
events: any[];
175+
code?: string;
176+
settings?: any;
177+
version?: number;
178+
store?: any;
179+
userAgent?: string;
180+
}
181+
): Promise<FunctionRunResult> {
182+
await verifyAccessWithRole(user, workspaceId, "editEntities");
183+
const pb = await this.prisma.profileBuilder.findFirst({
184+
where: { id: opts.profileBuilderId, workspaceId, deleted: false },
185+
include: { functions: { include: { function: true } } },
186+
});
187+
if (!pb) {
188+
throw new ApiError(`profile builder with id ${opts.profileBuilderId} does not exist`, {}, { status: 404 });
189+
}
190+
let code = opts.code;
191+
if (code === undefined) {
192+
const funcConfig = pb.functions[0]?.function?.config as any;
193+
code = requireDefined(
194+
funcConfig?.draft ?? funcConfig?.code,
195+
`profile builder ${opts.profileBuilderId} has no function code — pass \`code\` explicitly`
196+
);
197+
}
198+
const deploymentId = await this.getDeploymentId(workspaceId);
199+
if (!deploymentId) {
200+
return runtimeNotReady("Profile builder runtime");
201+
}
202+
const url = this.profileBuilderRunUrl(deploymentId);
203+
log.atInfo().log(`Running profile builder ${opts.profileBuilderId} for workspace ${workspaceId} via ${url}`);
204+
return await rpc(url, {
205+
method: "POST",
206+
body: {
207+
id: pb.id,
208+
name: pb.name,
209+
version: opts.version ?? pb.version,
210+
code,
211+
events: opts.events,
212+
settings: opts.settings ?? pb.connectionOptions ?? {},
213+
store: opts.store ?? {},
214+
userAgent: opts.userAgent,
215+
workspaceId,
216+
},
217+
headers: this.rotorAuthHeaders(),
218+
});
219+
}
220+
221+
/** Highest-priority functions-server deployment for the workspace (premium > dedicated > free). */
222+
private async getDeploymentId(workspaceId: string): Promise<string | undefined> {
223+
const records = await this.prisma.functionsServer.findMany({
224+
where: { workspaceId },
225+
select: { class: true, deploymentId: true },
226+
});
227+
for (const cls of classPriority) {
228+
const record = records.find(r => r.class === cls);
229+
if (record?.deploymentId) {
230+
return record.deploymentId;
231+
}
232+
}
233+
return undefined;
234+
}
235+
236+
private functionsServerUrl(deploymentId: string, path: string): string {
237+
const template = requireDefined(
238+
getServerEnv().FUNCTIONS_SERVER_URL_TEMPLATE,
239+
"env FUNCTIONS_SERVER_URL_TEMPLATE is not set. Functions server is required to run functions"
240+
);
241+
return template.replace("${workspaceId}", deploymentId) + path;
242+
}
243+
244+
// profile-builder/run.ts keeps a ROTOR_URL fallback for installations without
245+
// per-workspace functions servers — preserve it.
246+
private profileBuilderRunUrl(deploymentId: string): string {
247+
const serverEnv = getServerEnv();
248+
if (!serverEnv.FUNCTIONS_SERVER_URL_TEMPLATE) {
249+
const rotorURL = requireDefined(
250+
serverEnv.ROTOR_URL,
251+
`env ROTOR_URL is not set. Rotor is required to run functions`
252+
);
253+
return rotorURL + "/profileudfrun";
254+
}
255+
return this.functionsServerUrl(deploymentId, "/profileudfrun");
256+
}
257+
258+
private rotorAuthHeaders(): Record<string, string> {
259+
const rotorAuthKey = getServerEnv().ROTOR_AUTH_KEY;
260+
const headers: Record<string, string> = { "Content-Type": "application/json" };
261+
if (rotorAuthKey) {
262+
headers["Authorization"] = `Bearer ${rotorAuthKey}`;
263+
}
264+
return headers;
265+
}
266+
}

webapps/console/lib/server/mcp-server/index.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { NextApiRequest, NextApiResponse } from "next";
22
import type { PrismaClient } from "@prisma/client";
33
import type { ClickHouseClient } from "@clickhouse/client";
4+
import type { Pool } from "pg";
45
import { McpServer as SdkMcpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
56
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
67
import { db } from "../db";
@@ -12,6 +13,9 @@ import { OAuthHandlers } from "./oauth";
1213
import { registerTools } from "./tools";
1314
import { ConfigObjectsService } from "../config-objects-service";
1415
import { EventsLogService } from "../events-log-service";
16+
import { SyncService } from "../sync-service";
17+
import { DebugService } from "../debug-service";
18+
import { ReportsService } from "../reports-service";
1519

1620
const log = getServerLog("mcp-server");
1721

@@ -22,8 +26,10 @@ const log = getServerLog("mcp-server");
2226
export interface McpServerDeps {
2327
prisma: PrismaClient;
2428
kv: KvStore;
25-
/** ClickHouse client for the events-log tools. Defaults to the shared singleton. */
29+
/** ClickHouse client for the events-log/sync/report tools. Defaults to the shared singleton. */
2630
clickhouse?: ClickHouseClient;
31+
/** Postgres pool for the raw-SQL sync/report queries. Defaults to the shared singleton. */
32+
pgPool?: Pool;
2733
accessTokenTtlSec?: number;
2834
refreshTokenTtlDays?: number;
2935
/** Override the fire-and-forget scheduler (e.g. pass `after` from next/server
@@ -41,6 +47,9 @@ export class McpServer {
4147
private readonly auth: AuthChecker;
4248
private readonly configObjects: ConfigObjectsService;
4349
private readonly eventsLog: EventsLogService;
50+
private readonly syncs: SyncService;
51+
private readonly debug: DebugService;
52+
private readonly reports: ReportsService;
4453

4554
constructor(private readonly deps: McpServerDeps) {
4655
this.oauth = new OAuthHandlers({
@@ -51,7 +60,12 @@ export class McpServer {
5160
});
5261
this.auth = new AuthChecker(deps.prisma, deps.fireAndForget);
5362
this.configObjects = new ConfigObjectsService({ prisma: deps.prisma });
54-
this.eventsLog = new EventsLogService({ clickhouse: deps.clickhouse ?? clickhouse, prisma: deps.prisma });
63+
const ch = deps.clickhouse ?? clickhouse;
64+
const pgPool = deps.pgPool ?? db.pgPool();
65+
this.eventsLog = new EventsLogService({ clickhouse: ch, prisma: deps.prisma });
66+
this.syncs = new SyncService({ prisma: deps.prisma, pgPool, clickhouse: ch });
67+
this.debug = new DebugService({ prisma: deps.prisma });
68+
this.reports = new ReportsService({ prisma: deps.prisma, pgPool, clickhouse: ch });
5569
}
5670

5771
// ─── OAuth endpoints ────────────────────────────────────────────────────
@@ -75,7 +89,14 @@ export class McpServer {
7589
if (!authInfo) return; // 401 already sent
7690

7791
const sdkServer = new SdkMcpServer({ name: "jitsu", version: "0.1.0" });
78-
registerTools(sdkServer, { service: this.configObjects, eventsLog: this.eventsLog });
92+
registerTools(sdkServer, {
93+
service: this.configObjects,
94+
eventsLog: this.eventsLog,
95+
syncs: this.syncs,
96+
debug: this.debug,
97+
reports: this.reports,
98+
req,
99+
});
79100
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
80101
// The SDK reads auth from req.auth — do not pass authInfo as the third arg
81102
// (parsedBody), which would make the transport treat it as the request body.

0 commit comments

Comments
 (0)