@fnnm/fetch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md ADDED
@@ -0,0 +1,110 @@
1
+ # CLAUDE.md
2
+
3
+ Guidance for Claude Code working in this repo.
4
+
5
+ ## Project Overview
6
+
7
+ `@fnnm/fetch` is a Model Context Protocol (MCP) server that fetches URLs and converts HTML/PDF to Markdown for LLMs. TypeScript/Node port of the original Python `fetch`. Communicates over stdio (JSON-RPC).
8
+
9
+ **Security:** the server can reach local/internal IP addresses. Deploy with caution.
10
+
11
+ ## Commands
12
+
13
+ ```bash
14
+ npm run build # tsc → dist/
15
+ npm start # node dist/index.js
16
+ npm run dev # ts-node src/index.ts
17
+ npm test # vitest
18
+ docker build -t mcp/fetch .
19
+ ```
20
+
21
+ No CLI flags. Configuration is environment-only:
22
+ - `HTTPS_PROXY` / `HTTP_PROXY` (plus lowercase variants) — outbound proxy, wired through undici `ProxyAgent`.
23
+
24
+ ## Architecture
25
+
26
+ ```
27
+ src/
28
+ ├── index.ts # entry → runServer()
29
+ ├── server.ts # createServer(): McpServer "fetch" v0.1.0; registers tool + prompt
30
+ ├── tools/
31
+ │ ├── fetch.ts # FetchSchema (zod), handleFetch()
32
+ │ └── fetch.test.ts
33
+ ├── utils/
34
+ │ ├── convert.ts # HTML→MD (jsdom+readability+turndown), PDF→text (pdf-parse)
35
+ │ ├── convert.test.ts
36
+ │ ├── robots.ts # robots.txt check, dual user-agents
37
+ │ └── robots.test.ts
38
+ └── types.d.ts
39
+ ```
40
+
41
+ ### Entry flow
42
+ 1. `index.ts` → `runServer()`
43
+ 2. `server.ts:runServer()` → `createServer()` builds `McpServer`, connects `StdioServerTransport`.
44
+
45
+ ### Dual-mode operation (tool vs prompt)
46
+
47
+ | Mode | Trigger | robots.txt | User-Agent | max_length |
48
+ |------|---------|------------|------------|------------|
49
+ | Autonomous | `fetch` tool | checked | `ModelContextProtocol/1.0 (Autonomous; ...)` | caller-supplied (default 5000) |
50
+ | Manual | `fetch` prompt | bypassed | `ModelContextProtocol/1.0 (User-Specified; ...)` | 1,000,000 |
51
+
52
+ Hardcoded in `server.ts`: the tool calls `handleFetch(args, false)`; the prompt calls `handleFetch({ url, max_length: 1_000_000, start_index: 0, raw: false }, true)`.
53
+
54
+ ### `handleFetch` (`tools/fetch.ts`)
55
+ - 30s `AbortController` timeout around native `fetch`.
56
+ - Proxy: reads `HTTPS_PROXY`/`HTTP_PROXY` env, builds undici `ProxyAgent`, attaches as `dispatcher`.
57
+ - Content routing:
58
+ - PDF (`application/pdf` header or `.pdf` URL) → `extractContentFromPdf`
59
+ - HTML (`text/html` or empty content-type) and `raw=false` → `extractContentFromHtml`
60
+ - Otherwise → raw text with `Content type ... cannot be simplified to markdown` prefix
61
+ - Pagination: `start_index` / `max_length` slice; if truncated, appends a hint carrying the next `start_index`.
62
+ - Errors raised as `McpError(ErrorCode.InternalError, ...)`.
63
+
64
+ ### Content conversion (`utils/convert.ts`)
65
+ - HTML: `JSDOM` → `@mozilla/readability` → `turndown` (atx headings, fenced code). Failure returns a sentinel `<error>...</error>` string rather than throwing.
66
+ - PDF: `pdf-parse`, loaded via `createRequire` (CommonJS interop).
67
+
68
+ ### robots.txt (`utils/robots.ts`)
69
+ - Fetches `/robots.txt` with the chosen UA.
70
+ - `401`/`403` → deny (McpError). Other `4xx` → allow (treat as no robots). Network error → McpError.
71
+ - `robotsParser.isAllowed(...) === false` → deny.
72
+
73
+ ## Dependencies
74
+
75
+ | Package | Use |
76
+ |---------|-----|
77
+ | `@modelcontextprotocol/sdk` | MCP server, stdio transport, types |
78
+ | `zod` | input schema + validation |
79
+ | `undici` | `ProxyAgent` for proxied fetch |
80
+ | `jsdom`, `@mozilla/readability`, `turndown` | HTML → Markdown |
81
+ | `pdf-parse` | PDF → text |
82
+ | `robots-parser` | robots.txt evaluation |
83
+ | `vitest` | tests |
84
+
85
+ ## Notable differences from the Python original
86
+
87
+ - No `--user-agent`, `--ignore-robots-txt`, `--proxy-url` CLI flags. Proxy is env-only; user-agent is fixed.
88
+ - No PDF TTLCache (PDF is re-parsed on every call).
89
+ - No `verify_ssl` toggle (native fetch; Node TLS settings apply).
90
+ - Registered MCP server name is `fetch`.
91
+
92
+ ## Running in an MCP client
93
+
94
+ Build first (`npm run build`), then point the client at the built entry:
95
+
96
+ ```json
97
+ {
98
+ "mcpServers": {
99
+ "fetch": { "command": "node", "args": ["/abs/path/to/dist/index.js"] }
100
+ }
101
+ }
102
+ ```
103
+
104
+ `package.json` declares no `bin` field, so `npx @fnnm/fetch` will not work until one is added and the package is published.
105
+
106
+ ## Extending
107
+
108
+ - **New content type:** add detection in `handleFetch` (`tools/fetch.ts`), add an extractor in `utils/convert.ts`, return `(text, "")` on success or fall through to the raw prefix path.
109
+ - **Robots behavior:** edit `checkMayAutonomouslyFetchUrl` (`utils/robots.ts`) or the `ignoreRobotsTxt` argument passed in `server.ts`.
110
+ - **New tool/prompt:** define a zod schema, register via `server.tool(...)` / `server.prompt(...)` in `createServer()`.
package/Dockerfile ADDED
@@ -0,0 +1,31 @@
1
+ # Generated by MCP Server Migration
2
+ FROM node:18-alpine AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ # Install dependencies
7
+ COPY package.json package-lock.json ./
8
+ RUN npm ci
9
+
10
+ # Copy source
11
+ COPY tsconfig.json ./
12
+ COPY src ./src
13
+
14
+ # Build
15
+ RUN npm run build
16
+
17
+ # Production image
18
+ FROM node:18-alpine AS release
19
+
20
+ WORKDIR /app
21
+
22
+ # Copy built assets and production deps
23
+ COPY --from=builder /app/dist ./dist
24
+ COPY --from=builder /app/package.json ./package.json
25
+ COPY --from=builder /app/package-lock.json ./package-lock.json
26
+
27
+ # Install only production dependencies
28
+ RUN npm ci --omit=dev
29
+
30
+ # Entry point
31
+ ENTRYPOINT ["node", "dist/index.js"]
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2024 Anthropic, PBC.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # Fetch MCP Server
2
+
3
+ A Model Context Protocol (MCP) server that fetches URLs and converts HTML/PDF to Markdown for LLMs. TypeScript/Node port of the original `mcp-server-fetch2`, communicating over stdio.
4
+
5
+ > [!CAUTION]
6
+ > This server can access local/internal IP addresses and may represent a security risk. Exercise caution to ensure it does not expose sensitive data.
7
+
8
+ The fetch tool truncates the response; use the `start_index` argument to page through content in chunks until the model finds what it needs.
9
+
10
+ ### Available Tools
11
+
12
+ - `fetch` — Fetches a URL and extracts its contents as markdown (HTML) or text (PDF).
13
+ - `url` (string, required): URL to fetch
14
+ - `max_length` (integer, optional): Maximum characters to return (default: 5000)
15
+ - `start_index` (integer, optional): Start content from this character index (default: 0)
16
+ - `raw` (boolean, optional): Return raw content without markdown conversion (default: false)
17
+
18
+ ### Prompts
19
+
20
+ - **fetch** — Fetch a URL and extract its contents as markdown.
21
+ - Arguments:
22
+ - `url` (string, required): URL to fetch
23
+
24
+ ## Installation
25
+
26
+ Requires Node.js 18+.
27
+
28
+ ```bash
29
+ git clone https://github.com/fxcl/fetch.git
30
+ cd fetch
31
+ npm install
32
+ npm run build
33
+ ```
34
+
35
+ The server runs as `node dist/index.js`. `package.json` declares a `bin` field, so once published `npx @fnnm/fetch` will also work.
36
+
37
+ ## Configuration
38
+
39
+ ### Configure for Claude.app / Claude Desktop
40
+
41
+ Add to your Claude settings:
42
+
43
+ <details>
44
+ <summary>Using node (built from source)</summary>
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "fetch": {
50
+ "command": "node",
51
+ "args": ["/abs/path/to/fetch/dist/index.js"]
52
+ }
53
+ }
54
+ }
55
+ ```
56
+ </details>
57
+
58
+ <details>
59
+ <summary>Using docker</summary>
60
+
61
+ ```json
62
+ {
63
+ "mcpServers": {
64
+ "fetch": {
65
+ "command": "docker",
66
+ "args": ["run", "-i", "--rm", "mcp/fetch"]
67
+ }
68
+ }
69
+ }
70
+ ```
71
+ </details>
72
+
73
+ ### Configure for VS Code
74
+
75
+ Add the following to `.vscode/mcp.json` in your workspace, or to your User Settings (JSON) via `Ctrl+Shift+P` → `Preferences: Open User Settings (JSON)`. The `mcp` key is required when using `mcp.json`.
76
+
77
+ <details>
78
+ <summary>Using node</summary>
79
+
80
+ ```json
81
+ {
82
+ "mcp": {
83
+ "servers": {
84
+ "fetch": {
85
+ "command": "node",
86
+ "args": ["/abs/path/to/fetch/dist/index.js"]
87
+ }
88
+ }
89
+ }
90
+ }
91
+ ```
92
+ </details>
93
+
94
+ <details>
95
+ <summary>Using Docker</summary>
96
+
97
+ ```json
98
+ {
99
+ "mcp": {
100
+ "servers": {
101
+ "fetch": {
102
+ "command": "docker",
103
+ "args": ["run", "-i", "--rm", "mcp/fetch"]
104
+ }
105
+ }
106
+ }
107
+ }
108
+ ```
109
+ </details>
110
+
111
+ ### Behavior — robots.txt
112
+
113
+ The server obeys a site's robots.txt when the request comes from the model (via the `fetch` tool), but bypasses it when the request is user-initiated (via the `fetch` prompt). This behavior is fixed in the implementation; there is no CLI flag to toggle it.
114
+
115
+ ### Behavior — User-agent
116
+
117
+ The server sends one of two fixed user-agents depending on whether the request is model-initiated (tool) or user-initiated (prompt):
118
+
119
+ ```
120
+ ModelContextProtocol/1.0 (Autonomous; +https://github.com/modelcontextprotocol/servers)
121
+ ```
122
+ ```
123
+ ModelContextProtocol/1.0 (User-Specified; +https://github.com/modelcontextprotocol/servers)
124
+ ```
125
+
126
+ The user-agent is not configurable.
127
+
128
+ ### Behavior — Proxy
129
+
130
+ The server honors the `HTTPS_PROXY` / `HTTP_PROXY` environment variables (lowercase variants too), wired through an undici `ProxyAgent`. To set a proxy in `mcpServers`, add an `env` block:
131
+
132
+ ```json
133
+ {
134
+ "mcpServers": {
135
+ "fetch": {
136
+ "command": "node",
137
+ "args": ["/abs/path/to/fetch/dist/index.js"],
138
+ "env": {
139
+ "HTTPS_PROXY": "http://user:pass@proxy.example.com:8080"
140
+ }
141
+ }
142
+ }
143
+ }
144
+ ```
145
+
146
+ ## Debugging
147
+
148
+ Use the MCP inspector:
149
+
150
+ ```bash
151
+ npx @modelcontextprotocol/inspector node dist/index.js
152
+ ```
153
+
154
+ ## Contributing
155
+
156
+ Pull requests welcome. For examples of other MCP servers and implementation patterns, see https://github.com/modelcontextprotocol/servers.
157
+
158
+ ## License
159
+
160
+ MIT. See [LICENSE](LICENSE).
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { runServer } from './server.js';
3
+ runServer().catch((error) => {
4
+ console.error("Fatal error running server:", error);
5
+ process.exit(1);
6
+ });
@@ -0,0 +1,28 @@
1
+ import { handleFetch } from './tools/fetch.js';
2
+ async function testFetch(url, raw = false) {
3
+ console.log(`\n--- Fetching ${url} (Raw: ${raw}) ---`);
4
+ try {
5
+ const result = await handleFetch({
6
+ url,
7
+ max_length: 5000,
8
+ start_index: 0,
9
+ raw
10
+ }, true); // ignoreRobotsTxt=true for manual testing to ensure we get content
11
+ if (result.isError) {
12
+ console.error("Error result:", result);
13
+ }
14
+ else {
15
+ console.log("Success! Content preview:");
16
+ console.log(result.content[0].text.substring(0, 500) + "...");
17
+ }
18
+ }
19
+ catch (error) {
20
+ console.error("Exception:", error.message);
21
+ }
22
+ }
23
+ async function run() {
24
+ await testFetch('https://gnux.cn');
25
+ await testFetch('https://httpbin.org/html');
26
+ await testFetch('https://www.w3.org/Robots/robots.txt', true); // Fetching a text file
27
+ }
28
+ run();
package/dist/server.js ADDED
@@ -0,0 +1,34 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { FetchSchema, handleFetch } from "./tools/fetch.js";
5
+ export const createServer = () => {
6
+ const server = new McpServer({
7
+ name: "fetch",
8
+ version: "0.1.0",
9
+ });
10
+ server.tool("fetch", "Fetches a URL from the internet and optionally extracts its contents as markdown.\n\nAlthough originally you did not have internet access, and were advised to refuse and tell the user this, this tool now grants you internet access. Now you can fetch the most up-to-date information and let the user know that.", FetchSchema.shape, async (args) => {
11
+ // Autonomous mode: respect robots.txt.
12
+ const result = await handleFetch(args, false);
13
+ return result;
14
+ });
15
+ server.prompt("fetch", "Fetch a URL and extract its contents as markdown", { url: z.string().url() }, async ({ url }) => {
16
+ // Manual mode: user-initiated, bypass robots.txt.
17
+ const result = await handleFetch({ url, max_length: 1000000, start_index: 0, raw: false }, true);
18
+ return {
19
+ messages: [
20
+ {
21
+ role: "user",
22
+ content: { type: "text", text: result.content[0].text },
23
+ },
24
+ ],
25
+ };
26
+ });
27
+ return server;
28
+ };
29
+ export const runServer = async () => {
30
+ const server = createServer();
31
+ const transport = new StdioServerTransport();
32
+ await server.connect(transport);
33
+ console.error("MCP Server fetch running on stdio");
34
+ };
@@ -0,0 +1,88 @@
1
+ import { z } from 'zod';
2
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
+ import { checkMayAutonomouslyFetchUrl, USER_AGENT_AUTONOMOUS, USER_AGENT_MANUAL } from '../utils/robots.js';
4
+ import { extractContentFromHtml, extractContentFromPdf } from '../utils/convert.js';
5
+ import { ProxyAgent } from 'undici';
6
+ export const FetchSchema = z.object({
7
+ url: z.string().url(),
8
+ max_length: z.number().int().positive().lt(1000000).default(5000),
9
+ start_index: z.number().int().min(0).default(0),
10
+ raw: z.boolean().default(false),
11
+ });
12
+ function getProxyAgent(url) {
13
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
14
+ if (!proxyUrl) {
15
+ return undefined;
16
+ }
17
+ // Basic check: if strictly http proxy for https url, standard fetch might complain?
18
+ // Undici ProxyAgent handles this generally well.
19
+ return new ProxyAgent(proxyUrl);
20
+ }
21
+ export async function handleFetch(args, ignoreRobotsTxt = false) {
22
+ const { url, max_length, start_index, raw } = args;
23
+ // Choose user agent
24
+ const userAgent = ignoreRobotsTxt ? USER_AGENT_MANUAL : USER_AGENT_AUTONOMOUS;
25
+ if (!ignoreRobotsTxt) {
26
+ await checkMayAutonomouslyFetchUrl(url, userAgent);
27
+ }
28
+ try {
29
+ const controller = new AbortController();
30
+ const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout
31
+ const dispatcher = getProxyAgent(url);
32
+ const fetchOptions = {
33
+ headers: { "User-Agent": userAgent },
34
+ signal: controller.signal,
35
+ };
36
+ if (dispatcher) {
37
+ fetchOptions.dispatcher = dispatcher;
38
+ }
39
+ const response = await fetch(url, fetchOptions);
40
+ clearTimeout(timeoutId);
41
+ if (!response.ok) {
42
+ throw new McpError(ErrorCode.InternalError, `Failed to fetch ${url} - status code ${response.status}`);
43
+ }
44
+ const contentType = response.headers.get("content-type") || "";
45
+ const isPdf = contentType.toLowerCase().includes("application/pdf") || url.toLowerCase().endsWith(".pdf");
46
+ let textContent = "";
47
+ let prefix = "";
48
+ if (isPdf) {
49
+ const arrayBuffer = await response.arrayBuffer();
50
+ textContent = await extractContentFromPdf(Buffer.from(arrayBuffer));
51
+ }
52
+ else if ((contentType.includes("text/html") || contentType === "") && !raw) {
53
+ const html = await response.text();
54
+ textContent = await extractContentFromHtml(html, url);
55
+ }
56
+ else {
57
+ textContent = await response.text();
58
+ prefix = `Content type ${contentType} cannot be simplified to markdown, but here is the raw content:\n`;
59
+ }
60
+ const originalLength = textContent.length;
61
+ let finalContent = "";
62
+ if (start_index >= originalLength) {
63
+ finalContent = "<error>No more content available.</error>";
64
+ }
65
+ else {
66
+ finalContent = textContent.slice(start_index, start_index + max_length);
67
+ if (finalContent.length === 0) {
68
+ finalContent = "<error>No more content available.</error>";
69
+ }
70
+ }
71
+ // Check if truncated
72
+ const actualLength = finalContent.length;
73
+ const remaining = originalLength - (start_index + actualLength);
74
+ if (actualLength === max_length && remaining > 0) {
75
+ const nextStart = start_index + actualLength;
76
+ finalContent += `\n\n<error>Content truncated. Call the fetch tool with a start_index of ${nextStart} to get more content.</error>`;
77
+ }
78
+ return {
79
+ content: [{ type: "text", text: `${prefix}Contents of ${url}:\n${finalContent}` }],
80
+ isError: false
81
+ };
82
+ }
83
+ catch (error) {
84
+ if (error instanceof McpError)
85
+ throw error;
86
+ throw new McpError(ErrorCode.InternalError, `Failed to fetch ${url}: ${error.message}`);
87
+ }
88
+ }
@@ -0,0 +1,33 @@
1
+ import { JSDOM } from 'jsdom';
2
+ import { Readability } from '@mozilla/readability';
3
+ import TurndownService from 'turndown';
4
+ import { createRequire } from 'module';
5
+ const require = createRequire(import.meta.url);
6
+ const pdfParse = require('pdf-parse');
7
+ export async function extractContentFromHtml(html, url) {
8
+ try {
9
+ const dom = new JSDOM(html, { url });
10
+ const reader = new Readability(dom.window.document);
11
+ const article = reader.parse();
12
+ if (!article || !article.content) {
13
+ return "<error>Page failed to be simplified from HTML</error>";
14
+ }
15
+ const turndownService = new TurndownService({
16
+ headingStyle: 'atx',
17
+ codeBlockStyle: 'fenced'
18
+ });
19
+ return turndownService.turndown(article.content);
20
+ }
21
+ catch (error) {
22
+ return "<error>Page failed to be simplified from HTML</error>";
23
+ }
24
+ }
25
+ export async function extractContentFromPdf(buffer) {
26
+ try {
27
+ const data = await pdfParse(buffer);
28
+ return data.text;
29
+ }
30
+ catch (error) {
31
+ return "<error>Failed to extract content from PDF</error>";
32
+ }
33
+ }
@@ -0,0 +1,36 @@
1
+ import robotsParser from 'robots-parser';
2
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
+ export const USER_AGENT_AUTONOMOUS = "ModelContextProtocol/1.0 (Autonomous; +https://github.com/modelcontextprotocol/servers)";
4
+ export const USER_AGENT_MANUAL = "ModelContextProtocol/1.0 (User-Specified; +https://github.com/modelcontextprotocol/servers)";
5
+ export async function getRobotsTxtUrl(url) {
6
+ const parsed = new URL(url);
7
+ return `${parsed.protocol}//${parsed.host}/robots.txt`;
8
+ }
9
+ export async function checkMayAutonomouslyFetchUrl(url, userAgent) {
10
+ const robotsUrl = await getRobotsTxtUrl(url);
11
+ try {
12
+ const response = await fetch(robotsUrl, {
13
+ headers: { "User-Agent": userAgent }
14
+ });
15
+ if (response.status === 401 || response.status === 403) {
16
+ throw new McpError(ErrorCode.InternalError, `When fetching robots.txt (${robotsUrl}), received status ${response.status} so assuming that autonomous fetching is not allowed.`);
17
+ }
18
+ // 4xx (except 401/403): treat as no robots.txt -> allowed.
19
+ if (response.status >= 400 && response.status < 500) {
20
+ return; // Assume allowed if robots.txt is missing/bad request
21
+ }
22
+ const robotsTxt = await response.text();
23
+ const robot = robotsParser(robotsUrl, robotsTxt);
24
+ // isAllowed returns true/false/undefined; only an explicit false blocks.
25
+ if (robot.isAllowed(url, userAgent) === false) {
26
+ throw new McpError(ErrorCode.InternalError, `The sites robots.txt (${robotsUrl}) specifies that autonomous fetching of this page is not allowed.\nUser Agent: ${userAgent}\nURL: ${url}`);
27
+ }
28
+ }
29
+ catch (error) {
30
+ if (error instanceof McpError) {
31
+ throw error;
32
+ }
33
+ // Network errors
34
+ throw new McpError(ErrorCode.InternalError, `Failed to fetch robots.txt ${robotsUrl}: ${error.message}`);
35
+ }
36
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@fnnm/fetch",
3
+ "version": "0.1.0",
4
+ "description": "A Model Context Protocol server providing tools to fetch and convert web content",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "fetch": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "start": "node dist/index.js",
13
+ "dev": "ts-node src/index.ts",
14
+ "test": "vitest"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "fetch",
19
+ "web"
20
+ ],
21
+ "author": "",
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "^1.25.2",
28
+ "@mozilla/readability": "^0.6.0",
29
+ "@types/jsdom": "^27.0.0",
30
+ "@types/node": "^25.0.9",
31
+ "@types/pdf-parse": "^1.1.5",
32
+ "@types/turndown": "^5.0.6",
33
+ "jsdom": "^27.4.0",
34
+ "pdf-parse": "^2.4.5",
35
+ "robots-parser": "^3.0.1",
36
+ "ts-node": "^10.9.2",
37
+ "turndown": "^7.2.2",
38
+ "typescript": "^5.9.3",
39
+ "undici": "^7.18.2",
40
+ "vitest": "^4.0.17",
41
+ "zod": "^4.3.5"
42
+ },
43
+ "packageManager": "npm@11.18.0"
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { runServer } from './server.js';
3
+
4
+ runServer().catch((error) => {
5
+ console.error("Fatal error running server:", error);
6
+ process.exit(1);
7
+ });
@@ -0,0 +1,31 @@
1
+
2
+ import { handleFetch } from './tools/fetch.js';
3
+
4
+ async function testFetch(url: string, raw: boolean = false) {
5
+ console.log(`\n--- Fetching ${url} (Raw: ${raw}) ---`);
6
+ try {
7
+ const result = await handleFetch({
8
+ url,
9
+ max_length: 5000,
10
+ start_index: 0,
11
+ raw
12
+ }, true); // ignoreRobotsTxt=true for manual testing to ensure we get content
13
+
14
+ if (result.isError) {
15
+ console.error("Error result:", result);
16
+ } else {
17
+ console.log("Success! Content preview:");
18
+ console.log(result.content[0].text.substring(0, 500) + "...");
19
+ }
20
+ } catch (error: any) {
21
+ console.error("Exception:", error.message);
22
+ }
23
+ }
24
+
25
+ async function run() {
26
+ await testFetch('https://gnux.cn');
27
+ await testFetch('https://httpbin.org/html');
28
+ await testFetch('https://www.w3.org/Robots/robots.txt', true); // Fetching a text file
29
+ }
30
+
31
+ run();
package/src/server.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { FetchSchema, handleFetch } from "./tools/fetch.js";
5
+
6
+ export const createServer = () => {
7
+ const server = new McpServer({
8
+ name: "fetch",
9
+ version: "0.1.0",
10
+ });
11
+
12
+ server.tool(
13
+ "fetch",
14
+ "Fetches a URL from the internet and optionally extracts its contents as markdown.\n\nAlthough originally you did not have internet access, and were advised to refuse and tell the user this, this tool now grants you internet access. Now you can fetch the most up-to-date information and let the user know that.",
15
+ FetchSchema.shape,
16
+ async (args) => {
17
+ // Autonomous mode: respect robots.txt.
18
+
19
+ const result = await handleFetch(args as any, false);
20
+ return result;
21
+ },
22
+ );
23
+
24
+ server.prompt(
25
+ "fetch",
26
+ "Fetch a URL and extract its contents as markdown",
27
+ { url: z.string().url() },
28
+ async ({ url }) => {
29
+ // Manual mode: user-initiated, bypass robots.txt.
30
+ const result = await handleFetch(
31
+ { url, max_length: 1000000, start_index: 0, raw: false },
32
+ true,
33
+ );
34
+ return {
35
+ messages: [
36
+ {
37
+ role: "user",
38
+ content: { type: "text", text: result.content[0].text },
39
+ },
40
+ ],
41
+ };
42
+ },
43
+ );
44
+
45
+ return server;
46
+ };
47
+
48
+ export const runServer = async () => {
49
+ const server = createServer();
50
+ const transport = new StdioServerTransport();
51
+ await server.connect(transport);
52
+ console.error("MCP Server fetch running on stdio");
53
+ };
@@ -0,0 +1,107 @@
1
+
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { handleFetch } from './fetch.js';
4
+ import * as robots from '../utils/robots.js';
5
+
6
+ // Mock robots check to avoid actual network calls or dependency on robots logic
7
+ vi.mock('../utils/robots.js', async (importOriginal) => {
8
+ const actual = await importOriginal<typeof robots>();
9
+ return {
10
+ ...actual,
11
+ checkMayAutonomouslyFetchUrl: vi.fn().mockResolvedValue(undefined),
12
+ };
13
+ });
14
+
15
+ // Mock global fetch
16
+ const fetchMock = vi.fn();
17
+ global.fetch = fetchMock;
18
+
19
+ describe('handleFetch', () => {
20
+ beforeEach(() => {
21
+ fetchMock.mockReset();
22
+ vi.clearAllMocks();
23
+ });
24
+
25
+ it('should fetch and return markdown content', async () => {
26
+ fetchMock.mockResolvedValue({
27
+ ok: true,
28
+ status: 200,
29
+ headers: { get: () => 'text/html' },
30
+ text: async () => '<h1>Test Page</h1><p>Content</p>',
31
+ });
32
+
33
+ const args = { url: 'https://example.com', max_length: 5000, start_index: 0, raw: false };
34
+ const result = await handleFetch(args);
35
+
36
+ expect(result.isError).toBe(false);
37
+ expect(result.content[0].text).toContain('# Test Page');
38
+ });
39
+
40
+ it('should return raw content when requested', async () => {
41
+ fetchMock.mockResolvedValue({
42
+ ok: true,
43
+ status: 200,
44
+ headers: { get: () => 'text/html' },
45
+ text: async () => '<h1>Raw HTML</h1>',
46
+ });
47
+
48
+ const args = { url: 'https://example.com', max_length: 5000, start_index: 0, raw: true };
49
+ const result = await handleFetch(args);
50
+
51
+ expect(result.content[0].text).toContain('<h1>Raw HTML</h1>');
52
+ });
53
+
54
+ it('should truncate content if max_length is exceeded', async () => {
55
+ fetchMock.mockResolvedValue({
56
+ ok: true,
57
+ status: 200,
58
+ headers: { get: () => 'text/plain' },
59
+ text: async () => 'A'.repeat(100),
60
+ });
61
+
62
+ const args = { url: 'https://example.com', max_length: 10, start_index: 0, raw: true };
63
+ const result = await handleFetch(args);
64
+
65
+ expect(result.content[0].text).toContain('Contents of https://example.com');
66
+ // The actual content line
67
+ expect(result.content[0].text).toContain('AAAAAAAAAA');
68
+ expect(result.content[0].text).not.toContain('AAAAAAAAAAA'); // Should be 10 chars
69
+ expect(result.content[0].text).toContain('<error>Content truncated');
70
+ });
71
+
72
+ it('should handle fetch errors gracefully', async () => {
73
+ fetchMock.mockResolvedValue({
74
+ ok: false,
75
+ status: 404,
76
+ statusText: 'Not Found'
77
+ });
78
+
79
+ const args = { url: 'https://example.com/404', max_length: 5000, start_index: 0, raw: false };
80
+
81
+ await expect(handleFetch(args)).rejects.toThrow(/Failed to fetch/);
82
+ });
83
+
84
+ it('should use proxy agent if HTTP_PROXY is set', async () => {
85
+ const originalEnv = process.env;
86
+ process.env = { ...originalEnv, HTTP_PROXY: 'http://proxy.example.com' };
87
+
88
+ fetchMock.mockResolvedValue({
89
+ ok: true,
90
+ status: 200,
91
+ headers: { get: () => 'text/html' },
92
+ text: async () => '<h1>Proxy Test</h1>',
93
+ });
94
+
95
+ const args = { url: 'https://example.com', max_length: 5000, start_index: 0, raw: false };
96
+ await handleFetch(args);
97
+
98
+ const callArgs = fetchMock.mock.calls[0];
99
+ const options: any = callArgs[1];
100
+
101
+ expect(options).toHaveProperty('dispatcher');
102
+ // We can't easily check if it's strictly a ProxyAgent instance without exposing it,
103
+ // but checking for the presence of the dispatcher property is a good proxy (pun intended).
104
+
105
+ process.env = originalEnv;
106
+ });
107
+ });
@@ -0,0 +1,108 @@
1
+ import { z } from 'zod';
2
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
+ import { checkMayAutonomouslyFetchUrl, USER_AGENT_AUTONOMOUS, USER_AGENT_MANUAL } from '../utils/robots.js';
4
+ import { extractContentFromHtml, extractContentFromPdf } from '../utils/convert.js';
5
+ import { ProxyAgent, Dispatcher } from 'undici';
6
+
7
+ export const FetchSchema = z.object({
8
+ url: z.string().url(),
9
+ max_length: z.number().int().positive().lt(1000000).default(5000),
10
+ start_index: z.number().int().min(0).default(0),
11
+ raw: z.boolean().default(false),
12
+ });
13
+
14
+ export type FetchArgs = z.infer<typeof FetchSchema>;
15
+
16
+ function getProxyAgent(url: string): Dispatcher | undefined {
17
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
18
+
19
+ if (!proxyUrl) {
20
+ return undefined;
21
+ }
22
+
23
+ // Basic check: if strictly http proxy for https url, standard fetch might complain?
24
+ // Undici ProxyAgent handles this generally well.
25
+ return new ProxyAgent(proxyUrl);
26
+ }
27
+
28
+ export async function handleFetch(args: FetchArgs, ignoreRobotsTxt: boolean = false) {
29
+ const { url, max_length, start_index, raw } = args;
30
+
31
+ // Choose user agent
32
+ const userAgent = ignoreRobotsTxt ? USER_AGENT_MANUAL : USER_AGENT_AUTONOMOUS;
33
+
34
+ if (!ignoreRobotsTxt) {
35
+ await checkMayAutonomouslyFetchUrl(url, userAgent);
36
+ }
37
+
38
+ try {
39
+ const controller = new AbortController();
40
+ const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout
41
+
42
+ const dispatcher = getProxyAgent(url);
43
+
44
+ const fetchOptions: any = {
45
+ headers: { "User-Agent": userAgent },
46
+ signal: controller.signal,
47
+ };
48
+
49
+ if (dispatcher) {
50
+ fetchOptions.dispatcher = dispatcher;
51
+ }
52
+
53
+ const response = await fetch(url, fetchOptions);
54
+ clearTimeout(timeoutId);
55
+
56
+ if (!response.ok) {
57
+ throw new McpError(ErrorCode.InternalError, `Failed to fetch ${url} - status code ${response.status}`);
58
+ }
59
+
60
+ const contentType = response.headers.get("content-type") || "";
61
+ const isPdf = contentType.toLowerCase().includes("application/pdf") || url.toLowerCase().endsWith(".pdf");
62
+
63
+ let textContent = "";
64
+ let prefix = "";
65
+
66
+ if (isPdf) {
67
+ const arrayBuffer = await response.arrayBuffer();
68
+ textContent = await extractContentFromPdf(Buffer.from(arrayBuffer));
69
+ } else if ((contentType.includes("text/html") || contentType === "") && !raw) {
70
+ const html = await response.text();
71
+ textContent = await extractContentFromHtml(html, url);
72
+ } else {
73
+ textContent = await response.text();
74
+ prefix = `Content type ${contentType} cannot be simplified to markdown, but here is the raw content:\n`;
75
+ }
76
+
77
+ const originalLength = textContent.length;
78
+
79
+ let finalContent = "";
80
+
81
+ if (start_index >= originalLength) {
82
+ finalContent = "<error>No more content available.</error>";
83
+ } else {
84
+ finalContent = textContent.slice(start_index, start_index + max_length);
85
+ if (finalContent.length === 0) {
86
+ finalContent = "<error>No more content available.</error>";
87
+ }
88
+ }
89
+
90
+ // Check if truncated
91
+ const actualLength = finalContent.length;
92
+ const remaining = originalLength - (start_index + actualLength);
93
+
94
+ if (actualLength === max_length && remaining > 0) {
95
+ const nextStart = start_index + actualLength;
96
+ finalContent += `\n\n<error>Content truncated. Call the fetch tool with a start_index of ${nextStart} to get more content.</error>`;
97
+ }
98
+
99
+ return {
100
+ content: [{ type: "text" as const, text: `${prefix}Contents of ${url}:\n${finalContent}` }],
101
+ isError: false
102
+ };
103
+
104
+ } catch (error: any) {
105
+ if (error instanceof McpError) throw error;
106
+ throw new McpError(ErrorCode.InternalError, `Failed to fetch ${url}: ${error.message}`);
107
+ }
108
+ }
package/src/types.d.ts ADDED
@@ -0,0 +1,12 @@
1
+
2
+ declare module 'robots-parser' {
3
+ interface Robot {
4
+ isAllowed(url: string, userAgent?: string): boolean | undefined;
5
+ getPreferredHost(): string | null;
6
+ getCrawlDelay(userAgent?: string): number | undefined;
7
+ getSitemaps(): string[];
8
+ }
9
+
10
+ function robotsParser(url: string, contents: string): Robot;
11
+ export = robotsParser;
12
+ }
@@ -0,0 +1,41 @@
1
+
2
+ import { describe, it, expect } from 'vitest';
3
+ import { extractContentFromHtml } from './convert.js';
4
+
5
+ describe('extractContentFromHtml', () => {
6
+ it('should convert simple HTML to Markdown', async () => {
7
+ const html = '<h1>Hello World</h1><p>This is a test.</p>';
8
+ const url = 'https://example.com';
9
+ const markdown = await extractContentFromHtml(html, url);
10
+
11
+ expect(markdown).toContain('# Hello World');
12
+ expect(markdown).toContain('This is a test.');
13
+ });
14
+
15
+ it('should handle complex HTML', async () => {
16
+ const html = `
17
+ <article>
18
+ <h1>Title</h1>
19
+ <p>Paragraph 1</p>
20
+ <ul>
21
+ <li>Item 1</li>
22
+ <li>Item 2</li>
23
+ </ul>
24
+ </article>
25
+ `;
26
+ const url = 'https://example.com';
27
+ const markdown = await extractContentFromHtml(html, url);
28
+
29
+ expect(markdown).toContain('# Title');
30
+ expect(markdown).toContain('* Item 1');
31
+ expect(markdown).toContain('* Item 2');
32
+ });
33
+
34
+ it('should handle empty or invalid content gracefully', async () => {
35
+ const html = '';
36
+ const url = 'https://example.com';
37
+ const result = await extractContentFromHtml(html, url);
38
+ // Readability returns null for empty/insufficient content, wrapper returns error string
39
+ expect(result).toBe('<error>Page failed to be simplified from HTML</error>');
40
+ });
41
+ });
@@ -0,0 +1,37 @@
1
+
2
+ import { JSDOM } from 'jsdom';
3
+ import { Readability } from '@mozilla/readability';
4
+ import TurndownService from 'turndown';
5
+ import { createRequire } from 'module';
6
+ const require = createRequire(import.meta.url);
7
+ const pdfParse = require('pdf-parse');
8
+
9
+ export async function extractContentFromHtml(html: string, url: string): Promise<string> {
10
+ try {
11
+ const dom = new JSDOM(html, { url });
12
+ const reader = new Readability(dom.window.document);
13
+ const article = reader.parse();
14
+
15
+ if (!article || !article.content) {
16
+ return "<error>Page failed to be simplified from HTML</error>";
17
+ }
18
+
19
+ const turndownService = new TurndownService({
20
+ headingStyle: 'atx',
21
+ codeBlockStyle: 'fenced'
22
+ });
23
+
24
+ return turndownService.turndown(article.content);
25
+ } catch (error) {
26
+ return "<error>Page failed to be simplified from HTML</error>";
27
+ }
28
+ }
29
+
30
+ export async function extractContentFromPdf(buffer: Buffer): Promise<string> {
31
+ try {
32
+ const data = await pdfParse(buffer);
33
+ return data.text;
34
+ } catch (error) {
35
+ return "<error>Failed to extract content from PDF</error>";
36
+ }
37
+ }
@@ -0,0 +1,69 @@
1
+
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import { checkMayAutonomouslyFetchUrl, USER_AGENT_AUTONOMOUS } from './robots.js';
4
+ import { McpError } from '@modelcontextprotocol/sdk/types.js';
5
+
6
+ // Mock global fetch
7
+ const fetchMock = vi.fn();
8
+ global.fetch = fetchMock;
9
+
10
+ describe('checkMayAutonomouslyFetchUrl', () => {
11
+ beforeEach(() => {
12
+ fetchMock.mockReset();
13
+ });
14
+
15
+ it('should allow fetching when robots.txt allows it', async () => {
16
+ const robotsTxt = `
17
+ User-agent: *
18
+ Allow: /
19
+ `;
20
+
21
+ fetchMock.mockResolvedValue({
22
+ ok: true,
23
+ status: 200,
24
+ text: async () => robotsTxt,
25
+ });
26
+
27
+ await expect(checkMayAutonomouslyFetchUrl('https://example.com/page', USER_AGENT_AUTONOMOUS))
28
+ .resolves.not.toThrow();
29
+ });
30
+
31
+ it('should forbid fetching when robots.txt disallows it', async () => {
32
+ const robotsTxt = `
33
+ User-agent: *
34
+ Disallow: /private
35
+ `;
36
+
37
+ fetchMock.mockResolvedValue({
38
+ ok: true,
39
+ status: 200,
40
+ text: async () => robotsTxt,
41
+ });
42
+
43
+ await expect(checkMayAutonomouslyFetchUrl('https://example.com/private', USER_AGENT_AUTONOMOUS))
44
+ .rejects.toThrow(McpError);
45
+ });
46
+
47
+ it('should throw internal error on 401/403', async () => {
48
+ fetchMock.mockResolvedValue({
49
+ ok: false,
50
+ status: 403,
51
+ statusText: 'Forbidden'
52
+ });
53
+
54
+ await expect(checkMayAutonomouslyFetchUrl('https://example.com/page', USER_AGENT_AUTONOMOUS))
55
+ .rejects.toThrow(/received status 403/);
56
+ });
57
+
58
+ it('should allow if robots.txt returns 404', async () => {
59
+ // If robots.txt is missing, crawling is usually allowed
60
+ fetchMock.mockResolvedValue({
61
+ ok: false,
62
+ status: 404, // 400-499 range typically means "no robots.txt" -> allow
63
+ statusText: 'Not Found'
64
+ });
65
+
66
+ await expect(checkMayAutonomouslyFetchUrl('https://example.com/page', USER_AGENT_AUTONOMOUS))
67
+ .resolves.not.toThrow();
68
+ });
69
+ });
@@ -0,0 +1,57 @@
1
+
2
+ import robotsParser from 'robots-parser';
3
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
4
+
5
+ export const USER_AGENT_AUTONOMOUS = "ModelContextProtocol/1.0 (Autonomous; +https://github.com/modelcontextprotocol/servers)";
6
+ export const USER_AGENT_MANUAL = "ModelContextProtocol/1.0 (User-Specified; +https://github.com/modelcontextprotocol/servers)";
7
+
8
+ export async function getRobotsTxtUrl(url: string): Promise<string> {
9
+ const parsed = new URL(url);
10
+ return `${parsed.protocol}//${parsed.host}/robots.txt`;
11
+ }
12
+
13
+ export async function checkMayAutonomouslyFetchUrl(url: string, userAgent: string): Promise<void> {
14
+ const robotsUrl = await getRobotsTxtUrl(url);
15
+
16
+ try {
17
+ const response = await fetch(robotsUrl, {
18
+ headers: { "User-Agent": userAgent }
19
+ });
20
+
21
+ if (response.status === 401 || response.status === 403) {
22
+ throw new McpError(
23
+ ErrorCode.InternalError,
24
+ `When fetching robots.txt (${robotsUrl}), received status ${response.status} so assuming that autonomous fetching is not allowed.`
25
+ );
26
+ }
27
+
28
+ // 4xx (except 401/403): treat as no robots.txt -> allowed.
29
+
30
+ if (response.status >= 400 && response.status < 500) {
31
+ return; // Assume allowed if robots.txt is missing/bad request
32
+ }
33
+
34
+
35
+ const robotsTxt = await response.text();
36
+ const robot = robotsParser(robotsUrl, robotsTxt);
37
+
38
+ // isAllowed returns true/false/undefined; only an explicit false blocks.
39
+
40
+ if (robot.isAllowed(url, userAgent) === false) {
41
+ throw new McpError(
42
+ ErrorCode.InternalError,
43
+ `The sites robots.txt (${robotsUrl}) specifies that autonomous fetching of this page is not allowed.\nUser Agent: ${userAgent}\nURL: ${url}`
44
+ );
45
+ }
46
+
47
+ } catch (error: any) {
48
+ if (error instanceof McpError) {
49
+ throw error;
50
+ }
51
+ // Network errors
52
+ throw new McpError(
53
+ ErrorCode.InternalError,
54
+ `Failed to fetch robots.txt ${robotsUrl}: ${error.message}`
55
+ );
56
+ }
57
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": [
7
+ "ES2022"
8
+ ],
9
+ "outDir": "./dist",
10
+ "rootDir": "./src",
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true
15
+ },
16
+ "include": [
17
+ "src/**/*"
18
+ ],
19
+ "exclude": [
20
+ "node_modules",
21
+ "**/*.test.ts"
22
+ ]
23
+ }