Skip to main content
Glama
debugg-ai

Debugg AI MCP

Official
by debugg-ai

Probe Page

probe_page

Probe one or more URLs to retrieve rendered screenshots, page metadata, console errors, and network summaries. Use it to verify page state after changes or smoke-test multiple routes efficiently.

Instructions

Probe one or more URLs and return their rendered state — screenshot, page metadata (title/finalUrl/statusCode/loadTimeMs), structured console errors, and per-URL network summary (refetch loops collapse into one row by origin+pathname).

WHEN TO USE: "did I just break /settings?" / "smoke-test these 5 routes after my refactor" / "what's actually rendering at /dashboard?" — fast (<10s for 1 URL, <25s for 20), no LLM cost, no agent loop.

NOT FOR: scenario verification (sign in → click X → assert Y), interaction (clicks, form fills, scrolls), or anything requiring agent decisions. Use check_app_in_browser for those.

LOCALHOST SUPPORT: any localhost URL is auto-tunneled. Pre-flight TCP probe fails fast (<2s) if the dev server isn't listening.

BATCH MODE: pass up to 20 targets in one call to share browser session + tunnel — dramatically faster than firing parallel single-URL probes (one execution unit, not N). Per-URL waitForSelector / waitForLoadState / timeoutMs override defaults.

A single failed target's error appears in result.error without failing the whole batch — the other results stay valid.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
targetsYes1-20 URLs to probe. Each entry can carry its own per-URL wait config.
includeHtmlNoIf true, each result includes the page's outerHTML. Default false to keep response size sane.
captureScreenshotsNoIf true (default), one PNG screenshot is returned per target. Set false for very large batches or when only the structured data matters.
repoNameNoGitHub repository name (e.g. 'my-org/my-repo'). Auto-detected from the current git repo — only provide this to scope the probe to a different project context.

