@debugg-ai/debugg-ai-mcp
Version:
Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.
587 lines (586 loc) • 24.3 kB
JavaScript
import { createWorkflowsService } from "./workflows.js";
import { createTunnelsService } from "./tunnels.js";
import { AxiosTransport } from "../utils/axiosTransport.js";
import { config } from "../config/index.js";
/**
* DebuggTransport extends AxiosTransport to automatically add isMcpRequest=true
* to all requests so the server knows they're coming from MCP
*/
class DebuggTransport extends AxiosTransport {
constructor(options) {
super(options);
// Override the request interceptor to add isMcpRequest to all requests
this.axios.interceptors.request.use((config) => {
// For GET requests, add to params
if (config.method?.toLowerCase() === 'get') {
config.params = config.params || {};
config.params.isMcpRequest = true;
}
else {
// For POST, PUT, PATCH, DELETE requests, add to data
if (config.data && typeof config.data === 'object') {
config.data.isMcpRequest = true;
}
else if (!config.data) {
config.data = { isMcpRequest: true };
}
}
return config;
});
}
}
export class DebuggAIServerClient {
userApiKey;
tx;
url;
workflows;
tunnels;
constructor(userApiKey) {
this.userApiKey = userApiKey;
// Note: init() is async and should be called separately
}
async init() {
const serverUrl = config.api.baseUrl;
this.url = new URL(serverUrl);
this.tx = new DebuggTransport({ baseUrl: serverUrl, apiKey: this.userApiKey, tokenType: config.api.tokenType });
this.workflows = createWorkflowsService(this.tx);
this.tunnels = createTunnelsService(this.tx);
}
/**
* Look up a project by repo name.
* Accepts "owner/repo" or bare "repo" — searches with the short name
* (more likely to match project names) then ranks results by match quality.
*/
async findProjectByRepoName(repoName) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
// "debugg-ai/react-web-app" → short = "react-web-app"
const short = repoName.includes('/') ? repoName.split('/').pop() : repoName;
const response = await this.tx.get('api/v1/projects/', { search: short });
const projects = response?.results ?? [];
if (projects.length === 0)
return null;
// Exact match on full "owner/repo" or short name against project name/slug
const exact = projects.find(p => p.name === repoName || p.name === short
|| p.slug === repoName || p.slug === short);
if (exact)
return exact;
// Match on repo.name — backend may store "owner/repo" or just "repo"
const repoMatch = projects.find(p => p.repo?.name === repoName || p.repo?.name === short
|| p.repo?.name?.endsWith(`/${short}`));
if (repoMatch)
return repoMatch;
// Fallback to first search result
return projects[0];
}
/**
* Simplified project shape used by get/update tools — drops heavy internal
* fields (team, runner_configuration, github_auth_details) that most MCP
* clients don't need.
*/
mapProjectDetail(p) {
return {
uuid: p.uuid,
name: p.name,
slug: p.slug,
platform: p.platform ?? null,
repoName: p.repo?.name ?? null,
description: p.description ?? null,
status: p.status ?? null,
language: p.language ?? null,
framework: p.framework ?? null,
timestamp: p.timestamp,
lastMod: p.lastMod,
};
}
async getProject(uuid) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const p = await this.tx.get(`api/v1/projects/${uuid}/`);
return this.mapProjectDetail(p);
}
async updateProject(uuid, patch) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const body = {};
if (patch.name !== undefined)
body.name = patch.name;
if (patch.description !== undefined)
body.description = patch.description;
const p = await this.tx.patch(`api/v1/projects/${uuid}/`, body);
return this.mapProjectDetail(p);
}
async deleteProject(uuid) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
await this.tx.delete(`api/v1/projects/${uuid}/`);
}
/**
* List projects accessible to the current API key. Paginated.
* Optional q filters by project name / repo name server-side (backend `?search=`).
*/
async listProjects(pagination, q) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const { makePageInfo } = await import('../utils/pagination.js');
const params = { page: pagination.page, pageSize: pagination.pageSize };
if (q)
params.search = q;
const response = await this.tx.get('api/v1/projects/', params);
return {
pageInfo: makePageInfo(pagination.page, pagination.pageSize, response?.count ?? 0, response?.next),
projects: response?.results ?? [],
};
}
async listTeams(pagination, q) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const { makePageInfo } = await import('../utils/pagination.js');
const params = { page: pagination.page, pageSize: pagination.pageSize };
if (q)
params.search = q;
const response = await this.tx.get('api/v1/teams/', params);
return {
pageInfo: makePageInfo(pagination.page, pagination.pageSize, response?.count ?? 0, response?.next),
teams: (response?.results ?? []).map((t) => ({
uuid: t.uuid,
name: t.name,
description: t.description ?? null,
memberCount: t.memberCount ?? 0,
ownerCount: t.ownerCount ?? 0,
currentUserRole: t.currentUserRole ?? null,
})),
};
}
async listRepos(pagination, q) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const { makePageInfo } = await import('../utils/pagination.js');
const params = { page: pagination.page, pageSize: pagination.pageSize };
if (q)
params.search = q;
const response = await this.tx.get('api/v1/repos/', params);
return {
pageInfo: makePageInfo(pagination.page, pagination.pageSize, response?.count ?? 0, response?.next),
repos: (response?.results ?? []).map((r) => ({
uuid: r.uuid,
name: r.name,
url: r.url ?? '',
description: r.description ?? null,
isPrivate: !!r.isPrivate,
isGithubAuthorized: !!r.isGithubAuthorized,
githubAccountLogin: r.githubAccountLogin ?? null,
})),
};
}
async createProject(input) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
// Backend expects `team` and `repo` keys (UUIDs). MCP surfaces them as
// teamUuid/repoUuid for clarity about what kind of UUID they are.
const body = {
name: input.name,
platform: input.platform,
team: input.teamUuid,
repo: input.repoUuid,
};
const p = await this.tx.post('api/v1/projects/', body);
return this.mapProjectDetail(p);
}
/**
* List environments for a project. Paginated.
* Optional q filters by name via backend ?search=.
* The bare-array variant (no pagination) is still used internally by
* search_environments when iterating across all envs to inline credentials.
*/
async listEnvironmentsForProject(projectUuid, q) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const params = { pageSize: 200 };
if (q)
params.search = q;
const response = await this.tx.get(`api/v1/projects/${projectUuid}/environments/`, params);
return (response?.results ?? []).map((e) => ({
uuid: e.uuid,
name: e.name,
url: e.url || e.activeUrl || '',
isActive: e.isActive,
}));
}
async listEnvironmentsPaginated(projectUuid, pagination, q) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const { makePageInfo } = await import('../utils/pagination.js');
const params = { page: pagination.page, pageSize: pagination.pageSize };
if (q)
params.search = q;
const response = await this.tx.get(`api/v1/projects/${projectUuid}/environments/`, params);
return {
pageInfo: makePageInfo(pagination.page, pagination.pageSize, response?.count ?? 0, response?.next),
environments: (response?.results ?? []).map((e) => ({
uuid: e.uuid,
name: e.name,
url: e.url || e.activeUrl || '',
isActive: e.isActive,
})),
};
}
/**
* Create a new environment under a project.
* Backend requires `name`. Other fields optional.
*/
async createEnvironment(projectUuid, input) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const body = { name: input.name };
if (input.url)
body.url = input.url;
if (input.description)
body.description = input.description;
const response = await this.tx.post(`api/v1/projects/${projectUuid}/environments/`, body);
return {
uuid: response.uuid,
name: response.name,
url: response.url || response.activeUrl || '',
isActive: response.isActive,
};
}
/**
* Delete an environment. Used by evals to clean up throwaway test envs.
*/
async deleteEnvironment(projectUuid, envUuid) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
await this.tx.delete(`api/v1/projects/${projectUuid}/environments/${envUuid}/`);
}
/**
* Fetch a single environment by UUID. Throws AxiosError with status 404 if not found.
*/
async getEnvironment(projectUuid, envUuid) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const e = await this.tx.get(`api/v1/projects/${projectUuid}/environments/${envUuid}/`);
return {
uuid: e.uuid,
name: e.name,
url: e.url ?? '',
isActive: e.isActive,
description: e.description ?? null,
endpointType: e.endpointType,
activeUrl: e.activeUrl ?? null,
timestamp: e.timestamp,
lastMod: e.lastMod,
};
}
/**
* Patch an environment. Backend PATCH response omits uuid — caller should echo it.
*/
async updateEnvironment(projectUuid, envUuid, patch) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const body = {};
if (patch.name !== undefined)
body.name = patch.name;
if (patch.url !== undefined)
body.url = patch.url;
if (patch.description !== undefined)
body.description = patch.description;
const e = await this.tx.patch(`api/v1/projects/${projectUuid}/environments/${envUuid}/`, body);
return {
uuid: envUuid, // echo from input; backend PATCH response omits it
name: e.name,
url: e.url ?? '',
isActive: e.isActive,
description: e.description ?? null,
endpointType: e.endpointType,
};
}
/**
* List credentials for a specific environment. Unpaginated (fetches up to
* backend max pageSize). q filters label/username server-side via ?search=;
* role filters server-side. Used internally by search_environments when
* inlining credentials on each env in a page.
*/
async listCredentialsForEnvironment(projectUuid, envUuid, q, role) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const params = { pageSize: 200 };
if (q)
params.search = q;
if (role)
params.role = role;
const response = await this.tx.get(`api/v1/projects/${projectUuid}/environments/${envUuid}/credentials/`, params);
return (response?.results ?? [])
.filter((c) => c.isActive)
.map((c) => ({
uuid: c.uuid,
label: c.label || c.username,
username: c.username,
role: c.role,
environmentUuid: envUuid,
}));
}
async listCredentialsPaginated(projectUuid, envUuid, pagination, q, role) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const { makePageInfo } = await import('../utils/pagination.js');
const params = { page: pagination.page, pageSize: pagination.pageSize };
if (q)
params.search = q;
if (role)
params.role = role;
const response = await this.tx.get(`api/v1/projects/${projectUuid}/environments/${envUuid}/credentials/`, params);
const creds = (response?.results ?? [])
.filter((c) => c.isActive)
.map((c) => ({
uuid: c.uuid,
label: c.label || c.username,
username: c.username,
role: c.role,
environmentUuid: envUuid,
}));
return {
pageInfo: makePageInfo(pagination.page, pagination.pageSize, response?.count ?? 0, response?.next),
credentials: creds,
};
}
/**
* Create a credential on an environment. password is write-only — never echoed back.
*/
async createCredential(projectUuid, envUuid, input) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const body = {
label: input.label,
username: input.username,
password: input.password,
};
if (input.role)
body.role = input.role;
const response = await this.tx.post(`api/v1/projects/${projectUuid}/environments/${envUuid}/credentials/`, body);
return {
uuid: response.uuid,
label: response.label || response.username,
username: response.username,
role: response.role,
environmentUuid: envUuid,
};
}
/**
* Delete a credential. Used by evals to clean up throwaway test creds.
*/
async deleteCredential(projectUuid, envUuid, credUuid) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
await this.tx.delete(`api/v1/projects/${projectUuid}/environments/${envUuid}/credentials/${credUuid}/`);
}
/**
* Fetch a single credential by UUID. Throws AxiosError wrapper with statusCode=404 if not found.
* Response shape omits any password field — backend credential schema has no password field.
*/
async getCredential(projectUuid, envUuid, credUuid) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const c = await this.tx.get(`api/v1/projects/${projectUuid}/environments/${envUuid}/credentials/${credUuid}/`);
return {
uuid: c.uuid,
label: c.label ?? c.username,
username: c.username,
role: c.role ?? null,
environmentUuid: envUuid,
environmentName: c.environmentName ?? null,
isActive: c.isActive,
isDefault: c.isDefault,
description: c.description ?? null,
timestamp: c.timestamp,
lastMod: c.lastMod,
};
}
/**
* Update a credential via partial PATCH. Only the specified fields change.
*/
async updateCredential(projectUuid, envUuid, credUuid, patch) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const body = {};
if (patch.label !== undefined)
body.label = patch.label;
if (patch.username !== undefined)
body.username = patch.username;
if (patch.password !== undefined)
body.password = patch.password;
if (patch.role !== undefined)
body.role = patch.role;
const c = await this.tx.patch(`api/v1/projects/${projectUuid}/environments/${envUuid}/credentials/${credUuid}/`, body);
return {
uuid: credUuid, // echo from input; backend PATCH response omits it
label: c.label,
username: c.username,
role: c.role ?? null,
environmentUuid: envUuid,
isActive: c.isActive,
};
}
/**
* Revoke an ngrok API key by its key ID.
* Call this after workflow execution completes to clean up the short-lived key.
*/
async revokeNgrokKey(ngrokKeyId) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
await this.tx.post('api/v1/ngrok/revoke/', { ngrokKeyId });
}
// ── E2E Suite Management ──────────────────────────────────────────────────
async createTestSuite(input) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const s = await this.tx.post('api/v1/test-suites/', {
name: input.name,
description: input.description,
project: input.projectUuid,
});
return this.mapTestSuite(s);
}
async listTestSuites(params) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const { makePageInfo } = await import('../utils/pagination.js');
const page = params.page ?? 1;
const pageSize = params.pageSize ?? 20;
const query = { project: params.projectUuid, page, pageSize };
if (params.search)
query.search = params.search;
const response = await this.tx.get('api/v1/test-suites/', query);
return {
pageInfo: makePageInfo(page, pageSize, response?.count ?? 0, response?.next),
suites: (response?.results ?? []).map((s) => ({
uuid: s.uuid,
name: s.name,
description: s.description ?? null,
runStatus: s.runStatus ?? s.run_status ?? 'NEVER_RUN',
testsCount: s.testsCount ?? s.tests_count ?? 0,
passRate: s.passRate ?? s.pass_rate ?? null,
lastRunAt: s.lastRunAt ?? s.last_run_at ?? null,
})),
};
}
async disableTestSuite(suiteUuid) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
await this.tx.post(`api/v1/test-suites/${suiteUuid}/disable/`, {});
return { uuid: suiteUuid, isDisabled: true };
}
async createTestCase(input) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const body = {
name: input.name,
description: input.description,
agent_task_description: input.agentTaskDescription,
suite: input.suiteUuid,
project: input.projectUuid,
run: false,
};
if (input.relativeUrl)
body.relative_url = input.relativeUrl;
if (input.maxSteps)
body.max_steps = input.maxSteps;
const t = await this.tx.post('api/v1/e2e-tests/', body);
return {
uuid: t.uuid,
name: t.name,
description: t.description,
agentTaskDescription: t.agentTaskDescription ?? t.agent_task_description ?? '',
suite: t.suite ?? input.suiteUuid,
project: t.project ?? input.projectUuid,
runCount: t.runCount ?? t.run_count ?? 0,
};
}
async updateTestCase(testUuid, patch) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const body = {};
if (patch.name !== undefined)
body.name = patch.name;
if (patch.description !== undefined)
body.description = patch.description;
if (patch.agentTaskDescription !== undefined)
body.agent_task_description = patch.agentTaskDescription;
const t = await this.tx.patch(`api/v1/e2e-tests/${testUuid}/`, body);
return {
uuid: t.uuid,
name: t.name,
description: t.description,
agentTaskDescription: t.agentTaskDescription ?? t.agent_task_description ?? '',
};
}
async disableTestCase(testUuid) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
await this.tx.post(`api/v1/e2e-tests/${testUuid}/disable/`, {});
return { uuid: testUuid, isDisabled: true };
}
async runTestSuite(suiteUuid, params) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const body = {};
if (params.targetUrl)
body.target_url = params.targetUrl;
const s = await this.tx.post(`api/v1/test-suites/${suiteUuid}/run/`, body);
return {
suiteUuid,
runStatus: s?.runStatus ?? s?.run_status ?? 'PENDING',
testsTriggered: (s?.tests ?? []).length,
};
}
async getTestSuiteDetail(suiteUuid) {
if (!this.tx)
throw new Error('Client not initialized — call init() first');
const s = await this.tx.get(`api/v1/test-suites/${suiteUuid}/`);
const tests = s.tests ?? [];
return {
uuid: s.uuid,
name: s.name,
runStatus: s.runStatus ?? s.run_status ?? 'NEVER_RUN',
testsCount: tests.length,
passRate: s.passRate ?? s.pass_rate ?? null,
lastRunAt: s.lastRunAt ?? s.last_run_at ?? null,
tests: tests.map((t) => {
// Backend returns cur_run (latest run) per test in the suite detail view
const lastRun = t.curRun ?? t.cur_run ?? t.lastRun ?? t.last_run ?? null;
return {
uuid: t.uuid,
name: t.name,
runCount: t.runCount ?? t.run_count ?? 0,
passedRunsCount: t.passedRunsCount ?? t.passed_runs_count ?? 0,
failedRunsCount: t.failedRunsCount ?? t.failed_runs_count ?? 0,
passRate: t.passRate ?? t.pass_rate ?? null,
lastRun: lastRun ? {
uuid: lastRun.uuid,
status: lastRun.status,
outcome: lastRun.outcome,
executionTime: lastRun.executionTime ?? lastRun.execution_time ?? null,
timestamp: lastRun.timestamp,
} : null,
};
}),
};
}
mapTestSuite(s) {
return {
uuid: s.uuid,
name: s.name,
description: s.description ?? null,
runStatus: s.runStatus ?? s.run_status ?? 'NEVER_RUN',
testsCount: s.testsCount ?? s.tests_count ?? 0,
};
}
}
/**
* Create and initialize a service client
*/
export async function createClientService() {
const client = new DebuggAIServerClient(config.api.key);
await client.init();
return client;
}