@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 +110 -0
- package/Dockerfile +31 -0
- package/LICENSE +7 -0
- package/README.md +160 -0
- package/dist/index.js +6 -0
- package/dist/manual-test.js +28 -0
- package/dist/server.js +34 -0
- package/dist/tools/fetch.js +88 -0
- package/dist/utils/convert.js +33 -0
- package/dist/utils/robots.js +36 -0
- package/package.json +44 -0
- package/src/index.ts +7 -0
- package/src/manual-test.ts +31 -0
- package/src/server.ts +53 -0
- package/src/tools/fetch.test.ts +107 -0
- package/src/tools/fetch.ts +108 -0
- package/src/types.d.ts +12 -0
- package/src/utils/convert.test.ts +41 -0
- package/src/utils/convert.ts +37 -0
- package/src/utils/robots.test.ts +69 -0
- package/src/utils/robots.ts +57 -0
- package/tsconfig.json +23 -0
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,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,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
|
+
}
|