Skip to main content
Glama

analyze_pages

Analyze multiple web pages in a single session to produce an aggregated site-level accessibility report with per-page breakdown and site score.

Instructions

Analyze multiple pages and produce an aggregated site-level report. Runs analyze_url on each URL in a single browser session and combines results into a site score with per-page breakdown. Read-only — navigates to each URL but does not modify pages.

Use this instead of calling analyze_url repeatedly when you need a site-level assessment. Returns ~200 bytes per page plus a site-level summary. If a single URL fails (timeout, bot protection), its entry shows the error and remaining URLs still complete.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
urlsYesURLs to analyze (1-20 pages)
profileNoAT profile IDgeneric-mobile-web-sr-v0
waitForSelectorNoCSS selector to wait for on each page (for SPAs)
waitTimeNoAdditional wait per page in ms
timeoutNoPage load timeout per URL
storageStateNoPath to Playwright storageState JSON for authenticated pages. Use save_auth to create. Must be within cwd.

Implementation Reference

  • MCP tool registration: calls server.registerTool('analyze_pages', ...) with input schema and handler. This is the entry point where the tool 'analyze_pages' is registered with the MCP server.
    export function registerAnalyzePages(server: McpServer): void {
      server.registerTool(
        "analyze_pages",
        {
          description:
            "Analyze multiple pages and produce an aggregated site-level report. " +
            "Runs analyze_url on each URL in a single browser session and combines " +
            "results into a site score with per-page breakdown. " +
            "Read-only — navigates to each URL but does not modify pages.\n\n" +
            "Use this instead of calling analyze_url repeatedly when you need a site-level assessment. " +
            "Returns ~200 bytes per page plus a site-level summary. " +
            "If a single URL fails (timeout, bot protection), its entry shows the error and " +
            "remaining URLs still complete.",
          inputSchema: {
            urls: z.array(z.string()).describe("URLs to analyze (1-20 pages)"),
            profile: z
              .string()
              .default("generic-mobile-web-sr-v0")
              .describe("AT profile ID"),
            waitForSelector: z.string().optional().describe("CSS selector to wait for on each page (for SPAs)"),
            waitTime: z.number().optional().describe("Additional wait per page in ms"),
            timeout: z.number().default(30000).describe("Page load timeout per URL"),
            storageState: z
              .string()
              .optional()
              .describe("Path to Playwright storageState JSON for authenticated pages. Use save_auth to create. Must be within cwd."),
          },
        },
        async ({ urls, profile, waitForSelector, waitTime, timeout, storageState }) => {
          try {
            const result = await runAnalyzePages({
              urls,
              profileId: profile,
              waitForSelector,
              waitTime,
              timeout,
              storageState,
              restrictStorageStateToCwd: true,
              maxUrls: 20,
              useSharedBrowserPool: true,
            });
            return {
              content: [
                { type: "text" as const, text: JSON.stringify(result, null, 2) },
              ],
            };
          } catch (err) {
            if (err instanceof AnalyzePagesError) {
              let text = err.message;
              if (err.code === "unknown-profile") {
                text += `. Available: ${listProfiles().join(", ")}`;
              }
              return { content: [{ type: "text" as const, text }], isError: true };
            }
            return {
              content: [
                {
                  type: "text" as const,
                  text: `Error: ${err instanceof Error ? err.message : String(err)}`,
                },
              ],
              isError: true,
            };
          }
        },
      );
    }
  • Input schema for analyze_pages: defines urls (array of strings), profile (default 'generic-mobile-web-sr-v0'), waitForSelector (optional), waitTime (optional), timeout (default 30000), and storageState (optional).
    inputSchema: {
      urls: z.array(z.string()).describe("URLs to analyze (1-20 pages)"),
      profile: z
        .string()
        .default("generic-mobile-web-sr-v0")
        .describe("AT profile ID"),
      waitForSelector: z.string().optional().describe("CSS selector to wait for on each page (for SPAs)"),
      waitTime: z.number().optional().describe("Additional wait per page in ms"),
      timeout: z.number().default(30000).describe("Page load timeout per URL"),
      storageState: z
        .string()
        .optional()
        .describe("Path to Playwright storageState JSON for authenticated pages. Use save_auth to create. Must be within cwd."),
    },
  • MCP tool async handler: calls runAnalyzePages(...) with the parsed inputs, then returns JSON-stringified result. Also handles AnalyzePagesError (with profile listing) and generic errors, returning isError: true on failures.
    async ({ urls, profile, waitForSelector, waitTime, timeout, storageState }) => {
      try {
        const result = await runAnalyzePages({
          urls,
          profileId: profile,
          waitForSelector,
          waitTime,
          timeout,
          storageState,
          restrictStorageStateToCwd: true,
          maxUrls: 20,
          useSharedBrowserPool: true,
        });
        return {
          content: [
            { type: "text" as const, text: JSON.stringify(result, null, 2) },
          ],
        };
      } catch (err) {
        if (err instanceof AnalyzePagesError) {
          let text = err.message;
          if (err.code === "unknown-profile") {
            text += `. Available: ${listProfiles().join(", ")}`;
          }
          return { content: [{ type: "text" as const, text }], isError: true };
        }
        return {
          content: [
            {
              type: "text" as const,
              text: `Error: ${err instanceof Error ? err.message : String(err)}`,
            },
          ],
          isError: true,
        };
      }
    },
  • Core implementation (runAnalyzePages): orchestrates the full batch analysis. Loops over URLs, opens pages in a shared browser context, captures state, runs the analyzer, aggregates per-page and site-level scores, and returns AnalyzePagesResult with site and pages data. Also includes helper functions: buildRepeatedNavigationSummary, isRepeatedNavigationCandidate, normalizeTargetName, countLinearSteps, and emptyPageResult.
    export async function runAnalyzePages(
      opts: AnalyzePagesOptions,
    ): Promise<AnalyzePagesResult> {
      if (opts.urls.length === 0) {
        throw new AnalyzePagesError("no-urls", "At least one URL is required.");
      }
      if (opts.maxUrls && opts.urls.length > opts.maxUrls) {
        throw new AnalyzePagesError(
          "too-many-urls",
          `Maximum ${opts.maxUrls} URLs per call.`,
        );
      }
    
      const profileId = opts.profileId ?? "generic-mobile-web-sr-v0";
      const profile = getProfile(profileId);
      if (!profile) {
        throw new AnalyzePagesError(
          "unknown-profile",
          `Unknown profile: ${profileId}`,
        );
      }
    
      const pw = await import("playwright");
      const { captureState } = await import("../playwright/capture.js");
    
      const { browser, owned } = await acquireBrowser(
        {},
        { useSharedPool: opts.useSharedBrowserPool === true },
      );
      const ctxBuild = buildContextOptions(
        {
          storageState: opts.storageState,
          restrictStorageStateToCwd: opts.restrictStorageStateToCwd,
        },
        pw,
      );
      if (ctxBuild.error) {
        if (owned) await browser.close().catch(() => {});
        throw new AnalyzePagesError("bad-input", ctxBuild.error);
      }
    
      let context: BrowserContext | undefined;
      try {
        context = await browser.newContext(ctxBuild.options);
    
        const timeout = opts.timeout ?? 30000;
        const pageResults: PageAggregation[] = [];
        const repeatedInputs: RepeatedNavigationPageInput[] = [];
        const allScores: number[] = [];
        const allSeverity: Record<string, number> = {
          severe: 0,
          high: 0,
          moderate: 0,
          acceptable: 0,
          strong: 0,
        };
    
        for (const url of opts.urls) {
          const urlCheck = validateUrl(url);
          if (!urlCheck.valid) {
            pageResults.push(
              emptyPageResult(url, [`invalid-url: ${urlCheck.error}`]),
            );
            continue;
          }
    
          const pageWarnings: string[] = [];
          try {
            const page = await context.newPage();
            let state: TactualPageState;
            try {
              await page.goto(urlCheck.url!, {
                waitUntil: "domcontentloaded",
                timeout,
              });
              await Promise.race([
                page.waitForLoadState("networkidle").catch(() => {}),
                new Promise((r) => setTimeout(r, 5000)),
              ]);
              if (opts.waitForSelector) {
                const found = await page
                  .waitForSelector(opts.waitForSelector, { timeout })
                  .catch(() => null);
                if (!found) {
                  pageWarnings.push(
                    `waitForSelector "${opts.waitForSelector}" did not appear within ${timeout}ms`,
                  );
                }
              }
              if (opts.waitTime && opts.waitTime > 0) {
                await page.waitForTimeout(opts.waitTime);
              }
    
              state = await captureState(page, {
                provenance: "scripted",
                spaWaitTimeout: 15000,
              });
            } finally {
              await page.close().catch(() => {});
            }
    
            const result = analyze([state], profile, { name: url });
            const scores = result.findings.map((f) => f.scores.overall);
            const sorted = [...scores].sort((a, b) => a - b);
    
            const avg =
              scores.length > 0
                ? Math.round(
                    (scores.reduce((a, b) => a + b, 0) / scores.length) * 10,
                  ) / 10
                : 0;
            const p10 =
              sorted.length >= 5
                ? sorted[Math.max(0, Math.ceil(sorted.length * 0.1) - 1)]
                : sorted[0] ?? 0;
            const median =
              sorted.length > 0 ? sorted[Math.floor(sorted.length * 0.5)] : 0;
            const worst = sorted[0] ?? 0;
    
            const sev: Record<string, number> = {
              severe: 0,
              high: 0,
              moderate: 0,
              acceptable: 0,
              strong: 0,
            };
            for (const f of result.findings) {
              sev[f.severity]++;
              allSeverity[f.severity]++;
            }
            allScores.push(...scores);
    
            const diags = [
              ...pageWarnings,
              ...result.diagnostics
                .filter((d) => d.level !== "info" && d.code !== "ok")
                .map((d) => d.code),
            ];
    
            const worstFinding = result.findings.sort(
              (a, b) => a.scores.overall - b.scores.overall,
            )[0];
    
            pageResults.push({
              url,
              targets: result.findings.length,
              p10,
              median,
              average: avg,
              worst,
              severityCounts: sev,
              diagnostics: diags,
              topIssue: worstFinding
                ? `${worstFinding.targetId} (${worstFinding.scores.overall}/100): ${
                    worstFinding.penalties[0] ?? worstFinding.severity
                  }`
                : null,
            });
            repeatedInputs.push({
              url,
              findings: result.findings,
              targets: state.targets,
            });
          } catch (err) {
            pageResults.push(
              emptyPageResult(url, [
                `error: ${err instanceof Error ? err.message.slice(0, 80) : "unknown"}`,
              ]),
            );
          }
        }
    
        const allSorted = [...allScores].sort((a, b) => a - b);
        const siteP10 =
          allSorted.length >= 5
            ? allSorted[Math.max(0, Math.ceil(allSorted.length * 0.1) - 1)]
            : allSorted[0] ?? 0;
        const siteMedian =
          allSorted.length > 0
            ? allSorted[Math.floor(allSorted.length * 0.5)]
            : 0;
        const siteAverage =
          allScores.length > 0
            ? Math.round(
                (allScores.reduce((a, b) => a + b, 0) / allScores.length) * 10,
              ) / 10
            : 0;
    
        return {
          site: {
            pagesAnalyzed: pageResults.length,
            totalTargets: allScores.length,
            p10: siteP10,
            median: siteMedian,
            average: siteAverage,
            worst: allSorted[0] ?? 0,
            severityCounts: allSeverity,
            repeatedNavigation: buildRepeatedNavigationSummary(repeatedInputs),
          },
          pages: pageResults,
        };
      } finally {
        await context?.close().catch(() => {});
        if (owned) await browser.close().catch(() => {});
      }
    }
    
    interface RepeatedNavigationPageInput {
      url: string;
      findings: Finding[];
      targets: Target[];
    }
    
    export function buildRepeatedNavigationSummary(
      pages: RepeatedNavigationPageInput[],
    ): RepeatedNavigationSummary {
      const groups = new Map<string, {
        label: string;
        role: string;
        kind: string;
        pages: Set<string>;
        examples: RepeatedNavigationExample[];
      }>();
    
      for (const page of pages) {
        const targetById = new Map(page.targets.map((t) => [t.id, t]));
        for (const finding of page.findings) {
          const target = targetById.get(finding.targetId);
          if (!target || !isRepeatedNavigationCandidate(target)) continue;
          const label = target.name?.trim() || target.id;
          const signature = `${target.kind}|${target.role}|${normalizeTargetName(label)}`;
          const group = groups.get(signature) ?? {
            label,
            role: target.role,
            kind: target.kind,
            pages: new Set<string>(),
            examples: [],
          };
          group.pages.add(page.url);
          group.examples.push({
            url: page.url,
            targetId: finding.targetId,
            score: finding.scores.overall,
            linearSteps: countLinearSteps(finding.bestPath),
            penalty: finding.penalties[0],
          });
          groups.set(signature, group);
        }
      }
    
      const repeatedGroups: RepeatedNavigationGroup[] = [];
      for (const [signature, group] of groups) {
        const pageCount = group.pages.size;
        if (pageCount < 2 || group.examples.length < 2) continue;
        const scores = group.examples.map((e) => e.score);
        const totalLinearSteps = group.examples.reduce((sum, e) => sum + e.linearSteps, 0);
        const penalties = new Map<string, number>();
        for (const example of group.examples) {
          if (!example.penalty) continue;
          penalties.set(example.penalty, (penalties.get(example.penalty) ?? 0) + 1);
        }
        repeatedGroups.push({
          signature,
          label: group.label,
          role: group.role,
          kind: group.kind,
          pageCount,
          totalOccurrences: group.examples.length,
          averageScore: Math.round(scores.reduce((sum, s) => sum + s, 0) / scores.length),
          worstScore: Math.min(...scores),
          averageLinearSteps: Math.round((totalLinearSteps / group.examples.length) * 10) / 10,
          totalLinearSteps,
          topPenalties: [...penalties.entries()]
            .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
            .slice(0, 3)
            .map(([penalty]) => penalty),
          examples: group.examples
            .sort((a, b) => a.score - b.score || b.linearSteps - a.linearSteps)
            .slice(0, 5),
        });
      }
    
      repeatedGroups.sort((a, b) =>
        b.totalLinearSteps - a.totalLinearSteps ||
        a.averageScore - b.averageScore ||
        b.totalOccurrences - a.totalOccurrences,
      );
    
      const worstGroups = repeatedGroups.slice(0, 10);
      return {
        repeatedTargets: repeatedGroups.length,
        totalOccurrences: repeatedGroups.reduce((sum, g) => sum + g.totalOccurrences, 0),
        totalLinearSteps: repeatedGroups.reduce((sum, g) => sum + g.totalLinearSteps, 0),
        worstGroups,
      };
    }
    
    function isRepeatedNavigationCandidate(target: Target): boolean {
      return new Set([
        "link",
        "button",
        "menuTrigger",
        "menuItem",
        "tab",
        "formField",
        "search",
        "pagination",
        "disclosure",
      ]).has(target.kind);
    }
    
    function normalizeTargetName(name: string): string {
      return name.toLowerCase().replace(/\s+/g, " ").trim();
    }
    
    function countLinearSteps(path: string[]): number {
      return path.filter((step) => step.startsWith("nextItem:")).length;
    }
    
    function emptyPageResult(url: string, diagnostics: string[]): PageAggregation {
      return {
        url,
        targets: 0,
        p10: 0,
        median: 0,
        average: 0,
        worst: 0,
        severityCounts: {
          severe: 0,
          high: 0,
          moderate: 0,
          acceptable: 0,
          strong: 0,
        },
        diagnostics,
        topIssue: null,
      };
    }
  • CLI command handler for 'analyze-pages': parses command-line arguments and calls runAnalyzePages(...). Also includes console-based pretty-printing in printConsole().
        .action(async (urls: string[], opts: Record<string, unknown>) => {
          try {
            const result = await runAnalyzePages({
              urls,
              profileId: opts.profile as string | undefined,
              waitForSelector: opts.waitForSelector as string | undefined,
              waitTime: opts.waitTime ? parseInt(opts.waitTime as string, 10) : undefined,
              timeout: parseInt((opts.timeout as string) ?? "30000", 10),
              storageState: opts.storageState as string | undefined,
            });
    
            if (opts.format === "json") {
              console.log(JSON.stringify(result, null, 2));
            } else {
              printConsole(result, opts.profile as string | undefined, urls.length);
            }
          } catch (err) {
            if (err instanceof AnalyzePagesError) {
              console.error(err.message);
              if (err.code === "unknown-profile") {
                console.error(`Available: ${listProfiles().join(", ")}`);
              }
              process.exit(1);
            }
            if (
              err instanceof Error &&
              (err.message.includes("Cannot find module") ||
                err.message.includes("Cannot find package"))
            ) {
              console.error("Playwright is required. Install it: npm install playwright");
              process.exit(1);
            }
            throw err;
          }
        });
    }
Behavior5/5

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

The description explicitly states the tool is read-only (does not modify pages) and describes the behavior of running analyze_url in a single browser session. With no annotations provided, the description carries the full burden and addresses key behavioral aspects.

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?

The description is two paragraphs, front-loaded with the verb and purpose, and every sentence adds value. No wasted words.

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?

Given the tool has no annotations and no output schema, the description explains the return format (approximate size per page) and error handling. It could be more explicit about the site-level summary structure, but it is adequate for a tool of moderate complexity.

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

Parameters3/5

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

Schema description coverage is 100%, so the baseline is 3. The description adds overall context but does not provide additional meaning beyond the schema's parameter descriptions.

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 the tool analyzes multiple pages and produces an aggregated site-level report, and distinguishes it from the sibling tool analyze_url by explaining it runs analyze_url on each URL and combines results.

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?

The description explicitly advises to use this tool instead of calling analyze_url repeatedly for a site-level assessment, and describes error handling behavior where failed URLs produce errors while others continue.

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/tactual-dev/tactual'

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