├── logo.png ├── logoblack.png ├── robots.txt ├── sitemap.xml ├── worker ├── tsconfig.json ├── package.json ├── wrangler.toml ├── README.md └── src │ └── index.ts ├── .gitignore ├── .github └── FUNDING.yml ├── README.md ├── LICENSE ├── logo.svg ├── logowhite.svg └── index.html /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ami3466/tomcp/HEAD/logo.png -------------------------------------------------------------------------------- /logoblack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ami3466/tomcp/HEAD/logoblack.png -------------------------------------------------------------------------------- /robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | Sitemap: https://tomcp.org/sitemap.xml 5 | -------------------------------------------------------------------------------- /sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://tomcp.org/ 5 | weekly 6 | 1.0 7 | 8 | 9 | -------------------------------------------------------------------------------- /worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "strict": true, 7 | "lib": ["ES2022"], 8 | "types": ["@cloudflare/workers-types"], 9 | "noEmit": true 10 | }, 11 | "include": ["src/**/*"] 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | # Editor 7 | *.swp 8 | *.swo 9 | *~ 10 | 11 | # IDE 12 | .idea/ 13 | .vscode/ 14 | *.sublime-project 15 | *.sublime-workspace 16 | 17 | # Node 18 | node_modules/ 19 | npm-debug.log* 20 | 21 | # Cloudflare Workers 22 | .wrangler/ 23 | .dev.vars 24 | 25 | # Build 26 | dist/ 27 | *.tsbuildinfo 28 | -------------------------------------------------------------------------------- /worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flowengine-mcp-worker", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "wrangler dev --port 8787", 7 | "deploy": "wrangler deploy", 8 | "tail": "wrangler tail" 9 | }, 10 | "devDependencies": { 11 | "@cloudflare/workers-types": "^4.20241127.0", 12 | "typescript": "^5.3.3", 13 | "wrangler": "^3.91.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "tomcp" 2 | main = "src/index.ts" 3 | compatibility_date = "2024-12-01" 4 | account_id = "ec62a93ac5823c4621864bda8abb2be4" # Ami3466@gmail.com 5 | 6 | # Environment variables 7 | [vars] 8 | CF_ACCOUNT_ID = "ec62a93ac5823c4621864bda8abb2be4" 9 | 10 | # Workers AI - free tier, no API key needed 11 | [ai] 12 | binding = "AI" 13 | 14 | # Routes - www redirects to non-www in the worker code 15 | routes = [ 16 | { pattern = "tomcp.org/*", zone_name = "tomcp.org" }, 17 | { pattern = "www.tomcp.org/*", zone_name = "tomcp.org" } 18 | ] 19 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ami3466 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /worker/README.md: -------------------------------------------------------------------------------- 1 | # toMCP Worker 2 | 3 | Cloudflare Worker that powers [tomcp.org](https://tomcp.org) - convert any website to an MCP server + Chat with any website. 4 | 5 | ## Features 6 | 7 | - **MCP Server**: Turn any URL into an MCP server for AI tools (Cursor, Claude, Windsurf, VS Code, Cline) 8 | - **Chat API**: Chat with any website's content using Llama 3.1 9 | - **Rate Limited**: 10 requests/IP/day, 100 total/day (protects free tier) 10 | 11 | ## Setup 12 | 13 | ```bash 14 | cd worker 15 | npm install 16 | ``` 17 | 18 | ## Development 19 | 20 | ```bash 21 | npm run dev # Local dev on http://localhost:8787 22 | ``` 23 | 24 | ## Deploy 25 | 26 | ```bash 27 | npx wrangler login # Login to Cloudflare 28 | npm run deploy # Deploy to production 29 | ``` 30 | 31 | ## API Endpoints 32 | 33 | ### MCP Protocol 34 | ``` 35 | POST https://tomcp.org/{website-url} 36 | ``` 37 | Implements MCP JSON-RPC protocol with `fetch_page` and `search` tools. 38 | 39 | ### Chat API 40 | ``` 41 | POST https://tomcp.org/chat 42 | Content-Type: application/json 43 | 44 | { 45 | "url": "docs.stripe.com", 46 | "message": "How do I create a payment intent?", 47 | "history": [] // optional 48 | } 49 | ``` 50 | 51 | ## Rate Limits 52 | 53 | - 5 requests per IP per day 54 | - 200 total requests per day (global) 55 | - Resets at midnight UTC 56 | - Bypass with your own API key (coming soon) 57 | 58 | ## Tech Stack 59 | 60 | - Cloudflare Workers 61 | - Cloudflare Workers AI (Llama 3.1 8B) 62 | - TypeScript 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # toMCP 2 | 3 | **Turn any website into an MCP server** 4 | 5 | Simply add `tomcp.org/` before any URL: 6 | Or go to https://tomcp.org and paste the URL there. 7 | 8 | ## Why toMCP? 9 | specific APIs often cause AI hallucinations, and web searching is unreliable. This tool lets you serve your documentation as an MCP server, giving the AI direct access to the clear context it needs without manual copy-pasting. 10 | 11 | Standard `web_fetch` tools dump raw HTML into your AI's context—navbars, scripts, footers, and noise. toMCP runs pages through a **readability parser** and converts to **clean markdown**, using a fraction of the tokens. 12 | 13 | ### Persistent Documentation Context 14 | 15 | AI assistants hallucinate API details when they lack documentation. MCP Resources are **pinned as permanent, read-only context**—the model won't skip or forget them. Ideal for framework docs, API references, and internal team docs. 16 | 17 | ### web_fetch vs MCP Resources 18 | 19 | | | web_fetch | MCP Resource | 20 | |--|-----------|--------------| 21 | | Data | Raw HTML with noise | Clean markdown | 22 | | Tokens | High | Low | 23 | | Persistence | Per-request | Always available | 24 | | Hallucination | Higher | Lower | 25 | | JS Support | Full (SPAs / Dynamic) | Static Only (SSG) | 26 | 27 | ## Demo 28 | 29 | [![toMCP Demo](https://img.youtube.com/vi/-o2_T8TB9dQ/maxresdefault.jpg)](https://www.youtube.com/watch?v=-o2_T8TB9dQ) 30 | 31 | 32 | ## Supported AI Tools 33 | 34 | - **Cursor** - `~/.cursor/mcp.json` 35 | - **Claude Desktop** - `~/.claude/claude_desktop_config.json` 36 | - **Windsurf** - `~/.codeium/windsurf/mcp_config.json` 37 | - **VS Code** - `.vscode/mcp.json` 38 | - **Cline** - `~/.cline/mcp_settings.json` 39 | 40 | ## How It Works 41 | 42 | ### MCP Config 43 | 1. Visit [tomcp.org](https://tomcp.org) 44 | 2. Enter any website URL 45 | 3. Select your AI tool 46 | 4. Copy the generated MCP config 47 | 5. Add it to your tool's config file 48 | 6. Restart your AI tool 49 | 50 | ### Chat 51 | 1. Visit [tomcp.org](https://tomcp.org) 52 | 2. Paste any website URL 53 | 3. Click "Start Chat" 54 | 4. Ask questions about the website's content 55 | 56 | ## Example Config 57 | 58 | ```json 59 | { 60 | "mcpServers": { 61 | "docs-stripe-com": { 62 | "url": "https://tomcp.org/docs.stripe.com" 63 | } 64 | } 65 | } 66 | ``` 67 | 68 | ## Chat API 69 | 70 | ```bash 71 | curl -X POST https://tomcp.org/chat \ 72 | -H "Content-Type: application/json" \ 73 | -d '{"url": "docs.stripe.com", "message": "How do I create a payment intent?"}' 74 | ``` 75 | 76 | ## AI Models 77 | 78 | ### Free Models (No API Key Required) 79 | These models are available for everyone with no setup: 80 | - **Llama 3.1 8B** (Meta) - Default model, fast and capable 81 | - **Hermes 2 Pro** (NousResearch) - Great for reasoning 82 | - **Mistral 7B** (Mistral) - Efficient instruction-following 83 | - **Gemma 7B LoRA** (Google) - Lightweight and fast 84 | 85 | ### paid Models (API Key Required) 86 | Add your Cloudflare Workers AI API key to unlock these models: 87 | - **Llama 3.3 70B** (Meta) - Most powerful Llama model 88 | - **DeepSeek R1 32B** (DeepSeek) - Advanced reasoning 89 | - **Mistral Large** (Mistral) - Enterprise-grade 90 | - **Gemma 3 12B** (Google) - Latest Gemma 91 | - **GPT OSS 120B/20B** (OpenAI) - Open-source GPT variants 92 | 93 | ## Adding Your API Key 94 | 95 | You can add your own Cloudflare Workers AI API key to: 96 | 1. **Unlock all paid models** - Access larger, more capable models 97 | 2. **Bypass rate limits** - No daily request limits 98 | 3. **Use your own quota** - Charges go to your Cloudflare account 99 | 100 | ### How to Get an API Key 101 | 1. Go to [Cloudflare Workers AI](https://developers.cloudflare.com/workers-ai/get-started/rest-api/#1-get-api-token-and-account-id) 102 | 2. Create an API token with Workers AI permissions 103 | 3. Copy the token 104 | 105 | ### How to Add Your Key 106 | 1. Start a chat session on [tomcp.org](https://tomcp.org) 107 | 2. Below the chat input, you'll see "Add API key from Cloudflare Workers AI" 108 | 3. Paste your API key and click "Save" 109 | 4. paid models will now be unlocked in the dropdown 110 | 111 | ### Where Is the API Key Stored? 112 | - Your API key is stored **locally in your browser** using `localStorage` 113 | - Key name: `tomcp_api_key` 114 | - The key is sent with each chat request but **never stored on our servers** 115 | - You can remove it anytime by clicking "Remove" in the API key section 116 | 117 | ## How It Works (Technical) 118 | 119 | ### Model Fetching 120 | The available models are fetched dynamically from the Cloudflare Workers AI API: 121 | 1. Frontend calls `GET /models` endpoint on page load 122 | 2. Worker fetches models from `api.cloudflare.com/client/v4/accounts/{id}/ai/models/search` 123 | 3. Models are filtered to "Text Generation" tasks and cached for 5 minutes 124 | 4. Frontend displays free models as enabled, paid models as disabled (until API key is added) 125 | 126 | ### Chat Flow 127 | 1. User enters a URL and starts chatting 128 | 2. Worker fetches the static HTML and converts it to clean Markdown (JavaScript is not executed, so SPAs or dynamically-loaded content won't be captured) 129 | 3. Content is sent to the selected AI model with the user's message 130 | 4. Response is returned to the user 131 | 132 | ### Rate Limiting 133 | Without an API key: 134 | - 5 requests per IP per day 135 | 136 | With your API key: 137 | - No rate limits (uses your Cloudflare account quota) 138 | 139 | ## Tech Stack 140 | 141 | - **Frontend**: Vanilla HTML/CSS/JS with Tailwind CSS 142 | - **Backend**: Cloudflare Workers 143 | - **AI**: Cloudflare Workers AI (multiple models) 144 | 145 | ## Features 146 | 147 | - Works with any public URL 148 | - No setup required - just paste the config 149 | - Free forever - powered by Cloudflare Workers 150 | - Chat with any website using AI 151 | - Side-by-side MCP Config + Chat interface 152 | - **Multiple AI models** - Choose from Llama, Mistral, Gemma, and more 153 | - **Bring your own API key** - Unlock paid models and bypass rate limits 154 | 155 | ## License 156 | 157 | Apache 2.0 158 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to the Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright 2024 toMCP 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | -------------------------------------------------------------------------------- /worker/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * toMCP Worker 3 | * Converts any website to an MCP server + Chat with any website 4 | * 5 | * Usage: https://tomcp.org/docs.stripe.com 6 | * Chat: POST https://tomcp.org/chat 7 | */ 8 | 9 | export interface Env { 10 | AI: Ai; // Cloudflare Workers AI binding 11 | CF_API_TOKEN?: string; // Optional: for fetching models list 12 | CF_ACCOUNT_ID?: string; // Account ID for API calls 13 | } 14 | 15 | // ========== RATE LIMITING ========== 16 | // Protects free tier: 10,000 neurons/day ≈ 200 chat requests 17 | const RATE_LIMIT = { 18 | maxPerIP: 5, // Max requests per IP per day 19 | maxGlobal: 200, // Max total requests per day (stay within free tier) 20 | windowMs: 24 * 60 * 60 * 1000, // 24 hours 21 | }; 22 | 23 | // Per-IP tracking 24 | const rateLimitMap = new Map(); 25 | 26 | // Global daily counter 27 | let globalCounter = { count: 0, resetTime: Date.now() + RATE_LIMIT.windowMs }; 28 | 29 | function isRateLimited(ip: string): { limited: boolean; remaining: number; resetIn: number; reason?: string } { 30 | const now = Date.now(); 31 | 32 | // Reset global counter if window expired 33 | if (globalCounter.resetTime < now) { 34 | globalCounter = { count: 0, resetTime: now + RATE_LIMIT.windowMs }; 35 | } 36 | 37 | // Check global limit first (to stay within free tier) 38 | if (globalCounter.count >= RATE_LIMIT.maxGlobal) { 39 | return { 40 | limited: true, 41 | remaining: 0, 42 | resetIn: globalCounter.resetTime - now, 43 | reason: 'Daily limit reached. Try again tomorrow!' 44 | }; 45 | } 46 | 47 | // Clean up old IP entries periodically 48 | if (Math.random() < 0.01) { 49 | for (const [key, val] of rateLimitMap.entries()) { 50 | if (val.resetTime < now) rateLimitMap.delete(key); 51 | } 52 | } 53 | 54 | const record = rateLimitMap.get(ip); 55 | 56 | if (!record || record.resetTime < now) { 57 | // New window for this IP 58 | rateLimitMap.set(ip, { count: 1, resetTime: now + RATE_LIMIT.windowMs }); 59 | globalCounter.count++; 60 | return { limited: false, remaining: RATE_LIMIT.maxPerIP - 1, resetIn: RATE_LIMIT.windowMs }; 61 | } 62 | 63 | if (record.count >= RATE_LIMIT.maxPerIP) { 64 | return { limited: true, remaining: 0, resetIn: record.resetTime - now }; 65 | } 66 | 67 | record.count++; 68 | globalCounter.count++; 69 | return { limited: false, remaining: RATE_LIMIT.maxPerIP - record.count, resetIn: record.resetTime - now }; 70 | } 71 | 72 | function getClientIP(request: Request): string { 73 | return request.headers.get('CF-Connecting-IP') || 74 | request.headers.get('X-Forwarded-For')?.split(',')[0].trim() || 75 | 'unknown'; 76 | } 77 | 78 | // Simple HTML to Markdown converter 79 | function htmlToMarkdown(html: string): string { 80 | return html 81 | // Remove scripts and styles 82 | .replace(//gi, '') 83 | .replace(//gi, '') 84 | // Convert headers 85 | .replace(/]*>([\s\S]*?)<\/h1>/gi, '# $1\n\n') 86 | .replace(/]*>([\s\S]*?)<\/h2>/gi, '## $1\n\n') 87 | .replace(/]*>([\s\S]*?)<\/h3>/gi, '### $1\n\n') 88 | .replace(/]*>([\s\S]*?)<\/h4>/gi, '#### $1\n\n') 89 | // Convert paragraphs 90 | .replace(/]*>([\s\S]*?)<\/p>/gi, '$1\n\n') 91 | // Convert links 92 | .replace(/]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)') 93 | // Convert bold/strong 94 | .replace(/<(strong|b)[^>]*>([\s\S]*?)<\/(strong|b)>/gi, '**$2**') 95 | // Convert italic/em 96 | .replace(/<(em|i)[^>]*>([\s\S]*?)<\/(em|i)>/gi, '*$2*') 97 | // Convert code blocks 98 | .replace(/]*>]*>([\s\S]*?)<\/code><\/pre>/gi, '```\n$1\n```\n\n') 99 | .replace(/]*>([\s\S]*?)<\/code>/gi, '`$1`') 100 | // Convert lists 101 | .replace(/]*>([\s\S]*?)<\/li>/gi, '- $1\n') 102 | .replace(/<\/?[uo]l[^>]*>/gi, '\n') 103 | // Remove remaining HTML tags 104 | .replace(/<[^>]+>/g, '') 105 | // Decode HTML entities 106 | .replace(/ /g, ' ') 107 | .replace(/&/g, '&') 108 | .replace(/</g, '<') 109 | .replace(/>/g, '>') 110 | .replace(/"/g, '"') 111 | .replace(/'/g, "'") 112 | // Clean up whitespace 113 | .replace(/\n{3,}/g, '\n\n') 114 | .trim(); 115 | } 116 | 117 | // Fetch website content 118 | async function fetchWebsiteContent(url: string): Promise { 119 | try { 120 | const response = await fetch(url, { 121 | headers: { 122 | 'User-Agent': 'toMCP/1.0 (https://tomcp.org)', 123 | }, 124 | }); 125 | if (!response.ok) { 126 | return `Error: Could not fetch ${url} (${response.status})`; 127 | } 128 | const html = await response.text(); 129 | return htmlToMarkdown(html).slice(0, 30000); // Limit context size 130 | } catch (error) { 131 | return `Error fetching ${url}: ${error instanceof Error ? error.message : 'Unknown error'}`; 132 | } 133 | } 134 | 135 | // Cache for dynamic models (5 min TTL) 136 | let modelsCache: { models: any[]; timestamp: number } | null = null; 137 | const MODELS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes 138 | 139 | // Fetch models dynamically from Cloudflare API 140 | async function fetchCloudflareModels(accountId: string, apiToken?: string): Promise { 141 | // Return cached if fresh 142 | if (modelsCache && Date.now() - modelsCache.timestamp < MODELS_CACHE_TTL) { 143 | return modelsCache.models; 144 | } 145 | 146 | try { 147 | const headers: Record = { 148 | 'Content-Type': 'application/json', 149 | }; 150 | if (apiToken) { 151 | headers['Authorization'] = `Bearer ${apiToken}`; 152 | } 153 | 154 | const response = await fetch( 155 | `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/models/search?task=Text%20Generation`, 156 | { headers } 157 | ); 158 | 159 | if (!response.ok) { 160 | throw new Error(`API error: ${response.status}`); 161 | } 162 | 163 | const data = await response.json() as { result: any[] }; 164 | 165 | // Filter and format models for chat 166 | const models = (data.result || []) 167 | .filter((m: any) => m.task?.name === 'Text Generation' && !m.name.includes('embedding')) 168 | .map((m: any) => ({ 169 | id: m.name, // e.g. "@cf/meta/llama-3.1-8b-instruct" 170 | name: m.description || m.name.split('/').pop()?.replace(/-/g, ' ') || m.name, 171 | provider: m.name.split('/')[1] || 'Unknown', 172 | free: m.properties?.some((p: any) => p.property_id === 'beta') || false, 173 | })) 174 | .sort((a: any, b: any) => { 175 | // Free models first, then alphabetically 176 | if (a.free !== b.free) return a.free ? -1 : 1; 177 | return a.name.localeCompare(b.name); 178 | }); 179 | 180 | modelsCache = { models, timestamp: Date.now() }; 181 | return models; 182 | } catch (error) { 183 | console.error('Failed to fetch models:', error); 184 | // Fallback: free models first, then paid 185 | return [ 186 | // Free models (Beta) - Llama 3.1 8B is default 187 | { id: '@cf/meta/llama-3.1-8b-instruct', name: 'Llama 3.1 8B', provider: 'Meta', free: true }, 188 | { id: '@hf/nousresearch/hermes-2-pro-mistral-7b', name: 'Hermes 2 Pro', provider: 'NousResearch', free: true }, 189 | { id: '@cf/mistral/mistral-7b-instruct-v0.1', name: 'Mistral 7B', provider: 'Mistral', free: true }, 190 | { id: '@cf/google/gemma-7b-it-lora', name: 'Gemma 7B LoRA', provider: 'Google', free: true }, 191 | // Paid models (GA - require API key) 192 | { id: '@cf/meta/llama-3.3-70b-instruct-fp8-fast', name: 'Llama 3.3 70B', provider: 'Meta', free: false }, 193 | { id: '@cf/deepseek-ai/deepseek-r1-distill-qwen-32b', name: 'DeepSeek R1 32B', provider: 'DeepSeek', free: false }, 194 | { id: '@cf/mistral/mistral-large-2407', name: 'Mistral Large', provider: 'Mistral', free: false }, 195 | { id: '@cf/google/gemma-3-12b-it', name: 'Gemma 3 12B', provider: 'Google', free: false }, 196 | { id: '@cf/openai/gpt-oss-120b', name: 'GPT OSS 120B', provider: 'OpenAI', free: false }, 197 | { id: '@cf/openai/gpt-oss-20b', name: 'GPT OSS 20B', provider: 'OpenAI', free: false }, 198 | ]; 199 | } 200 | } 201 | 202 | // Default model ID 203 | const DEFAULT_MODEL = '@cf/meta/llama-3.1-8b-instruct'; 204 | 205 | // Chat with Cloudflare Workers AI (free, no API key needed) 206 | // Includes retry logic for transient failures 207 | async function chatWithAI( 208 | ai: Ai, 209 | websiteUrl: string, 210 | websiteContent: string, 211 | userMessage: string, 212 | chatHistory: Array<{ role: string; content: string }>, 213 | modelId: string = DEFAULT_MODEL 214 | ): Promise { 215 | const systemPrompt = `You are a helpful assistant that answers questions about the website ${websiteUrl}. 216 | You have access to the website's content below. Answer questions based on this content. 217 | If the answer isn't in the content, say so honestly. 218 | 219 | Website Content: 220 | ${websiteContent}`; 221 | 222 | const messages = [ 223 | { role: 'system', content: systemPrompt }, 224 | ...chatHistory.slice(-6), // Keep last 6 messages for context (smaller context for free tier) 225 | { role: 'user', content: userMessage }, 226 | ]; 227 | 228 | // Use provided model ID or default 229 | const model = modelId.startsWith('@') ? modelId : DEFAULT_MODEL; 230 | 231 | // Retry logic for transient AI failures 232 | const MAX_RETRIES = 3; 233 | let lastError: Error | null = null; 234 | 235 | for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { 236 | try { 237 | const response = await ai.run(model as Parameters[0], { 238 | messages, 239 | max_tokens: 1024, 240 | }); 241 | 242 | return (response as { response: string }).response || 'No response generated'; 243 | } catch (error) { 244 | lastError = error instanceof Error ? error : new Error(String(error)); 245 | 246 | // Don't retry on the last attempt 247 | if (attempt < MAX_RETRIES) { 248 | // Wait before retrying (exponential backoff: 500ms, 1000ms) 249 | await new Promise(resolve => setTimeout(resolve, attempt * 500)); 250 | } 251 | } 252 | } 253 | 254 | throw lastError || new Error('AI request failed after retries'); 255 | } 256 | 257 | // Chat using user's own Cloudflare API key (REST API) 258 | // This uses the user's own quota, not the shared free tier 259 | async function chatWithUserApiKey( 260 | apiKey: string, 261 | accountId: string, 262 | websiteUrl: string, 263 | websiteContent: string, 264 | userMessage: string, 265 | chatHistory: Array<{ role: string; content: string }>, 266 | modelId: string = DEFAULT_MODEL 267 | ): Promise { 268 | const systemPrompt = `You are a helpful assistant that answers questions about the website ${websiteUrl}. 269 | You have access to the website's content below. Answer questions based on this content. 270 | If the answer isn't in the content, say so honestly. 271 | 272 | Website Content: 273 | ${websiteContent}`; 274 | 275 | const messages = [ 276 | { role: 'system', content: systemPrompt }, 277 | ...chatHistory.slice(-6), 278 | { role: 'user', content: userMessage }, 279 | ]; 280 | 281 | const model = modelId.startsWith('@') ? modelId : DEFAULT_MODEL; 282 | 283 | const response = await fetch( 284 | `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${model}`, 285 | { 286 | method: 'POST', 287 | headers: { 288 | 'Authorization': `Bearer ${apiKey}`, 289 | 'Content-Type': 'application/json', 290 | }, 291 | body: JSON.stringify({ 292 | messages, 293 | max_tokens: 1024, 294 | }), 295 | } 296 | ); 297 | 298 | if (!response.ok) { 299 | const errorData = await response.json().catch(() => ({})) as { errors?: Array<{ message: string }> }; 300 | const errorMsg = errorData.errors?.[0]?.message || `API error: ${response.status}`; 301 | 302 | if (response.status === 401 || response.status === 403) { 303 | throw new Error('Invalid API key. Please check your Cloudflare API token.'); 304 | } 305 | if (response.status === 429) { 306 | throw new Error('API rate limit exceeded on your account.'); 307 | } 308 | throw new Error(`Cloudflare API error: ${errorMsg}`); 309 | } 310 | 311 | const data = await response.json() as { result?: { response?: string }; success?: boolean; errors?: Array<{ message: string }> }; 312 | 313 | if (!data.success) { 314 | throw new Error(data.errors?.[0]?.message || 'AI request failed'); 315 | } 316 | 317 | return data.result?.response || 'No response generated'; 318 | } 319 | 320 | // Validate API key and get user's account ID 321 | async function validateApiKey(apiKey: string): Promise<{ valid: boolean; error?: string; accountId?: string }> { 322 | try { 323 | // Step 1: Verify the token is valid 324 | const verifyResponse = await fetch('https://api.cloudflare.com/client/v4/user/tokens/verify', { 325 | headers: { 326 | 'Authorization': `Bearer ${apiKey}`, 327 | 'Content-Type': 'application/json', 328 | }, 329 | }); 330 | 331 | if (verifyResponse.status === 401 || verifyResponse.status === 403) { 332 | return { valid: false, error: 'Invalid API key. Please check your Cloudflare API token.' }; 333 | } 334 | 335 | const verifyData = await verifyResponse.json() as { success?: boolean }; 336 | if (!verifyData.success) { 337 | return { valid: false, error: 'Invalid API key. Token verification failed.' }; 338 | } 339 | 340 | // Step 2: Get the user's accounts 341 | const accountsResponse = await fetch('https://api.cloudflare.com/client/v4/accounts?per_page=1', { 342 | headers: { 343 | 'Authorization': `Bearer ${apiKey}`, 344 | 'Content-Type': 'application/json', 345 | }, 346 | }); 347 | 348 | if (!accountsResponse.ok) { 349 | return { valid: false, error: 'Could not retrieve account info. Make sure your API token has Account permissions.' }; 350 | } 351 | 352 | const accountsData = await accountsResponse.json() as { 353 | success?: boolean; 354 | result?: Array<{ id: string; name: string }>; 355 | }; 356 | 357 | if (!accountsData.success || !accountsData.result?.length) { 358 | return { valid: false, error: 'No accounts found. Make sure your API token has Account read permissions.' }; 359 | } 360 | 361 | const userAccountId = accountsData.result[0].id; 362 | 363 | // Step 3: Verify AI access on this account 364 | const aiResponse = await fetch( 365 | `https://api.cloudflare.com/client/v4/accounts/${userAccountId}/ai/models/search?per_page=1`, 366 | { 367 | headers: { 368 | 'Authorization': `Bearer ${apiKey}`, 369 | 'Content-Type': 'application/json', 370 | }, 371 | } 372 | ); 373 | 374 | if (!aiResponse.ok) { 375 | return { valid: false, error: 'API key valid but no Workers AI access. Make sure your token has Workers AI permissions.' }; 376 | } 377 | 378 | return { valid: true, accountId: userAccountId }; 379 | } catch (error) { 380 | return { valid: false, error: error instanceof Error ? error.message : 'Validation failed' }; 381 | } 382 | } 383 | 384 | // MCP Protocol handlers 385 | function createMcpResponse(id: number | string, result: unknown) { 386 | return { 387 | jsonrpc: '2.0', 388 | id, 389 | result, 390 | }; 391 | } 392 | 393 | function createMcpError(id: number | string | null, code: number, message: string) { 394 | return { 395 | jsonrpc: '2.0', 396 | id, 397 | error: { code, message }, 398 | }; 399 | } 400 | 401 | export default { 402 | async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { 403 | const url = new URL(request.url); 404 | 405 | // Redirect www to non-www 406 | if (url.hostname === 'www.tomcp.org') { 407 | url.hostname = 'tomcp.org'; 408 | return Response.redirect(url.toString(), 301); 409 | } 410 | 411 | const path = url.pathname.slice(1); // Remove leading slash 412 | 413 | // CORS headers 414 | const corsHeaders = { 415 | 'Access-Control-Allow-Origin': '*', 416 | 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 417 | 'Access-Control-Allow-Headers': 'Content-Type', 418 | }; 419 | 420 | // Handle CORS preflight 421 | if (request.method === 'OPTIONS') { 422 | return new Response(null, { headers: corsHeaders }); 423 | } 424 | 425 | // ========== MODELS API ========== 426 | if (path === 'models') { 427 | const accountId = env.CF_ACCOUNT_ID || 'ec62a93ac5823c4621864bda8abb2be4'; 428 | const models = await fetchCloudflareModels(accountId, env.CF_API_TOKEN); 429 | // Return all models (frontend will disable paid ones) 430 | return Response.json(models, { headers: corsHeaders }); 431 | } 432 | 433 | // ========== VALIDATE API KEY ========== 434 | if (path === 'validate-api-key' && request.method === 'POST') { 435 | try { 436 | const body = await request.json() as { apiKey?: string }; 437 | const { apiKey } = body; 438 | 439 | if (!apiKey || apiKey.length < 10) { 440 | return Response.json( 441 | { valid: false, error: 'API key is required and must be at least 10 characters.' }, 442 | { status: 400, headers: corsHeaders } 443 | ); 444 | } 445 | 446 | const result = await validateApiKey(apiKey); 447 | 448 | return Response.json(result, { headers: corsHeaders }); 449 | } catch (error) { 450 | return Response.json( 451 | { valid: false, error: error instanceof Error ? error.message : 'Validation failed' }, 452 | { status: 500, headers: corsHeaders } 453 | ); 454 | } 455 | } 456 | 457 | // ========== CHAT API ========== 458 | if (path === 'chat' && request.method === 'POST') { 459 | try { 460 | const body = await request.json() as { 461 | url: string; 462 | message: string; 463 | history?: Array<{ role: string; content: string }>; 464 | apiKey?: string; // Optional: user's own API key (uses their quota) 465 | accountId?: string; // Optional: user's Cloudflare account ID (required with apiKey) 466 | model?: string; // Optional: model ID to use (e.g. "@cf/microsoft/phi-2") 467 | }; 468 | 469 | const { apiKey, accountId: userAccountId, model = DEFAULT_MODEL } = body; 470 | const hasValidApiKey = !!apiKey && apiKey.length > 20 && !!userAccountId; // Need both key and account ID 471 | 472 | // Only check rate limit if no API key provided 473 | if (!hasValidApiKey) { 474 | const clientIP = getClientIP(request); 475 | const rateLimit = isRateLimited(clientIP); 476 | 477 | if (rateLimit.limited) { 478 | return Response.json( 479 | { 480 | error: 'Rate limit exceeded. Add a Cloudflare API key to use your own quota and bypass limits.', 481 | errorType: 'RATE_LIMIT', 482 | retryAfter: Math.ceil(rateLimit.resetIn / 1000) 483 | }, 484 | { 485 | status: 429, 486 | headers: { 487 | ...corsHeaders, 488 | 'Retry-After': String(Math.ceil(rateLimit.resetIn / 1000)), 489 | 'X-RateLimit-Remaining': '0', 490 | } 491 | } 492 | ); 493 | } 494 | } 495 | 496 | const { url: websiteUrl, message, history = [] } = body; 497 | 498 | if (!websiteUrl || !message) { 499 | return Response.json( 500 | { error: 'Missing required fields: url and message', errorType: 'VALIDATION' }, 501 | { status: 400, headers: corsHeaders } 502 | ); 503 | } 504 | 505 | // Fetch website content first 506 | const fullUrl = websiteUrl.startsWith('http') ? websiteUrl : `https://${websiteUrl}`; 507 | const content = await fetchWebsiteContent(fullUrl); 508 | 509 | // Check if website fetch failed 510 | if (content.startsWith('Error:') || content.startsWith('Error fetching')) { 511 | return Response.json( 512 | { error: `Could not fetch website: ${content}`, errorType: 'FETCH_ERROR' }, 513 | { status: 400, headers: corsHeaders } 514 | ); 515 | } 516 | 517 | let response: string; 518 | 519 | // Use user's API key if provided, otherwise use free tier 520 | if (hasValidApiKey) { 521 | try { 522 | response = await chatWithUserApiKey( 523 | apiKey, 524 | userAccountId!, // User's account ID (validated above) 525 | fullUrl, 526 | content, 527 | message, 528 | history, 529 | model 530 | ); 531 | } catch (error) { 532 | const errorMsg = error instanceof Error ? error.message : 'API request failed'; 533 | return Response.json( 534 | { error: errorMsg, errorType: 'API_KEY_ERROR' }, 535 | { status: 401, headers: corsHeaders } 536 | ); 537 | } 538 | } else { 539 | // Check for AI binding (free tier) 540 | if (!env.AI) { 541 | return Response.json( 542 | { error: 'Chat is not configured. AI binding missing.', errorType: 'CONFIG_ERROR' }, 543 | { status: 500, headers: corsHeaders } 544 | ); 545 | } 546 | 547 | try { 548 | response = await chatWithAI( 549 | env.AI, 550 | fullUrl, 551 | content, 552 | message, 553 | history, 554 | model 555 | ); 556 | } catch (error) { 557 | const errorMsg = error instanceof Error ? error.message : 'AI request failed'; 558 | return Response.json( 559 | { error: `AI service error: ${errorMsg}`, errorType: 'AI_ERROR' }, 560 | { status: 500, headers: corsHeaders } 561 | ); 562 | } 563 | } 564 | 565 | return Response.json( 566 | { response, url: fullUrl, usedApiKey: hasValidApiKey }, 567 | { headers: corsHeaders } 568 | ); 569 | } catch (error) { 570 | return Response.json( 571 | { error: error instanceof Error ? error.message : 'Chat failed', errorType: 'UNKNOWN' }, 572 | { status: 500, headers: corsHeaders } 573 | ); 574 | } 575 | } 576 | 577 | // Serve static assets from GitHub 578 | if (path === 'logo.svg' || path === 'logo.png' || path === 'logowhite.svg' || path === 'robots.txt' || path === 'sitemap.xml') { 579 | const timestamp = Date.now(); 580 | const assetUrl = `https://raw.githubusercontent.com/Ami3466/tomcp/main/${path}?t=${timestamp}`; 581 | const response = await fetch(assetUrl, { cf: { cacheTtl: 0 } }); 582 | const contentType = path.endsWith('.svg') ? 'image/svg+xml' 583 | : path.endsWith('.xml') ? 'application/xml' 584 | : path.endsWith('.txt') ? 'text/plain' 585 | : 'image/png'; 586 | return new Response(response.body, { 587 | headers: { ...corsHeaders, 'Content-Type': contentType, 'Cache-Control': 'no-cache, no-store, must-revalidate' }, 588 | }); 589 | } 590 | 591 | 592 | // Root path - serve website HTML from GitHub 593 | if (!path) { 594 | try { 595 | const timestamp = Date.now(); 596 | const htmlUrl = `https://raw.githubusercontent.com/Ami3466/tomcp/main/index.html?v=${timestamp}`; 597 | const response = await fetch(htmlUrl, { 598 | cf: { cacheTtl: 0, cacheEverything: false }, 599 | headers: { 'Cache-Control': 'no-cache' } 600 | }); 601 | if (!response.ok) { 602 | throw new Error(`Failed to fetch HTML: ${response.status}`); 603 | } 604 | const html = await response.text(); 605 | return new Response(html, { 606 | headers: { ...corsHeaders, 'Content-Type': 'text/html', 'Cache-Control': 'no-cache, no-store, must-revalidate' }, 607 | }); 608 | } catch (error) { 609 | return new Response('Error loading page', { status: 500, headers: corsHeaders }); 610 | } 611 | } 612 | 613 | // Parse target URL from path 614 | const targetUrl = path.startsWith('http') ? path : `https://${path}`; 615 | 616 | // Handle MCP protocol (POST with JSON-RPC) 617 | if (request.method === 'POST') { 618 | try { 619 | const body = await request.json() as { 620 | jsonrpc: string; 621 | id: number | string; 622 | method: string; 623 | params?: Record; 624 | }; 625 | 626 | const { id, method, params } = body; 627 | 628 | // Handle MCP methods 629 | switch (method) { 630 | case 'initialize': 631 | return Response.json(createMcpResponse(id, { 632 | protocolVersion: '2024-11-05', 633 | capabilities: { 634 | tools: {}, 635 | }, 636 | serverInfo: { 637 | name: `toMCP - ${new URL(targetUrl).hostname}`, 638 | version: '1.0.0', 639 | }, 640 | }), { headers: corsHeaders }); 641 | 642 | case 'notifications/initialized': 643 | return Response.json(createMcpResponse(id, {}), { headers: corsHeaders }); 644 | 645 | case 'tools/list': 646 | return Response.json(createMcpResponse(id, { 647 | tools: [ 648 | { 649 | name: 'fetch_page', 650 | description: `Fetch a page from ${new URL(targetUrl).hostname}. Returns content as markdown.`, 651 | inputSchema: { 652 | type: 'object', 653 | properties: { 654 | path: { 655 | type: 'string', 656 | description: 'Path to fetch (e.g., "/docs/api" or leave empty for homepage)', 657 | default: '', 658 | }, 659 | }, 660 | }, 661 | }, 662 | { 663 | name: 'search', 664 | description: `Search for content on ${new URL(targetUrl).hostname}`, 665 | inputSchema: { 666 | type: 'object', 667 | properties: { 668 | query: { 669 | type: 'string', 670 | description: 'Search query', 671 | }, 672 | }, 673 | required: ['query'], 674 | }, 675 | }, 676 | ], 677 | }), { headers: corsHeaders }); 678 | 679 | case 'tools/call': { 680 | const toolName = (params as { name: string })?.name; 681 | const toolArgs = (params as { arguments?: Record })?.arguments || {}; 682 | 683 | if (toolName === 'fetch_page') { 684 | const pagePath = toolArgs.path || ''; 685 | const fullUrl = pagePath 686 | ? `${targetUrl}${pagePath.startsWith('/') ? '' : '/'}${pagePath}` 687 | : targetUrl; 688 | 689 | try { 690 | const response = await fetch(fullUrl, { 691 | headers: { 692 | 'User-Agent': 'toMCP/1.0 (https://tomcp.org)', 693 | }, 694 | }); 695 | 696 | if (!response.ok) { 697 | return Response.json(createMcpResponse(id, { 698 | content: [{ 699 | type: 'text', 700 | text: `Error: Failed to fetch ${fullUrl} (${response.status})`, 701 | }], 702 | }), { headers: corsHeaders }); 703 | } 704 | 705 | const html = await response.text(); 706 | const markdown = htmlToMarkdown(html); 707 | 708 | return Response.json(createMcpResponse(id, { 709 | content: [{ 710 | type: 'text', 711 | text: markdown.slice(0, 50000), // Limit response size 712 | }], 713 | }), { headers: corsHeaders }); 714 | } catch (error) { 715 | return Response.json(createMcpResponse(id, { 716 | content: [{ 717 | type: 'text', 718 | text: `Error fetching page: ${error instanceof Error ? error.message : 'Unknown error'}`, 719 | }], 720 | }), { headers: corsHeaders }); 721 | } 722 | } 723 | 724 | if (toolName === 'search') { 725 | const query = toolArgs.query; 726 | // Try common search patterns 727 | const searchUrl = `${targetUrl}/search?q=${encodeURIComponent(query)}`; 728 | 729 | return Response.json(createMcpResponse(id, { 730 | content: [{ 731 | type: 'text', 732 | text: `Search not directly supported. Try fetching: ${searchUrl}\n\nOr use fetch_page with a specific path.`, 733 | }], 734 | }), { headers: corsHeaders }); 735 | } 736 | 737 | return Response.json(createMcpError(id, -32601, `Unknown tool: ${toolName}`), { 738 | headers: corsHeaders 739 | }); 740 | } 741 | 742 | default: 743 | return Response.json(createMcpError(id, -32601, `Method not found: ${method}`), { 744 | headers: corsHeaders 745 | }); 746 | } 747 | } catch (error) { 748 | return Response.json(createMcpError(null, -32700, 'Parse error'), { 749 | headers: corsHeaders 750 | }); 751 | } 752 | } 753 | 754 | // GET request - redirect to homepage with URL pre-filled 755 | // The homepage JS will handle showing the config 756 | return Response.redirect(`https://tomcp.org/?url=${encodeURIComponent(path)}`, 302); 757 | }, 758 | }; 759 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /logowhite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | toMCP - Turn Any Website into an MCP Server 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 52 | 53 | 54 | 72 | 160 | 161 | 162 | 163 |
164 |
165 |
166 |
167 | 168 |
169 | 170 |
171 | 172 |
173 | 174 |

