diff --git a/stagehand/package.json b/stagehand/package.json index 9b8f327..d9f4165 100644 --- a/stagehand/package.json +++ b/stagehand/package.json @@ -15,6 +15,7 @@ ], "scripts": { "build": "tsc && shx chmod +x dist/*.js", + "dev": "npm run build && STAGEHAND_HTTP_PORT=8081 node dist/index.js", "prepare": "npm run build", "watch": "tsc --watch" }, diff --git a/stagehand/src/httpStaticServer.ts b/stagehand/src/httpStaticServer.ts new file mode 100644 index 0000000..7568311 --- /dev/null +++ b/stagehand/src/httpStaticServer.ts @@ -0,0 +1,94 @@ +import http from "http"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Function to clean the tmp directory +function cleanTmpDirectory(directory: string) { + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); + return; + } + + try { + const files = fs.readdirSync(directory); + for (const file of files) { + if (file.startsWith('.')) continue; // Skip hidden files + + const filePath = path.join(directory, file); + fs.unlinkSync(filePath); + } + console.log(`Cleaned tmp directory: ${directory}`); + } catch (err) { + console.error(`Error cleaning tmp directory: ${err}`); + } +} + +export function startStaticHttpServer() { + const TMP_DIR = path.resolve(__dirname, "../tmp"); + const HTTP_PORT = process.env.STAGEHAND_HTTP_PORT ? parseInt(process.env.STAGEHAND_HTTP_PORT, 10) : 8080; + + // Clean the tmp directory on startup + cleanTmpDirectory(TMP_DIR); + + const server = http.createServer((req, res) => { + if (!req.url) { + res.writeHead(400); + res.end("Bad Request"); + return; + } + // Only allow /tmp/ URLs + if (!req.url.startsWith("/tmp/")) { + res.writeHead(404); + res.end("Not Found"); + return; + } + // Directory listing for /tmp/ or /tmp + if (req.url === "/tmp/" || req.url === "/tmp") { + fs.readdir(TMP_DIR, (err, files) => { + if (err) { + res.writeHead(500); + res.end("Failed to read directory"); + return; + } + const links = files + .filter(f => !f.startsWith(".")) + .map(f => `
  • ${f}
  • `) // encode for safety + .join("\n"); + const html = `tmp Directory Listing

    Files in /tmp/

    `; + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(html); + }); + return; + } + // Serve individual files + const filePath = path.join(TMP_DIR, req.url.replace("/tmp/", "")); + if (!filePath.startsWith(TMP_DIR)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end("Not Found"); + return; + } + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(data); + }); + }); + let actualPort = HTTP_PORT; + server.listen(HTTP_PORT, () => { + const address = server.address(); + if (address && typeof address === 'object') { + actualPort = address.port; + } + // eslint-disable-next-line no-console + console.log(`Static file server running at http://localhost:${actualPort}/tmp/`); + }); + return { server, port: actualPort }; +} \ No newline at end of file diff --git a/stagehand/src/index.ts b/stagehand/src/index.ts index 74a35fe..52bab08 100644 --- a/stagehand/src/index.ts +++ b/stagehand/src/index.ts @@ -2,7 +2,13 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { createServer } from "./server.js"; -import { ensureLogDirectory, registerExitHandlers, scheduleLogRotation, setupLogRotation } from "./logging.js"; +import { + ensureLogDirectory, + registerExitHandlers, + scheduleLogRotation, + setupLogRotation, +} from "./logging.js"; +import { startStaticHttpServer } from "./httpStaticServer.js"; // Run setup for logging ensureLogDirectory(); @@ -10,6 +16,9 @@ setupLogRotation(); scheduleLogRotation(); registerExitHandlers(); +// Start the static HTTP server for /tmp and capture the port +const { port: staticHttpPort } = startStaticHttpServer(); + // Run the server async function runServer() { const server = createServer(); @@ -17,7 +26,7 @@ async function runServer() { await server.connect(transport); server.sendLoggingMessage({ level: "info", - data: "Stagehand MCP server is ready to accept requests", + data: `Stagehand MCP server is ready to accept requests. Static HTTP server running on port ${staticHttpPort}`, }); } diff --git a/stagehand/src/tools.ts b/stagehand/src/tools.ts index e04a39e..c991904 100644 --- a/stagehand/src/tools.ts +++ b/stagehand/src/tools.ts @@ -2,6 +2,11 @@ import { Stagehand } from "@browserbasehq/stagehand"; import { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; import { getServerInstance, operationLogs } from "./logging.js"; import { screenshots } from "./resources.js"; +import { fileURLToPath } from "url"; +import path from "path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); // Define the Stagehand tools export const TOOLS: Tool[] = [ @@ -69,6 +74,21 @@ export const TOOLS: Tool[] = [ required: ["instruction"], }, }, + { + name: "stagehand_get_html", + description: + "Captures the raw HTML of the current webpage. Use this tool when you need to analyze the page structure or extract specific HTML elements. This tool returns a URL that you need to download to access the HTML content.", + inputSchema: { + type: "object", + properties: { + selector: { + type: "string", + description: + "Optional selector to get HTML for a specific element. Both CSS and XPath selectors are supported. If omitted, returns the entire page HTML.", + }, + }, + }, + }, { name: "screenshot", description: @@ -233,6 +253,146 @@ export async function handleToolCall( }; } + case "stagehand_get_html": + try { + const html = await stagehand.page.evaluate((selector) => { + if (selector) { + try { + // Check if the selector is an XPath selector + if ( + selector.startsWith("/") || + selector.startsWith("./") || + selector.startsWith("//") + ) { + // Handle XPath selector + const result = document.evaluate( + selector, + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ); + const element = result.singleNodeValue; + if (!element || !(element instanceof Element)) { + return ` + +XPath Element Not Found + +

    XPath Element Not Found

    +

    The XPath selector did not match any elements on the page.

    +

    Details:

    + +

    Suggestions:

    + + +`; + } + return element.outerHTML; + } else { + // Handle CSS selector + const element = document.querySelector(selector); + if (!element || !(element instanceof Element)) { + return ` + +CSS Element Not Found + +

    CSS Element Not Found

    +

    The CSS selector did not match any elements on the page.

    +

    Details:

    + +

    Suggestions:

    + + +`; + } + return element.outerHTML; + } + } catch (err: unknown) { + return `Selector error: ${ + err instanceof Error ? err.message : String(err) + }. For XPath, use '//' or '/' prefix. For CSS, use standard selectors.`; + } + } + return document.documentElement.outerHTML; + }, args.selector || null); + + // Save HTML to a file in the tmp directory + const fs = await import("fs/promises"); + const { randomBytes } = await import("crypto"); + const TMP_DIR = path.resolve(__dirname, "../tmp"); + await fs.mkdir(TMP_DIR, { recursive: true }); + const unique = `${Date.now()}-${randomBytes(6).toString("hex")}`; + const filename = `stagehand-html-${unique}.html`; + const filePath = path.join(TMP_DIR, filename); + await fs.writeFile(filePath, html, "utf8"); + const port = process.env.STAGEHAND_HTTP_PORT || 8080; + const url = `http://localhost:${port}/tmp/${filename}`; + + return { + content: [ + { + type: "text", + text: `HTML saved to: ${url}`, + }, + ], + isError: false, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: `Failed to get HTML: ${errorMsg}`, + }, + { + type: "text", + text: `Operation logs:\n${operationLogs.join("\n")}`, + }, + ], + isError: true, + }; + } + case "screenshot": try { const screenshotBuffer = await stagehand.page.screenshot({