import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { createServer } from "http"; import { execFileSync } from "child_process"; import { randomBytes, createHash } from "crypto"; import { readFileSync, writeFileSync, existsSync } from "fs"; import { z } from "zod"; import "dotenv/config"; // === CONFIG === const BB = process.env.BB_BROWSER_PATH || (process.env.HOME + "/local/bin/bb-browser"); const PORT = parseInt(process.env.PORT || "8080"); const CDP_PORT = process.env.CDP_PORT || "9222"; const BASE_URL = process.env.BASE_URL || "http://localhost:8080"; const VERSION = "0.6.1"; const DM_PIN = process.env.DM_PIN || ""; if (DM_PIN && !/^\d{4}$/.test(DM_PIN)) { console.error("Error: DM_PIN must be exactly 4 digits"); process.exit(1); } const OAUTH_CLIENT_ID = process.env.OAUTH_CLIENT_ID || "twitter-mcp-client"; const OAUTH_CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || randomBytes(32).toString("hex"); var registeredRedirectUris = []; const authCodes = new Map(); // code -> { expires, codeChallenge, redirectUri } const accessTokens = new Map(); // token -> expiresAt // === TOKEN PERSISTENCE === var TOKEN_FILE = new URL(".tokens.json", import.meta.url).pathname; function saveTokens() { try { var data = { tokens: Object.fromEntries(accessTokens), codes: Object.fromEntries(authCodes) }; writeFileSync(TOKEN_FILE, JSON.stringify(data)); } catch(e) { console.error("saveTokens error:", e.message); } } function loadTokens() { try { if (!existsSync(TOKEN_FILE)) return; var raw = readFileSync(TOKEN_FILE, "utf-8"); var data = JSON.parse(raw); var now = Date.now(); if (data.tokens) { for (var [k,v] of Object.entries(data.tokens)) { if (v > now) accessTokens.set(k, v); } } if (data.codes) { for (var [k,v] of Object.entries(data.codes)) { if (v.expires > now) authCodes.set(k, v); } } console.log("Loaded " + accessTokens.size + " tokens, " + authCodes.size + " codes from disk"); } catch(e) { console.error("loadTokens error:", e.message); } } loadTokens(); // Cleanup expired auth codes and tokens every 60s setInterval(function() { var now = Date.now(); for (var [k, v] of authCodes) { if (v.expires < now) authCodes.delete(k); } for (var [k, v] of accessTokens) { if (v < now) accessTokens.delete(k); } saveTokens(); }, 60000); // === BROWSER HELPERS === function bbDirect(...bbArgs) { var args = ["--port", CDP_PORT].concat(bbArgs); try { return execFileSync(BB, args, { encoding: "utf-8", timeout: 30000 }).trim(); } catch (e) { return "Error: " + (e.stderr || e.message || "").substring(0, 500); } } function sleep(ms) { return new Promise(function(r) { setTimeout(r, ms); }); } function isTwitterUrl(s) { try { var u = new URL(s); return /^https?:$/.test(u.protocol) && /^(.*\.)?(x\.com|twitter\.com)$/.test(u.hostname); } catch { return false; } } function jsStr(s) { return JSON.stringify(String(s)); } var MAX_TABS = 10; function cleanupTabs() { try { var list = bbDirect("tab", "list"); var m = list.match(/共 (\d+) 个/); if (m && parseInt(m[1]) > MAX_TABS) { var count = parseInt(m[1]); for (var i = count - 1; i >= 1 && count - i < count - MAX_TABS + 2; i--) { bbDirect("tab", "close", String(i)); } } } catch(e) {} } // === SHARED TWEET PARSER === // Single source of truth: open URL -> wait -> parse tweet articles from DOM // Used by: timeline, search, bookmarks, tweets function parseTweetsJS(cnt) { return '(function(){ var tweets=[]; var articles=document.querySelectorAll(\'article[data-testid="tweet"]\'); for(var i=0;i= 0) break; await sleep(1500); } await sleep(1000); // Parse main tweet var mainJS = '(function(){ var article=document.querySelector(\'article[data-testid="tweet"]\'); if(!article)return JSON.stringify({error:"not found"}); var user=article.querySelector(\'[data-testid="User-Name"]\'); var text=article.querySelector(\'[data-testid="tweetText"]\'); var time=article.querySelector("time"); var imgs=Array.from(article.querySelectorAll(\'[data-testid="tweetPhoto"] img\')).map(function(x){return x.src}); var video=article.querySelector("video"); var likes=article.querySelector(\'[data-testid="like"]\'); var rts=article.querySelector(\'[data-testid="retweet"]\'); return JSON.stringify({user:user?user.textContent:"",text:text?text.textContent:"",time:time?time.getAttribute("datetime"):"",images:imgs,has_video:!!video,likes:likes?likes.textContent.trim():"0",retweets:rts?rts.textContent.trim():"0"}); })()'; var mainTweet = bbDirect("eval", mainJS); // If include_replies, scroll and grab replies if (p.include_replies !== false) { for (var scrollI = 0; scrollI < 5; scrollI++) { bbDirect("eval", "window.scrollBy(0, 1000)"); await sleep(1500); } await sleep(2000); var repliesJS = '(function(){ var cells=document.querySelectorAll(\'[data-testid="cellInnerDiv"]\'); var replies=[]; var skippedFirst=false; for(var i=0;i= 0) { await sleep(4000); } // Get conversation list var convos = bbDirect("eval", dmConvoListJS()); // Click first conversation (needs pointer event sequence, not just click()) var clickResult = bbDirect("eval", dmClickConvoJS()); if (clickResult.indexOf("no conversation") >= 0) { return { content: [{ type: "text", text: JSON.stringify({ conversations: JSON.parse(convos || "[]"), messages: [] }) }] }; } await sleep(4000); // Read messages (clone nodes, strip time elements, then read clean text) var msgs = bbDirect("eval", dmReadMessagesJS()); // Return single clean JSON try { var result = { conversations: JSON.parse(convos || "[]"), chat: JSON.parse(msgs || "{}") }; return { content: [{ type: "text", text: JSON.stringify(result) }] }; } catch(e) { return { content: [{ type: "text", text: JSON.stringify({ error: "parse error", raw_convos: convos, raw_msgs: msgs }) }] }; } }); // === BROWSER TOOLS === s.tool("browser_open", "Open any URL (http/https only)", { url: z.string() }, async function(p) { try { var u = new URL(p.url); if (!/^https?:$/.test(u.protocol)) throw 0; } catch { return { content: [{ type: "text", text: "Error: only http/https URLs allowed" }] }; } cleanupTabs(); return { content: [{ type: "text", text: bbDirect("open", p.url) }] }; }); s.tool("browser_snapshot", "Get current page content", { depth: z.number().optional() }, async function(p) { return { content: [{ type: "text", text: bbDirect("snapshot", "-c", "-d", String(p.depth || 5)) }] }; }); return s; } // ============================================================ // HTTP SERVER + OAUTH // ============================================================ function parseBody(req, maxBytes) { maxBytes = maxBytes || 65536; return new Promise(function(resolve, reject) { var b = ""; req.on("data", function(c) { b += c; if (b.length > maxBytes) { req.destroy(); reject(new Error("body too large")); } }); req.on("end", function() { resolve(b); }); }); } var httpServer = createServer(async function(req, res) { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, mcp-session-id"); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } var url = new URL(req.url, "http://localhost:" + PORT); console.log(new Date().toISOString() + " " + req.method + " " + url.pathname + (req.headers["authorization"] ? " [auth]" : "")); // OAuth discovery if (url.pathname === "/.well-known/oauth-protected-resource") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ resource: BASE_URL, authorization_servers: [BASE_URL] })); return; } if (url.pathname === "/.well-known/oauth-authorization-server") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ issuer: BASE_URL, authorization_endpoint: BASE_URL + "/authorize", token_endpoint: BASE_URL + "/token", registration_endpoint: BASE_URL + "/register", response_types_supported: ["code"], grant_types_supported: ["authorization_code"], token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"], code_challenge_methods_supported: ["S256", "plain"] })); return; } // OAuth dynamic registration if (url.pathname === "/register" && req.method === "POST") { var body = JSON.parse(await parseBody(req)); var uris = body.redirect_uris || []; registeredRedirectUris = uris; res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ client_id: OAUTH_CLIENT_ID, client_secret: OAUTH_CLIENT_SECRET, client_name: body.client_name || "claude", redirect_uris: uris })); return; } // OAuth authorize (auto-approve, validates redirect_uri) if (url.pathname === "/authorize") { var rawRedir = url.searchParams.get("redirect_uri"); if (!rawRedir || (registeredRedirectUris.length > 0 && !registeredRedirectUris.includes(rawRedir))) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid_redirect_uri" })); return; } var redir = new URL(rawRedir); var code = randomBytes(32).toString("hex"); authCodes.set(code, { expires: Date.now() + 300000, codeChallenge: url.searchParams.get("code_challenge"), redirectUri: rawRedir }); saveTokens(); redir.searchParams.set("code", code); if (url.searchParams.get("state")) redir.searchParams.set("state", url.searchParams.get("state")); res.writeHead(302, { Location: redir.toString() }); res.end(); return; } // OAuth token exchange if (url.pathname === "/token" && req.method === "POST") { var tbody = new URLSearchParams(await parseBody(req)); var stored = authCodes.get(tbody.get("code")); if (!stored || stored.expires < Date.now()) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid_grant" })); return; } if (stored.codeChallenge && tbody.get("code_verifier")) { if (createHash("sha256").update(tbody.get("code_verifier")).digest("base64url") !== stored.codeChallenge) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid_grant" })); return; } } if (stored.redirectUri && tbody.get("redirect_uri") !== stored.redirectUri) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid_grant" })); return; } authCodes.delete(tbody.get("code")); var token = randomBytes(48).toString("hex"); accessTokens.set(token, Date.now() + 86400000); saveTokens(); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ access_token: token, token_type: "Bearer", expires_in: 86400 })); return; } // MCP endpoint if (url.pathname === "/mcp") { var auth = req.headers["authorization"]; var tk = auth ? auth.replace("Bearer ", "") : ""; if (!tk || !accessTokens.has(tk) || accessTokens.get(tk) < Date.now()) { if (tk) { accessTokens.delete(tk); saveTokens(); } res.writeHead(401, { "Content-Type": "application/json", "WWW-Authenticate": "Bearer" }); res.end(JSON.stringify({ error: "unauthorized" })); return; } var transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); var server = makeServer(); await server.connect(transport); await transport.handleRequest(req, res); return; } res.writeHead(404); res.end("Not found"); }); httpServer.listen(PORT, "0.0.0.0", function() { console.log("Twitter MCP Bridge v" + VERSION + ": http://0.0.0.0:" + PORT + "/mcp"); });