toMCP

175 |
176 | 177 | 178 |

Convert any website or documentation into an MCP server for your AI tools

179 | 180 | 181 |
182 |

add tomcp.org/ before any url and subdomain

183 |
184 | tomcp.org/your-docs.com 185 | tomcp.org/https://example.com/docs 186 |
187 |
188 | 189 | 190 |
191 |
192 | or 193 |
194 |
195 | 196 | 197 |
198 |

Paste your URL here:

199 |
200 | 206 |
207 | 213 |
214 |
215 |
216 |
217 | 218 |
219 | Star 220 |
221 | 222 | 223 |
224 | 225 |
226 |
227 | 228 | 229 | 230 |

MCP Config

231 |
232 | 233 | 234 |
235 | 236 |
237 | 238 |
239 |
240 | 241 | 242 | 292 | 293 | 294 |
295 | 296 | 297 | 298 | 299 |

Paste a URL above to generate config

300 |
301 |
302 | 303 | 304 |
305 |
306 | 307 | 308 | 309 |

Chat with Website

310 | 311 | 316 |
317 | 318 | 319 |
320 | 321 |
322 | 323 | 324 | 325 |

Paste a URL above to start chatting
Ask questions about any website's content

326 | 333 |
334 |
335 | 336 | 337 |
338 |
339 | 346 | 355 |
356 | 357 | 358 |
359 | 362 |
363 | 364 | 365 | 429 |
430 |
431 |
432 | 433 | 436 | 437 | 438 | 479 | 480 | 481 |
482 | 483 |
484 |

web_fetch vs MCP Resources

485 |
486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 |
web_fetchMCP Resource
Data QualityRaw HTML with noiseClean markdown
Token UsageHighLow
PersistencePer-request onlyAlways available
Hallucination RiskHigherLower
JavaScript SupportFull (SPAs / Dynamic)Static only (SSG)
522 |
523 |
524 |
525 | 526 | 527 | 534 |
535 | 536 | 1222 | 1223 | 1224 | 1225 | 1226 | --------------------------------------------------------------------------------