UNPKG

@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
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; }