Implementation Reference

  • The main handler function `probePageHandler` that executes the probe_page tool logic. It performs pre-flight checks, tunnel setup, locates the workflow template, executes the backend workflow, polls for completion, and formats the response with screenshots.
    export async function probePageHandler(
      input: ProbePageInput,
      context: ToolContext,
      rawProgressCallback?: ProgressCallback,
    ): Promise<ToolResponse> {
      const startTime = Date.now();
      logger.toolStart('probe_page', input);
    
      // Bead 0bq: progress circuit-breaker — see testPageChangesHandler for rationale.
      let progressDisabled = false;
      const progressCallback: ProgressCallback | undefined = rawProgressCallback
        ? async (update) => {
            if (progressDisabled) return;
            try {
              await rawProgressCallback(update);
            } catch (err) {
              progressDisabled = true;
              logger.warn('Progress emission failed; disabling further emissions for this request', {
                error: err instanceof Error ? err.message : String(err),
              });
            }
          }
        : undefined;
    
      const client = new DebuggAIServerClient(config.api.key);
      await client.init();
    
      const abortController = new AbortController();
      const onStdinClose = () => {
        abortController.abort();
        progressDisabled = true;
      };
      process.stdin.once('close', onStdinClose);
    
      // Per-target tunnel contexts. Index aligns with input.targets[].
      const targetContexts: TunnelContext[] = [];
      // Tunnel keys we provisioned this call (for cleanup if creation fails after key acquired).
      const acquiredKeyIds: string[] = [];
    
      // Progress budget: 1 pre-flight + 1 template + 1 execute + N per-target captures + 1 done
      const TOTAL_STEPS = 3 + input.targets.length + 1;
      let progressStep = 0;
    
      try {
        if (progressCallback) {
          await progressCallback({ progress: ++progressStep, total: TOTAL_STEPS, message: `Pre-flight + tunnel setup (${input.targets.length} target${input.targets.length === 1 ? '' : 's'})...` });
        }
    
        // ── Per-target pre-flight + tunnel resolution ──────────────────────────
        for (const target of input.targets) {
          const ctx = buildContext(target.url);
    
          if (ctx.isLocalhost) {
            // Pre-flight TCP probe: fail fast if dev server isn't listening.
            const port = extractLocalhostPort(ctx.originalUrl);
            if (typeof port === 'number') {
              const probe = await probeLocalPort(port);
              if (!probe.reachable) {
                const payload = {
                  error: 'LocalServerUnreachable',
                  message: `No server listening on 127.0.0.1:${port}. Start your dev server on that port before running probe_page. Probe result: ${probe.code} (${probe.detail ?? 'no detail'}).`,
                  detail: {
                    port,
                    probeCode: probe.code,
                    probeDetail: probe.detail,
                    elapsedMs: probe.elapsedMs,
                  },
                };
                logger.warn(`Pre-flight port probe failed for ${ctx.originalUrl}: ${probe.code} in ${probe.elapsedMs}ms`);
                return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
              }
            }
    
            // Reuse existing tunnel for this port if any; otherwise provision.
            const reused = findExistingTunnel(ctx);
            if (reused) {
              targetContexts.push(reused);
            } else {
              let tunnel;
              try {
                tunnel = await client.tunnels!.provisionWithRetry();
              } catch (provisionError) {
                const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
                const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
                throw new Error(
                  `Failed to provision tunnel for ${ctx.originalUrl}. ` +
                  `(Detail: ${msg})${diag}`
                );
              }
              acquiredKeyIds.push(tunnel.keyId);
              let tunneled: TunnelContext;
              try {
                tunneled = await ensureTunnel(
                  ctx,
                  tunnel.tunnelKey,
                  tunnel.tunnelId,
                  tunnel.keyId,
                  () => client.revokeNgrokKey(tunnel.keyId),
                );
              } catch (tunnelError) {
                const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
                throw new Error(
                  `Tunnel creation failed for ${ctx.originalUrl}. (Detail: ${msg})`
                );
              }
    
              // Tunnel health probe: catch the IPv4/IPv6 bind / dead-server case
              // before committing to a full backend execution.
              if (tunneled.targetUrl) {
                const health = await probeTunnelHealth(tunneled.targetUrl);
                if (!health.healthy) {
                  const payload = {
                    error: 'TunnelTrafficBlocked',
                    message: `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`,
                    detail: {
                      code: health.code,
                      status: health.status,
                      ngrokErrorCode: health.ngrokErrorCode,
                      elapsedMs: health.elapsedMs,
                    },
                  };
                  if (tunneled.tunnelId) {
                    tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) =>
                      logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`),
                    );
                  }
                  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
                }
              }
    
              targetContexts.push(tunneled);
            }
          } else {
            // Public URL — no tunnel needed.
            targetContexts.push(ctx);
          }
        }
    
        // ── Locate workflow template ───────────────────────────────────────────
        if (progressCallback) {
          await progressCallback({ progress: ++progressStep, total: TOTAL_STEPS, message: 'Locating page-probe workflow template...' });
        }
    
        const templateUuid = await getCachedTemplateUuid(TEMPLATE_KEYWORD, async (name) => {
          return client.workflows!.findTemplateByName(name);
        });
        if (!templateUuid) {
          throw new Error(
            `Page Probe Workflow Template not found. ` +
            `Ensure the backend has a template matching "${TEMPLATE_KEYWORD}" seeded and accessible.`,
          );
        }
    
        // ── Build contextData (camelCase; axiosTransport snake_cases on the wire) ──
        // Backend's browser.setup node (shared with App Evaluation + Raw Crawl
        // templates) requires `target_url` (singular). The Page Probe template
        // currently uses that node as-is — the per-target loop primitive is
        // pending. Send BOTH:
        //   - targetUrl: first target's tunneled URL (satisfies browser.setup
        //     today; will keep working when the loop wraps it later)
        //   - targets[]: the full per-URL config for when the loop primitive
        //     ships and iterates over them
        const firstTargetUrl = targetContexts[0]?.targetUrl ?? input.targets[0].url;
        const contextData: Record<string, any> = {
          targetUrl: firstTargetUrl,
          targets: input.targets.map((t, i) => ({
            url: targetContexts[i].targetUrl ?? t.url,
            // Send null (not undefined) for optional fields so the field exists
            // in the target object even when the caller didn't pass one. Backend
            // placeholder resolver was fixed in commit 154e1e69 to type-preserve
            // null in single-placeholder substitutions, so null flows through.
            waitForSelector: t.waitForSelector ?? null,
            waitForLoadState: t.waitForLoadState,
            timeoutMs: t.timeoutMs,
          })),
          // Backend's browser.capture template binds {{include_dom}} and
          // {{include_screenshot}} from contextData (verified 2026-04-29).
          // The MCP-facing schema keeps `includeHtml` / `captureScreenshots`
          // for caller ergonomics; we just map them to what the template wants.
          includeDom: input.includeHtml,
          includeScreenshot: input.captureScreenshots,
          // Keep the original keys too for any downstream node that reads them
          // (cheap to send, future-proof against template field-name churn).
          includeHtml: input.includeHtml,
          captureScreenshots: input.captureScreenshots,
        };
    
        // ── Execute ────────────────────────────────────────────────────────────
        if (progressCallback) {
          await progressCallback({ progress: ++progressStep, total: TOTAL_STEPS, message: 'Queuing workflow execution...' });
        }
    
        const executeResponse = await client.workflows!.executeWorkflow(templateUuid, contextData);
        const executionUuid = executeResponse.executionUuid;
        logger.info(`Probe execution queued: ${executionUuid}`);
    
        // ── Poll ───────────────────────────────────────────────────────────────
        let lastCompleted = -1;
        const finalExecution = await client.workflows!.pollExecution(executionUuid, async (exec) => {
          // Keep all active tunnels alive during polling.
          for (const tc of targetContexts) {
            if (tc.tunnelId) touchTunnelById(tc.tunnelId);
          }
    
          if (!progressCallback) return;
    
          const completedNodes = (exec.nodeExecutions ?? []).filter(
            n => n.nodeType === 'browser.capture' && n.status === 'success',
          ).length;
          if (completedNodes !== lastCompleted) {
            lastCompleted = completedNodes;
            await progressCallback({
              progress: Math.min(progressStep + completedNodes, TOTAL_STEPS - 1),
              total: TOTAL_STEPS,
              message: `Probed ${completedNodes}/${input.targets.length} target${input.targets.length === 1 ? '' : 's'}...`,
            });
          }
        }, abortController.signal);
    
        // ── Format response ────────────────────────────────────────────────────
        const duration = Date.now() - startTime;
        const captureNodes = (finalExecution.nodeExecutions ?? [])
          .filter(n => n.nodeType === 'browser.capture')
          .sort((a, b) => a.executionOrder - b.executionOrder);
    
        const results: ProbePageResult[] = [];
    
        for (let i = 0; i < input.targets.length; i++) {
          const target = input.targets[i];
          const node = captureNodes[i];
          const data: any = node?.outputData ?? {};
    
          // Backend (post-154e1e69) emits browser.capture output_data with:
          //   captured_url, status_code, title, load_time_ms,
          //   console_slice (already per-capture, in {text, level, location, timestamp} shape),
          //   network_summary (already pre-aggregated by FULL URL,
          //                    in {url, count, methods[], statuses{}, resource_types[]} shape),
          //   surfer_page_uuid (reference to SurferPage row for screenshot/title/visible_text),
          //   error
          // axiosTransport snake→camel'd at the wire, so JS-side these are
          // capturedUrl / consoleSlice / networkSummary / surferPageUuid / etc.
          // Re-aggregate networkSummary by origin+pathname so refetch loops
          // collapse (preserves the original client-feedback contract).
          const result: ProbePageResult = {
            url: target.url, // ORIGINAL caller URL — not the tunneled rewrite
            finalUrl: typeof data.capturedUrl === 'string' ? data.capturedUrl
                    : typeof data.finalUrl === 'string' ? data.finalUrl
                    : typeof data.url === 'string' ? data.url
                    : target.url,
            statusCode: typeof data.statusCode === 'number' ? data.statusCode : 0,
            title: typeof data.title === 'string' ? data.title : null,
            loadTimeMs: typeof data.loadTimeMs === 'number' ? data.loadTimeMs : 0,
            consoleErrors: mapConsoleSlice(Array.isArray(data.consoleSlice) ? data.consoleSlice : []),
            networkSummary: reaggregateByOriginPath(Array.isArray(data.networkSummary) ? data.networkSummary : []),
          };
    
          if (input.includeHtml && typeof data.html === 'string') {
            result.html = data.html;
          }
          if (typeof data.error === 'string' && data.error) {
            result.error = data.error;
          }
          if (typeof data.surferPageUuid === 'string' && data.surferPageUuid) {
            result.surferPageUuid = data.surferPageUuid;
          }
    
          results.push(result);
        }
    
        const responsePayload: Record<string, any> = {
          executionId: executionUuid,
          durationMs: typeof finalExecution.durationMs === 'number' ? finalExecution.durationMs : duration,
          results,
        };
    
        if (finalExecution.browserSession) {
          responsePayload.browserSession = finalExecution.browserSession;
        }
    
        // Sanitize ngrok URLs from the entire payload — agent-authored strings in
        // node outputData (titles, HTML, console messages from the page itself)
        // can occasionally contain the tunnel URL; rewrite to the original
        // localhost origin per tunnel context. For multi-localhost batches we
        // run sanitize once per localhost target since each may have its own
        // tunnel↔origin mapping.
        let sanitizedPayload: any = responsePayload;
        for (const tc of targetContexts) {
          if (tc.isLocalhost) {
            sanitizedPayload = sanitizeResponseUrls(sanitizedPayload, tc);
          }
        }
    
        logger.toolComplete('probe_page', duration);
    
        const responseContent: ToolResponse['content'] = [
          { type: 'text', text: JSON.stringify(sanitizedPayload, null, 2) },
        ];
    
        // Embed screenshots when captureScreenshots is true. The backend may return
        // screenshotB64 or a URL-keyed field on browser.capture outputData.
        if (input.captureScreenshots) {
          const SCREENSHOT_URL_KEYS = ['screenshotB64', 'screenshot', 'screenshotUrl', 'screenshotUri', 'finalScreenshot'];
          for (const node of captureNodes) {
            const data: any = node?.outputData ?? {};
            if (typeof data.screenshotB64 === 'string' && data.screenshotB64) {
              responseContent.push(imageContentBlock(data.screenshotB64, 'image/png'));
            } else {
              let screenshotUrl: string | null = null;
              for (const key of SCREENSHOT_URL_KEYS) {
                if (key !== 'screenshotB64' && typeof data[key] === 'string' && data[key]) {
                  screenshotUrl = data[key] as string;
                  break;
                }
              }
              if (screenshotUrl) {
                const img = await fetchImageAsBase64(screenshotUrl).catch(() => null);
                if (img) responseContent.push(imageContentBlock(img.data, img.mimeType));
              }
            }
          }
        }
    
        return { content: responseContent };
      } catch (error) {
        const duration = Date.now() - startTime;
        logger.toolError('probe_page', error as Error, duration);
    
        if (error instanceof Error && (error.message.includes('not found') || error.message.includes('401'))) {
          invalidateTemplateCache();
        }
        throw handleExternalServiceError(error, 'DebuggAI', 'probe_page execution');
      } finally {
        process.stdin.removeListener('close', onStdinClose);
        // Tunnels intentionally NOT torn down — reuse pattern (bead vwd) +
        // 55-min idle auto-shutoff. Revoke only orphaned keys (we acquired the
        // key but tunnel creation failed before ensureTunnel completed).
        for (let i = 0; i < acquiredKeyIds.length; i++) {
          const keyId = acquiredKeyIds[i];
          const tc = targetContexts[i];
          if (tc && !tc.tunnelId && keyId) {
            client.revokeNgrokKey(keyId).catch(err =>
              logger.warn(`Failed to revoke unused ngrok key ${keyId}: ${err}`),
            );
          }
        }
      }
    }
  • Zod schemas `ProbePageTargetSchema`, `ProbePageInputSchema` and TypeScript types `ProbePageTarget`, `ProbePageInput`, `ProbePageResult`, `ProbePageResponse`, `NetworkSummaryEntry`, `ConsoleErrorEntry` defining the input/output validation for the probe_page tool.
    export const ProbePageTargetSchema = z.object({
      url: z.preprocess(
        normalizeUrl,
        z.string().url('Invalid URL. Pass a full URL like "http://localhost:3000" or "https://example.com". Localhost URLs are auto-tunneled to the remote browser.'),
      ),
      waitForSelector: z.string().optional(),
      waitForLoadState: z.enum(['load', 'domcontentloaded', 'networkidle']).default('load'),
      timeoutMs: z.number().int().min(1000, 'timeoutMs minimum is 1000 (1s)').max(30000, 'timeoutMs maximum is 30000 (30s) — longer probes should use check_app_in_browser').default(10000),
    }).strict();
    
    export const ProbePageInputSchema = z.object({
      targets: z.array(ProbePageTargetSchema).min(1, 'targets must have at least one URL').max(20, 'targets capped at 20 per call — split larger sweeps across multiple calls'),
      includeHtml: z.boolean().default(false),
      captureScreenshots: z.boolean().default(true),
      repoName: z.string().optional(),
    }).strict();
  • Tool definition and registration: `buildProbePageTool()` creates the raw Tool with name 'probe_page', and `buildValidatedProbePageTool()` wraps it with the Zod schema and handler.
    export function buildProbePageTool(): Tool {
      return {
        name: 'probe_page',
        title: 'Probe Page',
        description: DESCRIPTION,
        inputSchema: {
          type: 'object',
          properties: {
            targets: {
              type: 'array',
              minItems: 1,
              maxItems: 20,
              items: {
                type: 'object',
                properties: TARGET_PROPERTIES,
                required: ['url'],
                additionalProperties: false,
              },
              description: '1-20 URLs to probe. Each entry can carry its own per-URL wait config.',
            },
            includeHtml: {
              type: 'boolean',
              description: "If true, each result includes the page's outerHTML. Default false to keep response size sane.",
            },
            captureScreenshots: {
              type: 'boolean',
              description: 'If true (default), one PNG screenshot is returned per target. Set false for very large batches or when only the structured data matters.',
            },
            repoName: {
              type: 'string',
              description: "GitHub repository name (e.g. 'my-org/my-repo'). Auto-detected from the current git repo — only provide this to scope the probe to a different project context.",
            },
          },
          required: ['targets'],
          additionalProperties: false,
        },
      };
    }
    
    export function buildValidatedProbePageTool(): ValidatedTool {
      const tool = buildProbePageTool();
      return {
        ...tool,
        inputSchema: ProbePageInputSchema,
        handler: probePageHandler,
      };
    }
  • tools/index.ts:24-59 (registration)
    Tool registration in the main tools index: `buildProbePageTool()` (line 28) and `buildValidatedProbePageTool()` (line 42) are called in `initTools()` to register probe_page in the tool registry.
    export function initTools(ctx: ProjectContext | null): void {
      const tools: Tool[] = [
        buildTestPageChangesTool(ctx),
        buildTriggerCrawlTool(ctx),
        buildProbePageTool(),
        buildSearchProjectsTool(),
        buildSearchEnvironmentsTool(),
        buildCreateEnvironmentTool(),
        buildUpdateEnvironmentTool(),
        buildDeleteEnvironmentTool(),
        buildUpdateProjectTool(),
        buildDeleteProjectTool(),
        buildSearchExecutionsTool(),
        buildCreateProjectTool(),
      ];
      const validated: ValidatedTool[] = [
        buildValidatedTestPageChangesTool(ctx),
        buildValidatedTriggerCrawlTool(ctx),
        buildValidatedProbePageTool(),
        buildValidatedSearchProjectsTool(),
        buildValidatedSearchEnvironmentsTool(),
        buildValidatedCreateEnvironmentTool(),
        buildValidatedUpdateEnvironmentTool(),
        buildValidatedDeleteEnvironmentTool(),
        buildValidatedUpdateProjectTool(),
        buildValidatedDeleteProjectTool(),
        buildValidatedSearchExecutionsTool(),
        buildValidatedCreateProjectTool(),
      ];
    
      _tools = tools;
      _validatedTools = validated;
    
      toolRegistry.clear();
      for (const v of validated) toolRegistry.set(v.name, v);
    }
  • Helper logic: Per-target pre-flight checks and tunnel resolution using `probeLocalPort`, `findExistingTunnel`, `ensureTunnel`, and `probeTunnelHealth` — the tunnel provisioning and health probe utilities supporting the handler.
    // ── Per-target pre-flight + tunnel resolution ──────────────────────────
    for (const target of input.targets) {
      const ctx = buildContext(target.url);
    
      if (ctx.isLocalhost) {
        // Pre-flight TCP probe: fail fast if dev server isn't listening.
        const port = extractLocalhostPort(ctx.originalUrl);
        if (typeof port === 'number') {
          const probe = await probeLocalPort(port);
          if (!probe.reachable) {
            const payload = {
              error: 'LocalServerUnreachable',
              message: `No server listening on 127.0.0.1:${port}. Start your dev server on that port before running probe_page. Probe result: ${probe.code} (${probe.detail ?? 'no detail'}).`,
              detail: {
                port,
                probeCode: probe.code,
                probeDetail: probe.detail,
                elapsedMs: probe.elapsedMs,
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Description is transparent about localhost auto-tunneling, fast fail on unresponsive servers, batch session sharing, and partial failure handling. However, it does not explicitly state that the operation is read-only, though implied by 'probe'.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Well-structured with clear sections (WHEN TO USE, NOT FOR, LOCALHOST SUPPORT, BATCH MODE). Every sentence adds value without redundancy.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Describes output (screenshot, metadata, console errors, network summary) and covers edge cases (localhost, batch failure). Lacks explicit format details for console errors/network summary, but sufficient for agent understanding.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema covers all parameters with descriptions. The description adds valuable context: batch mode behavior, per-URL overrides, and defaults (includeHtml false, captureScreenshots true). Slightly above baseline due to practical usage details.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states it probes URLs and returns rendered state (screenshot, metadata, console errors, network summary). It explicitly distinguishes from the sibling tool check_app_in_browser for scenario verification.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Provides explicit WHEN TO USE (smoke testing, fast checks) and NOT FOR (interaction, scenario verification) with an alternative named (check_app_in_browser).

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/debugg-ai/debugg-ai-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server