@bugmole/cli 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bugmole.env.example +20 -0
- package/LICENSE +7 -0
- package/README.md +293 -0
- package/TESTING.md +117 -0
- package/bugmole.config.yaml +39 -0
- package/package.json +85 -0
- package/scripts/billing/paypal-setup.mjs +121 -0
- package/scripts/bugmole-continue.ts +318 -0
- package/scripts/bugmole-init.ts +188 -0
- package/scripts/bugmole.cjs +17 -0
- package/scripts/bugmole.test.ts +344 -0
- package/scripts/bugmole.ts +657 -0
- package/scripts/ensure-maestro.cjs +79 -0
- package/scripts/ios-tunnel-keeper.sh +45 -0
- package/scripts/ios-wda-keeper.sh +66 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -0
- package/scripts/ui-parity-diff.py +65 -0
- package/scripts/ui-parity-requirements.txt +1 -0
- package/scripts/verify-manage-to-plans.mts +194 -0
- package/spec/app-ui-audit.schema.json +176 -0
- package/spec/blockers.yaml +79 -0
- package/spec/bugs.index.json +42 -0
- package/spec/design-dna.schema.json +38 -0
- package/spec/domain_rules.yaml +24 -0
- package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
- package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
- package/spec/journey_graph.yaml +126 -0
- package/spec/journeys.graph.json +2618 -0
- package/spec/plans/dashboard-smoke.flow.yaml +20 -0
- package/spec/plans/example.flow.yaml +99 -0
- package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
- package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
- package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
- package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
- package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
- package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
- package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
- package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
- package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
- package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
- package/spec/plans/login.flow.yaml +22 -0
- package/spec/plans/owner-operations.flow.yaml +20 -0
- package/spec/project_config.yaml +55 -0
- package/spec/roles.yaml +30 -0
- package/spec/schema.md +394 -0
- package/spec/test-case-results.schema.json +62 -0
- package/spec/test-cases.schema.json +85 -0
- package/spec/ui-parity-audit.schema.json +194 -0
- package/spec/ui-reverse-engineering.schema.json +94 -0
- package/src/billing/plan-catalog.test.ts +46 -0
- package/src/billing/plan-catalog.ts +199 -0
- package/src/integrations/aws-sigv4.test.ts +42 -0
- package/src/integrations/aws-sigv4.ts +72 -0
- package/src/integrations/device-farm.ts +155 -0
- package/src/integrations/github-app.test.ts +57 -0
- package/src/integrations/github-app.ts +143 -0
- package/src/integrations/gitlab.ts +81 -0
- package/src/integrations/temp-email.test.ts +123 -0
- package/src/integrations/temp-email.ts +175 -0
- package/src/integrations/testflight-feedback.test.ts +51 -0
- package/src/integrations/testflight-feedback.ts +173 -0
- package/src/integrations/webdriver-client.ts +131 -0
- package/src/mcp/server.test.ts +1220 -0
- package/src/mcp/server.ts +3064 -0
- package/src/mcp/write-test-cases.test.ts +287 -0
- package/src/registry/api-key-client.ts +39 -0
- package/src/registry/control-plane-client.ts +212 -0
- package/src/registry/migrations/0001_registry.sql +47 -0
- package/src/registry/migrations/0002_device_authorizations.sql +23 -0
- package/src/registry/migrations/0003_project_environments.sql +25 -0
- package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
- package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
- package/src/registry/migrations/0006_workspaces.sql +36 -0
- package/src/registry/migrations/0007_project_apps.sql +26 -0
- package/src/registry/migrations/0008_agent_tasks.sql +30 -0
- package/src/registry/migrations/0009_journey_revisions.sql +17 -0
- package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
- package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
- package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
- package/src/registry/migrations/0013_device_sessions.sql +22 -0
- package/src/registry/migrations/0014_agent_task_step.sql +1 -0
- package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
- package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
- package/src/registry/migrations/0017_run_targets.sql +7 -0
- package/src/registry/migrations/0018_run_fix_from.sql +4 -0
- package/src/registry/migrations/0019_workspace_flags.sql +9 -0
- package/src/registry/migrations/0020_orgs.sql +40 -0
- package/src/registry/migrations/0021_billing_core.sql +58 -0
- package/src/registry/migrations/0022_cloud_runners.sql +19 -0
- package/src/registry/migrations/0023_signup.sql +4 -0
- package/src/registry/migrations/0024_billing.sql +67 -0
- package/src/registry/migrations/0025_notifications.sql +47 -0
- package/src/registry/migrations/0026_repo_bindings.sql +28 -0
- package/src/registry/migrations/0027_feedback.sql +29 -0
- package/src/registry/migrations/0028_devices.sql +48 -0
- package/src/registry/migrations/0029_sso.sql +31 -0
- package/src/registry/migrations/0030_workspace_domains.sql +18 -0
- package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
- package/src/registry/migrations/0032_personas.sql +15 -0
- package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
- package/src/registry/task-scheduling.test.ts +100 -0
- package/src/registry/task-scheduling.ts +80 -0
- package/src/registry-worker/ai/platform-model.ts +77 -0
- package/src/registry-worker/ai/routes.test.ts +88 -0
- package/src/registry-worker/ai/routes.ts +90 -0
- package/src/registry-worker/artifacts.test.ts +98 -0
- package/src/registry-worker/artifacts.ts +85 -0
- package/src/registry-worker/billing/billing-core.test.ts +175 -0
- package/src/registry-worker/billing/checkout-routes.ts +209 -0
- package/src/registry-worker/billing/enforcement.ts +69 -0
- package/src/registry-worker/billing/entitlements.ts +108 -0
- package/src/registry-worker/billing/ledger.ts +186 -0
- package/src/registry-worker/billing/paypal/api.ts +259 -0
- package/src/registry-worker/billing/paypal/client.ts +91 -0
- package/src/registry-worker/billing/paypal/provider.ts +143 -0
- package/src/registry-worker/billing/paypal.test.ts +466 -0
- package/src/registry-worker/billing/provider.ts +114 -0
- package/src/registry-worker/billing/routes.ts +66 -0
- package/src/registry-worker/billing/subscriptions.ts +780 -0
- package/src/registry-worker/billing/thresholds.ts +107 -0
- package/src/registry-worker/core.ts +308 -0
- package/src/registry-worker/devices/devices.test.ts +185 -0
- package/src/registry-worker/devices/policy.ts +71 -0
- package/src/registry-worker/devices/routes.ts +453 -0
- package/src/registry-worker/domains/domains.test.ts +210 -0
- package/src/registry-worker/domains/routes.ts +139 -0
- package/src/registry-worker/domains.ts +88 -0
- package/src/registry-worker/email/sender.ts +75 -0
- package/src/registry-worker/env.d.ts +14716 -0
- package/src/registry-worker/features.ts +20 -0
- package/src/registry-worker/feedback/feedback.test.ts +230 -0
- package/src/registry-worker/feedback/format.ts +148 -0
- package/src/registry-worker/feedback/routes.ts +386 -0
- package/src/registry-worker/flags.ts +39 -0
- package/src/registry-worker/github/checks.test.ts +177 -0
- package/src/registry-worker/github/checks.ts +374 -0
- package/src/registry-worker/gitlab/checks.test.ts +141 -0
- package/src/registry-worker/gitlab/checks.ts +349 -0
- package/src/registry-worker/hooks.ts +54 -0
- package/src/registry-worker/index.ts +2077 -0
- package/src/registry-worker/jobs/index.ts +29 -0
- package/src/registry-worker/jobs/retention.ts +68 -0
- package/src/registry-worker/mcp/mcp.test.ts +355 -0
- package/src/registry-worker/mcp/routes.ts +215 -0
- package/src/registry-worker/mcp/token.ts +126 -0
- package/src/registry-worker/mcp/tools.ts +563 -0
- package/src/registry-worker/notifications/alerts.ts +212 -0
- package/src/registry-worker/notifications/notifications.test.ts +298 -0
- package/src/registry-worker/notifications/outbox.ts +83 -0
- package/src/registry-worker/notifications/routes.ts +280 -0
- package/src/registry-worker/notifications/secrets.ts +49 -0
- package/src/registry-worker/notifications/slack.ts +96 -0
- package/src/registry-worker/notifications/teams.ts +46 -0
- package/src/registry-worker/org/audit.ts +116 -0
- package/src/registry-worker/org/routes.test.ts +163 -0
- package/src/registry-worker/org/routes.ts +302 -0
- package/src/registry-worker/personas/personas.test.ts +78 -0
- package/src/registry-worker/personas/routes.ts +100 -0
- package/src/registry-worker/repo-triggers.ts +20 -0
- package/src/registry-worker/routes/index.ts +74 -0
- package/src/registry-worker/run-events.ts +24 -0
- package/src/registry-worker/runner/dispatch.ts +219 -0
- package/src/registry-worker/runner/jobs.ts +43 -0
- package/src/registry-worker/runner/metering.ts +82 -0
- package/src/registry-worker/runner/policy.ts +59 -0
- package/src/registry-worker/runner/routes.ts +171 -0
- package/src/registry-worker/runner/runner.test.ts +358 -0
- package/src/registry-worker/runner/tokens.ts +93 -0
- package/src/registry-worker/runs.test.ts +60 -0
- package/src/registry-worker/signup/policy.ts +57 -0
- package/src/registry-worker/signup/routes.ts +106 -0
- package/src/registry-worker/signup/signup.test.ts +81 -0
- package/src/registry-worker/sso/aegis.ts +141 -0
- package/src/registry-worker/sso/membership.ts +157 -0
- package/src/registry-worker/sso/routes.ts +458 -0
- package/src/registry-worker/sso/sso.test.ts +344 -0
- package/src/registry-worker/testing/d1-shim.ts +180 -0
- package/src/registry-worker/testing/harness.ts +137 -0
- package/src/runner-worker/index.ts +108 -0
- package/src/runtime/ai-analysis.ts +97 -0
- package/src/runtime/ai-exploration.test.ts +32 -0
- package/src/runtime/ai-exploration.ts +69 -0
- package/src/runtime/ai-repair.ts +74 -0
- package/src/runtime/ai-work.test.ts +99 -0
- package/src/runtime/android-screen-record.test.ts +75 -0
- package/src/runtime/android-screen-record.ts +192 -0
- package/src/runtime/app-understanding.test.ts +123 -0
- package/src/runtime/app-understanding.ts +201 -0
- package/src/runtime/appium-driver.test.ts +179 -0
- package/src/runtime/appium-driver.ts +295 -0
- package/src/runtime/blocker-resolution.test.ts +113 -0
- package/src/runtime/blocker-resolution.ts +111 -0
- package/src/runtime/browser-matrix.integration.test.ts +212 -0
- package/src/runtime/browser-matrix.test.ts +143 -0
- package/src/runtime/browser-matrix.ts +200 -0
- package/src/runtime/canonical-flow.test.ts +52 -0
- package/src/runtime/config-validate.ts +185 -0
- package/src/runtime/continuous-execution.ts +291 -0
- package/src/runtime/cursor-applescript.ts +573 -0
- package/src/runtime/cursor-cli-driver.test.ts +78 -0
- package/src/runtime/cursor-cli-driver.ts +156 -0
- package/src/runtime/cursor-driver-example.ts +117 -0
- package/src/runtime/cursor-driver-index.ts +65 -0
- package/src/runtime/cursor-driver-init.ts +277 -0
- package/src/runtime/cursor-driver-run.test.ts +15 -0
- package/src/runtime/cursor-driver-run.ts +323 -0
- package/src/runtime/cursor-driver.ts +332 -0
- package/src/runtime/cursor-llm-example.ts +90 -0
- package/src/runtime/cursor-llm.ts +206 -0
- package/src/runtime/cursor-mcp-monitor.ts +386 -0
- package/src/runtime/device-clouds/browserstack.ts +73 -0
- package/src/runtime/device-clouds/device-farm.ts +52 -0
- package/src/runtime/device-clouds/index.ts +92 -0
- package/src/runtime/device-clouds/kobiton.ts +70 -0
- package/src/runtime/device-clouds/targets.ts +44 -0
- package/src/runtime/device-clouds/types.ts +62 -0
- package/src/runtime/diff-proposal.ts +84 -0
- package/src/runtime/discovery-task.test.ts +29 -0
- package/src/runtime/discovery-task.ts +284 -0
- package/src/runtime/driver-recovery.ts +69 -0
- package/src/runtime/driver.ts +79 -0
- package/src/runtime/environment.test.ts +104 -0
- package/src/runtime/environment.ts +137 -0
- package/src/runtime/executor.test.ts +509 -0
- package/src/runtime/executor.ts +921 -0
- package/src/runtime/explorer.test.ts +101 -0
- package/src/runtime/explorer.ts +1013 -0
- package/src/runtime/failure-analysis.test.ts +111 -0
- package/src/runtime/failure-analysis.ts +272 -0
- package/src/runtime/fixtures/fake-maestro.sh +36 -0
- package/src/runtime/flow-language.test.ts +268 -0
- package/src/runtime/flow-language.ts +414 -0
- package/src/runtime/init-wizard.ts +354 -0
- package/src/runtime/ios-screen-record.test.ts +68 -0
- package/src/runtime/ios-screen-record.ts +155 -0
- package/src/runtime/journey-editor.ts +452 -0
- package/src/runtime/journey-evidence.test.ts +161 -0
- package/src/runtime/journey-evidence.ts +180 -0
- package/src/runtime/journey-graph.test.ts +257 -0
- package/src/runtime/journey-graph.ts +170 -0
- package/src/runtime/legacy-names.ts +32 -0
- package/src/runtime/llm-example.ts +105 -0
- package/src/runtime/llm.ts +527 -0
- package/src/runtime/local-browser.test.ts +45 -0
- package/src/runtime/local-browser.ts +48 -0
- package/src/runtime/local-registry-stub.test.ts +325 -0
- package/src/runtime/local-registry-stub.ts +803 -0
- package/src/runtime/maestro-driver.test.ts +84 -0
- package/src/runtime/maestro-driver.ts +209 -0
- package/src/runtime/mole-voice.ts +21 -0
- package/src/runtime/nav-crawl.test.ts +100 -0
- package/src/runtime/nav-crawl.ts +153 -0
- package/src/runtime/pipeline.test.ts +405 -0
- package/src/runtime/pipeline.ts +833 -0
- package/src/runtime/planner.test.ts +37 -0
- package/src/runtime/planner.ts +274 -0
- package/src/runtime/platform-ai.ts +76 -0
- package/src/runtime/playwright-driver.test.ts +93 -0
- package/src/runtime/playwright-driver.ts +620 -0
- package/src/runtime/project-spec.ts +140 -0
- package/src/runtime/record-run-verdicts.ts +68 -0
- package/src/runtime/reporter.test.ts +56 -0
- package/src/runtime/reporter.ts +158 -0
- package/src/runtime/reset.test.ts +44 -0
- package/src/runtime/reset.ts +61 -0
- package/src/runtime/reviewer.test.ts +73 -0
- package/src/runtime/reviewer.ts +158 -0
- package/src/runtime/run-job.ts +136 -0
- package/src/runtime/run-once.test.ts +207 -0
- package/src/runtime/run-once.ts +168 -0
- package/src/runtime/run.ts +132 -0
- package/src/runtime/screen-recording.ts +34 -0
- package/src/runtime/serve-gateway.test.ts +74 -0
- package/src/runtime/serve-gateway.ts +164 -0
- package/src/runtime/serve-worker.test.ts +23 -0
- package/src/runtime/serve-worker.ts +278 -0
- package/src/runtime/site-discovery.test.ts +168 -0
- package/src/runtime/site-discovery.ts +308 -0
- package/src/runtime/target-runner.ts +144 -0
- package/src/runtime/test-case-verdicts.test.ts +94 -0
- package/src/runtime/test-case-verdicts.ts +120 -0
- package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
- package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
- package/src/runtime/ui-reverse-engineering/types.ts +197 -0
- package/src/runtime/web-suite.test.ts +97 -0
- package/src/runtime/web-suite.ts +176 -0
- package/src/storage/create-object-store.ts +144 -0
- package/src/storage/keys.ts +34 -0
- package/src/storage/local-artifact-server.test.ts +314 -0
- package/src/storage/local-artifact-server.ts +357 -0
- package/src/storage/object-store.test.ts +28 -0
- package/src/storage/object-store.ts +101 -0
- package/src/storage/registry-object-store.ts +88 -0
- package/src/storage/remote-object-store.ts +104 -0
- package/src/storage/storage-directory.test.ts +43 -0
- package/src/storage/storage-directory.ts +24 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
// Tools the hosted MCP endpoint offers. Each one calls the registry's own
|
|
2
|
+
// routes as the signed-in person, so roles, plan limits and audit logging
|
|
3
|
+
// apply exactly as they do in the dashboard.
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { countFlowSteps, describeStepLabel, parseMaestroStyleFlow, type FlowStep } from "../../runtime/flow-language.js";
|
|
6
|
+
|
|
7
|
+
/** Calls a registry route as the signed-in person. */
|
|
8
|
+
export type RegistryCall = (
|
|
9
|
+
method: string,
|
|
10
|
+
path: string,
|
|
11
|
+
body?: unknown,
|
|
12
|
+
init?: { contentType?: string; raw?: boolean },
|
|
13
|
+
) => Promise<{ status: number; json: any; text: string }>;
|
|
14
|
+
|
|
15
|
+
export type ToolContext = {
|
|
16
|
+
call: RegistryCall;
|
|
17
|
+
dashboardUrl: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ToolResult = {
|
|
21
|
+
content: Array<{ type: "text"; text: string }>;
|
|
22
|
+
structuredContent?: Record<string, unknown>;
|
|
23
|
+
isError?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type ToolDefinition = {
|
|
27
|
+
name: string;
|
|
28
|
+
title: string;
|
|
29
|
+
description: string;
|
|
30
|
+
inputSchema: Record<string, unknown>;
|
|
31
|
+
annotations: { readOnlyHint: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint?: boolean };
|
|
32
|
+
run: (args: Record<string, unknown>, context: ToolContext) => Promise<ToolResult>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const FLOW_REFERENCE = `A flow is YAML: a header, a line with ---, then a list of steps.
|
|
36
|
+
|
|
37
|
+
url: https://shop.example.com/login
|
|
38
|
+
---
|
|
39
|
+
- launchApp
|
|
40
|
+
- tapOn: "Email"
|
|
41
|
+
- inputText: "{{secret.SHOP_EMAIL}}"
|
|
42
|
+
- tapOn: "Password"
|
|
43
|
+
- inputText: "{{secret.SHOP_PASSWORD}}"
|
|
44
|
+
- tapOn: "Sign in"
|
|
45
|
+
- assertVisible: "Your orders"
|
|
46
|
+
|
|
47
|
+
Steps:
|
|
48
|
+
- launchApp (opens the header url), openLink: <url>, stopApp
|
|
49
|
+
- tapOn: "<visible text>" (or { text, index, optional: true })
|
|
50
|
+
- inputText: "<text>", eraseText, pressKey: Enter
|
|
51
|
+
- assertVisible / assertNotVisible / assertClickable: "<text>" (".*" makes it a pattern)
|
|
52
|
+
- scrollUntilVisible: "<text>", wait: <ms>, takeScreenshot: <name>
|
|
53
|
+
- retry: { maxRetries: 2, commands: [ ...steps ] }
|
|
54
|
+
- requestTempEmail: <name>, then use "{{name}}" in later steps; openEmailLink: <name>
|
|
55
|
+
|
|
56
|
+
Describe what a person sees and does, never CSS selectors. Passwords go in
|
|
57
|
+
{{secret.NAME}}, which a worker fills from BUGMOLE_FLOW_SECRET_NAME; never
|
|
58
|
+
put a real password in a flow. The url's origin is replaced by the chosen
|
|
59
|
+
environment's base URL when a run names an environment.`;
|
|
60
|
+
|
|
61
|
+
const PLAN_ID = /^[a-z0-9][a-z0-9._-]{0,79}$/;
|
|
62
|
+
|
|
63
|
+
function text(value: unknown): ToolResult {
|
|
64
|
+
const structured = value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : { result: value };
|
|
65
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], structuredContent: structured };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function toolError(message: string): ToolResult {
|
|
69
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function problem(result: { status: number; json: any; text: string }, fallback: string): ToolResult {
|
|
73
|
+
const message = typeof result.json?.error === "string" ? result.json.error : fallback;
|
|
74
|
+
const code = typeof result.json?.code === "string" ? ` (${result.json.code})` : "";
|
|
75
|
+
if (result.status === 402) return toolError(`${message}${code}. Upgrade or raise the spending limit in Billing, or run on your own workers.`);
|
|
76
|
+
return toolError(`${message}${code}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function stringArg(args: Record<string, unknown>, name: string, required = true): string {
|
|
80
|
+
const value = args[name];
|
|
81
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
82
|
+
if (required) throw new ToolInputError(`${name} is required`);
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class ToolInputError extends Error {}
|
|
87
|
+
|
|
88
|
+
const enc = encodeURIComponent;
|
|
89
|
+
|
|
90
|
+
function runLink(context: ToolContext, projectId: string, runId: string): string {
|
|
91
|
+
return `${context.dashboardUrl}/runs?${new URLSearchParams({ project: projectId, run: runId })}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function planLink(context: ToolContext, projectId: string): string {
|
|
95
|
+
return `${context.dashboardUrl}/plans?${new URLSearchParams({ project: projectId })}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function flowKey(projectId: string, planId: string): string {
|
|
99
|
+
return `projects/${enc(projectId)}/spec/flows/${planId}.yaml`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function planKey(projectId: string, planId: string): string {
|
|
103
|
+
return `projects/${enc(projectId)}/plans/${planId}.flow.yaml`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The plan document the runner reads, pointing at the flow stored under spec/. */
|
|
107
|
+
export function planDocument(input: { planId: string; name: string; journeyId: string; actor: string; steps: FlowStep[] }) {
|
|
108
|
+
return {
|
|
109
|
+
version: 2,
|
|
110
|
+
id: input.planId,
|
|
111
|
+
name: input.name,
|
|
112
|
+
description: `Written with an AI assistant through Bugmole MCP`,
|
|
113
|
+
suites: [
|
|
114
|
+
{
|
|
115
|
+
id: "default",
|
|
116
|
+
runs: "on_demand",
|
|
117
|
+
journeys: [
|
|
118
|
+
{
|
|
119
|
+
id: input.journeyId,
|
|
120
|
+
name: input.name,
|
|
121
|
+
actor: input.actor,
|
|
122
|
+
driver: "playwright",
|
|
123
|
+
platform: "web",
|
|
124
|
+
flow: `flows/${input.planId}.yaml`,
|
|
125
|
+
goal: input.name,
|
|
126
|
+
capabilities_required: [],
|
|
127
|
+
preconditions: [],
|
|
128
|
+
steps: input.steps.map((step, index) => ({ node_id: `step-${index + 1}`, action: describeStepLabel(step) })),
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type RunRow = Record<string, any>;
|
|
137
|
+
|
|
138
|
+
function runSummary(context: ToolContext, run: RunRow) {
|
|
139
|
+
const targets = Array.isArray(run.targetResults) ? run.targetResults : [];
|
|
140
|
+
return {
|
|
141
|
+
id: run.id,
|
|
142
|
+
planId: run.planId,
|
|
143
|
+
status: run.status,
|
|
144
|
+
where: run.executionMode === "cloud" ? "Bugmole Cloud" : "your workers",
|
|
145
|
+
environmentBaseUrl: run.environmentBaseUrl ?? undefined,
|
|
146
|
+
steps: { executed: run.stepsExecuted ?? 0, passed: run.stepsPassed ?? 0, failed: run.stepsFailed ?? 0 },
|
|
147
|
+
currentStep: run.currentStep ?? undefined,
|
|
148
|
+
error: run.errorMessage ?? undefined,
|
|
149
|
+
errorCode: run.errorCode ?? run.failureCode ?? undefined,
|
|
150
|
+
blockedReason: run.blockedReason ?? undefined,
|
|
151
|
+
durationMs: run.durationMs ?? undefined,
|
|
152
|
+
requestedAt: run.requestedAt,
|
|
153
|
+
completedAt: run.completedAt ?? undefined,
|
|
154
|
+
targets: targets.map((target: Record<string, any>) => {
|
|
155
|
+
const failed = Array.isArray(target.steps) ? target.steps.find((step: Record<string, any>) => step.status !== "passed") : undefined;
|
|
156
|
+
return {
|
|
157
|
+
id: target.targetId,
|
|
158
|
+
browser: target.device ? `${target.browser} (${target.device})` : target.browser,
|
|
159
|
+
status: target.status,
|
|
160
|
+
steps: { passed: target.stepsPassed ?? 0, failed: target.stepsFailed ?? 0 },
|
|
161
|
+
message: target.message ?? undefined,
|
|
162
|
+
failureCode: target.failureCode ?? undefined,
|
|
163
|
+
failedStep: failed ? { number: failed.index + 1, label: failed.label, message: failed.message } : undefined,
|
|
164
|
+
// Bugmole's own diagnosis: whether the test, the app or the environment is at fault, and a fix.
|
|
165
|
+
diagnosis: target.analysis
|
|
166
|
+
? { title: target.analysis.title, cause: target.analysis.category, fix: target.analysis.fixSummary }
|
|
167
|
+
: undefined,
|
|
168
|
+
};
|
|
169
|
+
}),
|
|
170
|
+
link: runLink(context, run.projectId, run.id),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function projectDefaults(context: ToolContext, projectId: string) {
|
|
175
|
+
const result = await context.call("GET", "/api/projects");
|
|
176
|
+
if (result.status !== 200) return null;
|
|
177
|
+
return (result.json.projects as Array<Record<string, any>>).find((project) => project.id === projectId) ?? null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const TOOLS: ToolDefinition[] = [
|
|
181
|
+
{
|
|
182
|
+
name: "list_projects",
|
|
183
|
+
title: "List projects",
|
|
184
|
+
description: "Lists the Bugmole projects you can use, with their environments (the base URLs tests run against) and where runs go by default. Start here to find a projectId.",
|
|
185
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
186
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
187
|
+
async run(_args, context) {
|
|
188
|
+
const result = await context.call("GET", "/api/projects");
|
|
189
|
+
if (result.status !== 200) return problem(result, "Could not list projects");
|
|
190
|
+
const projects = (result.json.projects as Array<Record<string, any>>).map((project) => ({
|
|
191
|
+
id: project.id,
|
|
192
|
+
name: project.name,
|
|
193
|
+
workspace: project.workspaceName,
|
|
194
|
+
storage: project.storageMode === "managed" ? "Bugmole" : "your own",
|
|
195
|
+
runsGoTo: project.defaultExecutionMode === "cloud" ? "Bugmole Cloud" : "your workers",
|
|
196
|
+
readOnly: Boolean(project.lockedAt),
|
|
197
|
+
environments: (project.environments ?? []).map((environment: Record<string, any>) => ({
|
|
198
|
+
id: environment.id,
|
|
199
|
+
name: environment.name,
|
|
200
|
+
baseUrl: environment.baseUrl,
|
|
201
|
+
default: environment.isDefault,
|
|
202
|
+
})),
|
|
203
|
+
}));
|
|
204
|
+
return text({ projects });
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: "list_test_plans",
|
|
209
|
+
title: "List test plans",
|
|
210
|
+
description: "Lists a project's test plans. Each plan runs one flow.",
|
|
211
|
+
inputSchema: {
|
|
212
|
+
type: "object",
|
|
213
|
+
properties: { projectId: { type: "string" } },
|
|
214
|
+
required: ["projectId"],
|
|
215
|
+
additionalProperties: false,
|
|
216
|
+
},
|
|
217
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
218
|
+
async run(args, context) {
|
|
219
|
+
const projectId = stringArg(args, "projectId");
|
|
220
|
+
const result = await context.call("GET", `/api/projects/${enc(projectId)}/plans`);
|
|
221
|
+
if (result.status !== 200) return problem(result, "Could not list plans");
|
|
222
|
+
const plans = (result.json.plans as Array<Record<string, any>>).map((plan) => ({
|
|
223
|
+
planId: plan.planId,
|
|
224
|
+
journeyId: plan.journeyId,
|
|
225
|
+
status: plan.status,
|
|
226
|
+
updatedAt: plan.updatedAt,
|
|
227
|
+
}));
|
|
228
|
+
return text({ plans, link: planLink(context, projectId) });
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: "get_test_plan",
|
|
233
|
+
title: "Read a test plan",
|
|
234
|
+
description: "Reads a test plan and the flow it runs, so you can review or change it.",
|
|
235
|
+
inputSchema: {
|
|
236
|
+
type: "object",
|
|
237
|
+
properties: { projectId: { type: "string" }, planId: { type: "string" } },
|
|
238
|
+
required: ["projectId", "planId"],
|
|
239
|
+
additionalProperties: false,
|
|
240
|
+
},
|
|
241
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
242
|
+
async run(args, context) {
|
|
243
|
+
const projectId = stringArg(args, "projectId");
|
|
244
|
+
const planId = stringArg(args, "planId");
|
|
245
|
+
const list = await context.call("GET", `/api/projects/${enc(projectId)}/plans`);
|
|
246
|
+
if (list.status !== 200) return problem(list, "Could not read plans");
|
|
247
|
+
const draft = (list.json.plans as Array<Record<string, any>>).find((plan) => plan.planId === planId);
|
|
248
|
+
if (!draft) return toolError(`No plan "${planId}" in this project. Use list_test_plans to see the plans.`);
|
|
249
|
+
const planFile = await context.call("GET", `/api/projects/${enc(projectId)}/artifacts/object?key=${enc(draft.objectKey)}`, undefined, { raw: true });
|
|
250
|
+
if (planFile.status === 503) return toolError("This project keeps its plans in its own storage, which Bugmole can't read. Open the plan in your repository instead.");
|
|
251
|
+
if (planFile.status !== 200) return problem(planFile, "Could not read the plan file");
|
|
252
|
+
let plan: any;
|
|
253
|
+
try {
|
|
254
|
+
plan = YAML.parse(planFile.text);
|
|
255
|
+
} catch {
|
|
256
|
+
return toolError("The plan file isn't valid YAML.");
|
|
257
|
+
}
|
|
258
|
+
const journey = (plan?.suites ?? []).flatMap((suite: any) => suite?.journeys ?? [])
|
|
259
|
+
.find((item: any) => item?.id === draft.journeyId) ?? plan?.suites?.[0]?.journeys?.[0];
|
|
260
|
+
const flowFile: unknown = journey?.flow ?? plan?.flow;
|
|
261
|
+
let flow: string | undefined;
|
|
262
|
+
if (typeof flowFile === "string" && !flowFile.split("/").includes("..")) {
|
|
263
|
+
const key = `projects/${enc(projectId)}/spec/${flowFile.replace(/^\/+/, "")}`;
|
|
264
|
+
const stored = await context.call("GET", `/api/projects/${enc(projectId)}/artifacts/object?key=${enc(key)}`, undefined, { raw: true });
|
|
265
|
+
if (stored.status === 200) flow = stored.text;
|
|
266
|
+
}
|
|
267
|
+
return text({
|
|
268
|
+
planId,
|
|
269
|
+
journeyId: draft.journeyId,
|
|
270
|
+
name: journey?.name ?? plan?.name,
|
|
271
|
+
platform: journey?.platform ?? plan?.platform,
|
|
272
|
+
flowFile: typeof flowFile === "string" ? flowFile : undefined,
|
|
273
|
+
flow: flow ?? null,
|
|
274
|
+
...(flow ? {} : { note: "The flow file isn't in Bugmole storage; it lives with the project's own files." }),
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: "save_test_plan",
|
|
280
|
+
title: "Save a test plan",
|
|
281
|
+
description: `Checks a flow and saves it as a test plan (creating or replacing it), ready to run. Only for projects whose files are stored by Bugmole.\n\n${FLOW_REFERENCE}`,
|
|
282
|
+
inputSchema: {
|
|
283
|
+
type: "object",
|
|
284
|
+
properties: {
|
|
285
|
+
projectId: { type: "string" },
|
|
286
|
+
planId: { type: "string", description: "Lowercase id, e.g. checkout-guest. Saving the same id replaces the plan." },
|
|
287
|
+
name: { type: "string", description: "What the flow proves, e.g. \"Guest can check out\"" },
|
|
288
|
+
flow: { type: "string", description: "The flow YAML" },
|
|
289
|
+
actor: { type: "string", description: "Who does this, e.g. guest, admin (default: user)" },
|
|
290
|
+
},
|
|
291
|
+
required: ["projectId", "planId", "name", "flow"],
|
|
292
|
+
additionalProperties: false,
|
|
293
|
+
},
|
|
294
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
295
|
+
async run(args, context) {
|
|
296
|
+
const projectId = stringArg(args, "projectId");
|
|
297
|
+
const planId = stringArg(args, "planId").toLowerCase();
|
|
298
|
+
const name = stringArg(args, "name").slice(0, 120);
|
|
299
|
+
// Stored as written; only the check trims.
|
|
300
|
+
const flow = typeof args.flow === "string" && args.flow.trim() ? args.flow : stringArg(args, "flow");
|
|
301
|
+
const actor = (stringArg(args, "actor", false) || "user").slice(0, 40);
|
|
302
|
+
if (!PLAN_ID.test(planId)) return toolError("planId must be lowercase letters, digits, dots, dashes or underscores (up to 80).");
|
|
303
|
+
if (flow.length > 100_000) return toolError("The flow is too long (100 KB at most).");
|
|
304
|
+
const project = await projectDefaults(context, projectId);
|
|
305
|
+
if (!project) return toolError(`No project "${projectId}" that you can use. Use list_projects.`);
|
|
306
|
+
if (project.storageMode !== "managed") {
|
|
307
|
+
return toolError("This project keeps its files in its own storage. Save the flow in the repository and publish it with `bugmole plan`, or use a project stored by Bugmole.");
|
|
308
|
+
}
|
|
309
|
+
const fallback = (project.environments ?? []).find((environment: Record<string, any>) => environment.isDefault)?.baseUrl;
|
|
310
|
+
let steps: FlowStep[];
|
|
311
|
+
try {
|
|
312
|
+
steps = parseMaestroStyleFlow(flow, fallback || undefined).steps;
|
|
313
|
+
} catch (error) {
|
|
314
|
+
return toolError(`The flow isn't valid: ${error instanceof Error ? error.message : String(error)}\n\n${FLOW_REFERENCE}`);
|
|
315
|
+
}
|
|
316
|
+
if (steps.length === 0) return toolError("The flow has no steps.");
|
|
317
|
+
const document = planDocument({ planId, name, journeyId: planId, actor, steps });
|
|
318
|
+
const putFlow = await context.call("PUT", `/api/projects/${enc(projectId)}/artifacts/object?key=${enc(flowKey(projectId, planId))}`, flow, { contentType: "text/yaml" });
|
|
319
|
+
if (putFlow.status !== 201) return problem(putFlow, "Could not save the flow");
|
|
320
|
+
const putPlan = await context.call("PUT", `/api/projects/${enc(projectId)}/artifacts/object?key=${enc(planKey(projectId, planId))}`, YAML.stringify(document), { contentType: "text/yaml" });
|
|
321
|
+
if (putPlan.status !== 201) return problem(putPlan, "Could not save the plan");
|
|
322
|
+
const published = await context.call("POST", `/api/projects/${enc(projectId)}/plans`, {
|
|
323
|
+
planId,
|
|
324
|
+
journeyId: planId,
|
|
325
|
+
objectKey: planKey(projectId, planId),
|
|
326
|
+
});
|
|
327
|
+
if (published.status !== 201) return problem(published, "Could not publish the plan");
|
|
328
|
+
return text({ planId, name, steps: countFlowSteps(steps), status: "saved", next: "run_tests", link: planLink(context, projectId) });
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
name: "run_tests",
|
|
333
|
+
title: "Run a test plan",
|
|
334
|
+
description: "Starts a run of a test plan. It runs on Bugmole Cloud (uses the workspace's cloud minutes) or on the project's own workers. Returns a run id; check it with get_run.",
|
|
335
|
+
inputSchema: {
|
|
336
|
+
type: "object",
|
|
337
|
+
properties: {
|
|
338
|
+
projectId: { type: "string" },
|
|
339
|
+
planId: { type: "string" },
|
|
340
|
+
environmentId: { type: "string", description: "Which environment's base URL to test (default: the project's default environment)" },
|
|
341
|
+
where: { type: "string", enum: ["cloud", "my-workers"], description: "Default: the project's setting" },
|
|
342
|
+
browsers: {
|
|
343
|
+
type: "array",
|
|
344
|
+
items: { type: "string", enum: ["chromium", "firefox", "webkit", "msedge"] },
|
|
345
|
+
description: "Browsers to run in, in parallel (default: chromium)",
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
required: ["projectId", "planId"],
|
|
349
|
+
additionalProperties: false,
|
|
350
|
+
},
|
|
351
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
352
|
+
async run(args, context) {
|
|
353
|
+
const projectId = stringArg(args, "projectId");
|
|
354
|
+
const planId = stringArg(args, "planId");
|
|
355
|
+
const project = await projectDefaults(context, projectId);
|
|
356
|
+
if (!project) return toolError(`No project "${projectId}" that you can use. Use list_projects.`);
|
|
357
|
+
const where = args.where === "cloud" || args.where === "my-workers" ? args.where : undefined;
|
|
358
|
+
const executionMode = where ? (where === "cloud" ? "cloud" : "self") : (project.defaultExecutionMode === "cloud" ? "cloud" : "self");
|
|
359
|
+
let environmentId = stringArg(args, "environmentId", false);
|
|
360
|
+
if (!environmentId) environmentId = (project.environments ?? []).find((environment: Record<string, any>) => environment.isDefault)?.id ?? "";
|
|
361
|
+
const browsers = Array.isArray(args.browsers) ? args.browsers.filter((value): value is string => typeof value === "string") : [];
|
|
362
|
+
const created = await context.call("POST", `/api/projects/${enc(projectId)}/runs`, {
|
|
363
|
+
planId,
|
|
364
|
+
idempotencyKey: `mcp:${crypto.randomUUID()}`,
|
|
365
|
+
executionMode,
|
|
366
|
+
...(environmentId ? { environmentId } : {}),
|
|
367
|
+
...(browsers.length ? { targets: browsers.map((browser) => ({ browser })) } : {}),
|
|
368
|
+
});
|
|
369
|
+
if (created.status !== 201 && created.status !== 200) return problem(created, "Could not start the run");
|
|
370
|
+
const run = created.json.run;
|
|
371
|
+
return text({
|
|
372
|
+
runId: run.id,
|
|
373
|
+
status: run.status,
|
|
374
|
+
where: executionMode === "cloud" ? "Bugmole Cloud" : "your workers",
|
|
375
|
+
...(executionMode === "self" ? { note: "A worker you run (`bugmole serve`) picks this up. It stays queued until one is online." } : {}),
|
|
376
|
+
next: "get_run",
|
|
377
|
+
link: runLink(context, projectId, run.id),
|
|
378
|
+
});
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
name: "get_run",
|
|
383
|
+
title: "Check a run",
|
|
384
|
+
description: "Shows a run's status, which steps passed or failed, errors, and the AI's explanation of a failure when there is one. Runs usually take a minute or two; check again while status is queued or running.",
|
|
385
|
+
inputSchema: {
|
|
386
|
+
type: "object",
|
|
387
|
+
properties: { runId: { type: "string" } },
|
|
388
|
+
required: ["runId"],
|
|
389
|
+
additionalProperties: false,
|
|
390
|
+
},
|
|
391
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
392
|
+
async run(args, context) {
|
|
393
|
+
const runId = stringArg(args, "runId");
|
|
394
|
+
const result = await context.call("GET", `/api/runs/${enc(runId)}`);
|
|
395
|
+
if (result.status !== 200) return problem(result, "Run not found");
|
|
396
|
+
const events = (result.json.events as Array<Record<string, any>>).slice(-15).map((event) => ({
|
|
397
|
+
type: event.type,
|
|
398
|
+
at: event.createdAt,
|
|
399
|
+
...(event.payload?.currentStep ? { step: event.payload.currentStep } : {}),
|
|
400
|
+
...(event.payload?.status ? { status: event.payload.status } : {}),
|
|
401
|
+
...(event.payload?.message ? { message: event.payload.message } : {}),
|
|
402
|
+
}));
|
|
403
|
+
return text({ ...runSummary(context, result.json.run), recentEvents: events });
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
name: "list_runs",
|
|
408
|
+
title: "List recent runs",
|
|
409
|
+
description: "Lists a project's recent runs, newest first, optionally for one plan.",
|
|
410
|
+
inputSchema: {
|
|
411
|
+
type: "object",
|
|
412
|
+
properties: {
|
|
413
|
+
projectId: { type: "string" },
|
|
414
|
+
planId: { type: "string" },
|
|
415
|
+
limit: { type: "number", minimum: 1, maximum: 50 },
|
|
416
|
+
},
|
|
417
|
+
required: ["projectId"],
|
|
418
|
+
additionalProperties: false,
|
|
419
|
+
},
|
|
420
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
421
|
+
async run(args, context) {
|
|
422
|
+
const projectId = stringArg(args, "projectId");
|
|
423
|
+
const planId = stringArg(args, "planId", false);
|
|
424
|
+
const limit = Math.min(50, Math.max(1, Number(args.limit) || 10));
|
|
425
|
+
const result = await context.call("GET", `/api/projects/${enc(projectId)}/runs`);
|
|
426
|
+
if (result.status !== 200) return problem(result, "Could not list runs");
|
|
427
|
+
const runs = (result.json.runs as RunRow[])
|
|
428
|
+
.filter((run) => !planId || run.planId === planId)
|
|
429
|
+
.slice(0, limit)
|
|
430
|
+
.map((run) => {
|
|
431
|
+
const { targets: _targets, ...summary } = runSummary(context, run);
|
|
432
|
+
return summary;
|
|
433
|
+
});
|
|
434
|
+
return text({ runs });
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
name: "explore_app",
|
|
439
|
+
title: "Explore an app",
|
|
440
|
+
description: "Asks Bugmole to explore a web app like a new user: it opens the URL, follows links and fills in forms, and saves a test plan for each flow it can write. Runs on Bugmole Cloud (uses cloud minutes) or the project's own workers. Check progress with get_exploration, then review the new plans with list_test_plans and get_test_plan.",
|
|
441
|
+
inputSchema: {
|
|
442
|
+
type: "object",
|
|
443
|
+
properties: {
|
|
444
|
+
projectId: { type: "string" },
|
|
445
|
+
url: { type: "string", description: "Where to start, e.g. https://staging.shop.example.com" },
|
|
446
|
+
goal: { type: "string", description: "What to focus on, e.g. \"checkout as a guest\"" },
|
|
447
|
+
where: { type: "string", enum: ["cloud", "my-workers"], description: "Default: the project's setting" },
|
|
448
|
+
},
|
|
449
|
+
required: ["projectId", "url"],
|
|
450
|
+
additionalProperties: false,
|
|
451
|
+
},
|
|
452
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
453
|
+
async run(args, context) {
|
|
454
|
+
const projectId = stringArg(args, "projectId");
|
|
455
|
+
const url = stringArg(args, "url");
|
|
456
|
+
const goal = stringArg(args, "goal", false).slice(0, 500);
|
|
457
|
+
let parsed: URL;
|
|
458
|
+
try {
|
|
459
|
+
parsed = new URL(url);
|
|
460
|
+
} catch {
|
|
461
|
+
return toolError("url must be a full http(s) address.");
|
|
462
|
+
}
|
|
463
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return toolError("url must be a full http(s) address.");
|
|
464
|
+
const project = await projectDefaults(context, projectId);
|
|
465
|
+
if (!project) return toolError(`No project "${projectId}" that you can use. Use list_projects.`);
|
|
466
|
+
const where = args.where === "cloud" || args.where === "my-workers" ? args.where : undefined;
|
|
467
|
+
const executionMode = where ? (where === "cloud" ? "cloud" : "self") : (project.defaultExecutionMode === "cloud" ? "cloud" : "self");
|
|
468
|
+
const created = await context.call("POST", `/api/projects/${enc(projectId)}/tasks`, {
|
|
469
|
+
title: `Explore ${parsed.host}${goal ? `: ${goal}` : ""}`.slice(0, 200),
|
|
470
|
+
description: `${goal ? `${goal}\n\n` : ""}${parsed.toString()}`,
|
|
471
|
+
taskType: "discovery",
|
|
472
|
+
idempotencyKey: `mcp:${crypto.randomUUID()}`,
|
|
473
|
+
executionMode,
|
|
474
|
+
});
|
|
475
|
+
if (created.status !== 201 && created.status !== 200) return problem(created, "Could not start exploring");
|
|
476
|
+
return text({
|
|
477
|
+
taskId: created.json.task.id,
|
|
478
|
+
status: created.json.task.status,
|
|
479
|
+
where: executionMode === "cloud" ? "Bugmole Cloud" : "your workers",
|
|
480
|
+
next: "get_exploration",
|
|
481
|
+
});
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
name: "get_exploration",
|
|
486
|
+
title: "Check an exploration",
|
|
487
|
+
description: "Shows how an exploration started with explore_app is going, and its summary when done.",
|
|
488
|
+
inputSchema: {
|
|
489
|
+
type: "object",
|
|
490
|
+
properties: { projectId: { type: "string" }, taskId: { type: "string" } },
|
|
491
|
+
required: ["projectId", "taskId"],
|
|
492
|
+
additionalProperties: false,
|
|
493
|
+
},
|
|
494
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
495
|
+
async run(args, context) {
|
|
496
|
+
const projectId = stringArg(args, "projectId");
|
|
497
|
+
const taskId = stringArg(args, "taskId");
|
|
498
|
+
const result = await context.call("GET", `/api/projects/${enc(projectId)}/tasks`);
|
|
499
|
+
if (result.status !== 200) return problem(result, "Could not read explorations");
|
|
500
|
+
const task = (result.json.tasks as Array<Record<string, any>>).find((row) => row.id === taskId);
|
|
501
|
+
if (!task) return toolError("No such exploration in this project (only the latest 100 are kept here).");
|
|
502
|
+
return text({
|
|
503
|
+
taskId: task.id,
|
|
504
|
+
title: task.title,
|
|
505
|
+
status: task.status,
|
|
506
|
+
progress: task.progressMessage ?? undefined,
|
|
507
|
+
summary: task.completionSummary ?? undefined,
|
|
508
|
+
error: task.errorMessage ?? undefined,
|
|
509
|
+
startedAt: task.startedAt ?? undefined,
|
|
510
|
+
completedAt: task.completedAt ?? undefined,
|
|
511
|
+
// Exploration saves a test plan for each flow it could write.
|
|
512
|
+
...(task.status === "completed" ? { next: "list_test_plans" } : {}),
|
|
513
|
+
});
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
name: "list_journeys",
|
|
518
|
+
title: "List journeys",
|
|
519
|
+
description: "Lists the user journeys Bugmole knows for a project (from exploration or saved by people), with whether each has been verified by a passing run.",
|
|
520
|
+
inputSchema: {
|
|
521
|
+
type: "object",
|
|
522
|
+
properties: { projectId: { type: "string" } },
|
|
523
|
+
required: ["projectId"],
|
|
524
|
+
additionalProperties: false,
|
|
525
|
+
},
|
|
526
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
527
|
+
async run(args, context) {
|
|
528
|
+
const projectId = stringArg(args, "projectId");
|
|
529
|
+
const result = await context.call("GET", `/api/projects/${enc(projectId)}/journeys`);
|
|
530
|
+
if (result.status !== 200) return problem(result, "Could not list journeys");
|
|
531
|
+
const latest = new Map<string, Record<string, any>>();
|
|
532
|
+
for (const revision of result.json.revisions as Array<Record<string, any>>) {
|
|
533
|
+
if (!latest.has(revision.journeyId)) latest.set(revision.journeyId, revision);
|
|
534
|
+
}
|
|
535
|
+
const journeys = [...latest.values()].map((revision) => ({
|
|
536
|
+
journeyId: revision.journeyId,
|
|
537
|
+
revision: revision.revision,
|
|
538
|
+
status: revision.status,
|
|
539
|
+
stability: revision.stability ?? undefined,
|
|
540
|
+
verifiedBy: revision.verificationRunId ?? undefined,
|
|
541
|
+
updatedAt: revision.createdAt,
|
|
542
|
+
}));
|
|
543
|
+
return text({ journeys });
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
];
|
|
547
|
+
|
|
548
|
+
export function toolList() {
|
|
549
|
+
return TOOLS.map(({ run: _run, ...definition }) => definition);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export async function callTool(name: string, args: unknown, context: ToolContext): Promise<ToolResult> {
|
|
553
|
+
const tool = TOOLS.find((candidate) => candidate.name === name);
|
|
554
|
+
if (!tool) return toolError(`Unknown tool: ${name}`);
|
|
555
|
+
const input = args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : {};
|
|
556
|
+
try {
|
|
557
|
+
return await tool.run(input, context);
|
|
558
|
+
} catch (error) {
|
|
559
|
+
if (error instanceof ToolInputError) return toolError(error.message);
|
|
560
|
+
console.error("[mcp] tool failed", name, error);
|
|
561
|
+
return toolError("Something went wrong on Bugmole's side. Try again in a moment.");
|
|
562
|
+
}
|
|
563
|
+
}
|