save_auth
Authenticate with a web application by executing login steps and save the session cookies and localStorage to a JSON file for later analysis.
Instructions
Authenticate with a web application and save the session for subsequent analysis. Navigates to the URL, executes login steps (click a button, fill a form, etc.), waits for the authenticated page to load, then saves cookies and localStorage to a JSON file. Overwrites the output file if it already exists.
Side effects: Writes a storageState JSON file to disk at outputPath. Launches a headed browser that interacts with the page (clicks, fills inputs). Not needed for public pages — only use when content is behind authentication.
Pass the output file path as storageState to analyze_url, trace_path, or analyze_pages to analyze authenticated content.
Steps format: Array of actions to perform in order. Each step is an object:
{ click: 'button text or selector' }— click a button/link{ fill: ['input selector', 'value'] }— fill an input field{ wait: 2000 }— wait N milliseconds{ waitForUrl: '/dashboard' }— wait until URL contains this string
Example for a dev login: steps: [{ click: 'Dev Login' }, { waitForUrl: '/workspace' }]
Example for form login: steps: [{ fill: ['#email', 'user@test.com'] }, { fill: ['#password', 'pass'] }, { click: 'Sign In' }, { waitForUrl: '/dashboard' }]
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| url | Yes | Login page URL | |
| steps | Yes | Login steps to execute (see description for format) | |
| outputPath | No | File path to save the storageState JSON (must be within cwd) | tactual-auth.json |
| timeout | No | Timeout per step in ms |
Implementation Reference
- src/pipeline/save-auth.ts:73-151 (handler)Core handler function `runSaveAuth` that authenticates with a web app and saves Playwright storageState. Validates URL, parses auth steps, acquires a browser, navigates to the login page, executes steps (click, fill, wait, waitForUrl), saves cookies/localStorage to a JSON file, and returns the result.
export async function runSaveAuth( opts: SaveAuthOptions, ): Promise<SaveAuthResult> { const urlCheck = validateUrl(opts.url); if (!urlCheck.valid) { throw new SaveAuthError("invalid-url", `Invalid URL: ${urlCheck.error}`); } const outputPath = opts.outputPath ?? "tactual-auth.json"; const resolved = resolvePath(outputPath); if (opts.restrictOutputToCwd) { const rel = relativePath(process.cwd(), resolved); if (rel.startsWith("..") || isAbsolute(rel)) { throw new SaveAuthError( "invalid-output-path", `Invalid outputPath: must be within the current working directory (${process.cwd()}). Resolved: ${resolved}`, ); } } const timeout = opts.timeout ?? 30000; const steps = parseAuthSteps(opts.steps); const fs = await import("fs/promises"); const { browser, owned } = await acquireBrowser( {}, { useSharedPool: opts.useSharedBrowserPool === true }, ); let context: BrowserContext | undefined; try { context = await browser.newContext(); const page = await context.newPage(); await page.goto(urlCheck.url!, { waitUntil: "domcontentloaded", timeout }); await page.waitForTimeout(2000); for (const step of steps) { if ("click" in step) { const target = step.click; const byRole = page .getByRole("button", { name: target }) .or(page.getByRole("link", { name: target })) .or(page.getByText(target, { exact: false })); const exists = (await byRole.count()) > 0; if (exists) { await byRole.first().click({ timeout }); } else { await page.click(target, { timeout }); } } else if ("fill" in step) { await page.fill(step.fill[0], step.fill[1]); } else if ("wait" in step) { await page.waitForTimeout(Math.min(step.wait, 60000)); } else if ("waitForUrl" in step) { await page.waitForURL(`**${step.waitForUrl}**`, { timeout }); } } await page.waitForTimeout(2000); const state = await context.storageState(); await fs.writeFile(resolved, JSON.stringify(state, null, 2), { mode: opts.fileMode ?? 0o600, }); const cookieCount = state.cookies?.length ?? 0; const originCount = state.origins?.length ?? 0; return { saved: outputPath, cookies: cookieCount, origins: originCount, currentUrl: page.url(), message: `Auth state saved. Pass storageState="${outputPath}" to analyze-url/analyze_url.`, }; } finally { await context?.close().catch(() => {}); if (owned) await closeBrowser(browser); } } - src/mcp/tools/save-auth.ts:12-69 (handler)MCP tool registration for 'save_auth'. Defines input schema (url, steps array, outputPath, timeout) and invokes the pipeline's `runSaveAuth`. Wraps result as MCP content response with error handling for SaveAuthError.
export function registerSaveAuth(server: McpServer): void { server.registerTool( "save_auth", { description: "Authenticate with a web application and save the session for subsequent analysis. " + "Navigates to the URL, executes login steps (click a button, fill a form, etc.), " + "waits for the authenticated page to load, then saves cookies and localStorage " + "to a JSON file. Overwrites the output file if it already exists.\n\n" + "**Side effects**: Writes a storageState JSON file to disk at `outputPath`. " + "Launches a headed browser that interacts with the page (clicks, fills inputs). " + "Not needed for public pages — only use when content is behind authentication.\n\n" + "Pass the output file path as `storageState` to analyze_url, trace_path, " + "or analyze_pages to analyze authenticated content.\n\n" + "**Steps format**: Array of actions to perform in order. Each step is an object:\n" + "- `{ click: 'button text or selector' }` — click a button/link\n" + "- `{ fill: ['input selector', 'value'] }` — fill an input field\n" + "- `{ wait: 2000 }` — wait N milliseconds\n" + "- `{ waitForUrl: '/dashboard' }` — wait until URL contains this string\n\n" + "Example for a dev login: `steps: [{ click: 'Dev Login' }, { waitForUrl: '/workspace' }]`\n" + "Example for form login: `steps: [{ fill: ['#email', 'user@test.com'] }, { fill: ['#password', 'pass'] }, { click: 'Sign In' }, { waitForUrl: '/dashboard' }]`", inputSchema: { url: z.string().describe("Login page URL"), steps: z .array(AuthStepInputSchema) .describe("Login steps to execute (see description for format)"), outputPath: z .string() .default("tactual-auth.json") .describe("File path to save the storageState JSON (must be within cwd)"), timeout: z.number().default(30000).describe("Timeout per step in ms"), }, }, async ({ url, steps, outputPath, timeout }) => { try { const result = await runSaveAuth({ url, steps: steps as Record<string, unknown>[], outputPath, timeout, restrictOutputToCwd: true, useSharedBrowserPool: true, }); return { content: [ { type: "text" as const, text: JSON.stringify(result, null, 2) }, ], }; } catch (err) { const text = err instanceof SaveAuthError ? err.message : `Auth failed: ${err instanceof Error ? err.message : String(err)}`; return { content: [{ type: "text" as const, text }], isError: true }; } }, ); } - src/pipeline/save-auth.ts:38-57 (schema)Type definitions: SaveAuthOptions (input config), SaveAuthResult (output shape with saved path, cookie/origin counts, currentUrl), AuthStep union type (click, fill, wait, waitForUrl), and Zod schemas (AuthStepSchema, AuthStepsSchema) for validation.
export interface SaveAuthOptions { url: string; steps: AuthStep[] | Record<string, unknown>[]; outputPath?: string; timeout?: number; /** MCP callers pass true; CLI passes false (explicit path choice). */ restrictOutputToCwd?: boolean; /** File mode for the saved JSON. Defaults to 0o600. */ fileMode?: number; /** Long-lived MCP/server callers can opt into the shared browser pool. */ useSharedBrowserPool?: boolean; } export interface SaveAuthResult { saved: string; cookies: number; origins: number; currentUrl: string; message: string; } - src/pipeline/save-auth.ts:157-167 (helper)Helper function `parseAuthSteps` that validates the steps array against the Zod schema and throws a SaveAuthError with details on invalid steps.
function parseAuthSteps(steps: AuthStep[] | Record<string, unknown>[]): AuthStep[] { const parsed = AuthStepsSchema.safeParse(steps); if (parsed.success) return parsed.data; const firstIssue = parsed.error.issues[0]; const path = firstIssue.path.length > 0 ? ` at steps.${firstIssue.path.join(".")}` : ""; throw new SaveAuthError( "invalid-step", `${firstIssue.message}${path}. Valid step types: click, fill, wait, waitForUrl.`, ); } - src/pipeline/save-auth.ts:173-190 (helper)Helper function `stepsFromCliFlags` that builds an AuthStep array from CLI flag values (fill pairs as selector=value, click text, waitForUrl pattern).
export function stepsFromCliFlags(opts: { fill?: string[]; click?: string; waitForUrl?: string; }): AuthStep[] { const steps: AuthStep[] = []; if (opts.fill) { for (const pair of opts.fill) { const [selector, value] = pair.split("=", 2); if (selector && value !== undefined) { steps.push({ fill: [selector, value] }); } } } if (opts.click) steps.push({ click: opts.click }); if (opts.waitForUrl) steps.push({ waitForUrl: opts.waitForUrl }); return steps; } - src/mcp/index.ts:40-40 (registration)Registration of save_auth tool in the MCP server via `registerSaveAuth(server)`.
registerSaveAuth(server); - src/cli/index.ts:32-32 (registration)Registration of save-auth CLI command via `registerSaveAuth(program)`.
registerSaveAuth(program);