https://txtfetch.com/integrations/mcp/
Give an AI agent txtfetch as a tool.
txtfetch ships no MCP server. This page gives you one, in about 50 lines, that you run on your own machine. Your API key stays in your local config.
the-problem
An agent that reads and acts on documents needs their text first. A URL alone doesn't help much. A scanned PDF returns no text at all. A DOCX or PPTX needs a parser the agent doesn't have. A 300-page report can also blow past the agent's context window. A tool that hands back the whole thing unfiltered isn't safe to use by default.
how-to-wire-it-up
txtfetch ships no MCP server of its own. The Model Context Protocol lets an agent call a local tool over a small, standard interface. A server that wraps the REST API is short to write. Run it on your own machine with your own API key, so the key never leaves your local config. Because the server runs locally, it can also read a file already on your disk, not just a public URL. The recipe below covers both cases: extract_text for a link, extract_file for a local path.
- extract_text takes a URL and returns text or markdown, with automatic OCR for scanned pages.
- extract_file uploads a file from your own disk, so the agent doesn't need a public link first.
- Both tools poll a slow job on their own, so the agent never sees a bare 202 response.
- A typed ToolError carries the real error.code and message, instead of a bare 'Error executing tool'.
- The server runs on your machine, so your API key stays in your local config.
the-server
We ran both servers below against a mock of the API. Each one passed six cases. Four are simple: plain text, markdown, a file upload, and a typed error. In the other two, a URL and an upload each returned 202 first, and the tool polled until the job finished.
import os
import time
import httpx
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
API = os.environ.get("TXTFETCH_API", "https://api.txtfetch.com/v1/extract")
HEADERS = {"Authorization": f"Bearer {os.environ['TXTFETCH_KEY']}"}
mcp = MCPServer("txtfetch")
def finish(body: dict) -> dict:
# A large or slow document returns 202 and a job_id. Poll until it finishes.
while body["status"] == "processing":
time.sleep(2)
body = httpx.get(f"{API}/{body['job_id']}", headers=HEADERS, timeout=60).json()
if body["status"] == "error":
raise ToolError(f"{body['error']['code']}: {body['error']['message']}")
return body
@mcp.tool()
def extract_text(url: str, format: str = "text") -> str:
"""Fetch a document or web page by URL and return its text.
format is "text" (plain text) or "markdown" (keeps headings, and most tables).
"""
r = httpx.post(API, headers=HEADERS, params={"url": url, "format": format}, timeout=60)
body = finish(r.json())
return body["markdown"] if format == "markdown" else body["extracted_text"]
@mcp.tool()
def extract_file(path: str) -> str:
"""Extract text from a file already on this machine."""
with open(path, "rb") as f:
r = httpx.post(API, headers=HEADERS, files={"file": f}, timeout=60)
return finish(r.json())["extracted_text"]
if __name__ == "__main__":
mcp.run()import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const API = process.env.TXTFETCH_API ?? "https://api.txtfetch.com/v1/extract";
const headers = { Authorization: `Bearer ${process.env.TXTFETCH_KEY}` };
// A large or slow document returns 202 and a job_id. Poll until it finishes.
async function finish(res, field) {
let body = await res.json();
while (body.status === "processing") {
await new Promise((r) => setTimeout(r, 2000));
body = await (await fetch(`${API}/${body.job_id}`, { headers })).json();
}
if (body.status === "error") {
return { isError: true, content: [{ type: "text", text: `${body.error.code}: ${body.error.message}` }] };
}
return { content: [{ type: "text", text: body[field] }] };
}
const server = new McpServer({ name: "txtfetch", version: "1.0.0" });
server.registerTool(
"extract_text",
{
description: "Fetch a document or web page by URL and return its text.",
inputSchema: {
url: z.string().url(),
format: z.enum(["text", "markdown"]).default("text"),
},
},
async ({ url, format }) => {
const endpoint = new URL(API);
endpoint.searchParams.set("url", url);
endpoint.searchParams.set("format", format);
const res = await fetch(endpoint, { method: "POST", headers });
return finish(res, format === "markdown" ? "markdown" : "extracted_text");
},
);
server.registerTool(
"extract_file",
{
description: "Extract text from a file already on this machine.",
inputSchema: { path: z.string() },
},
async ({ path }) => {
const form = new FormData();
form.append("file", new Blob([await readFile(path)]), basename(path));
const res = await fetch(API, { method: "POST", headers, body: form });
return finish(res, "extracted_text");
},
);
await server.connect(new StdioServerTransport());install-it
# Python: save the server as server.py
pip install "mcp>=2" httpx
# Node.js 18+: save the server as server.mjs
npm install @modelcontextprotocol/sdk zodregister-it
# Python server
claude mcp add txtfetch -e TXTFETCH_KEY=your-key-here -- python server.py
# Or the Node.js server
claude mcp add txtfetch -e TXTFETCH_KEY=your-key-here -- node server.mjs
# Check the connection
claude mcp list{
"mcpServers": {
"txtfetch": {
"command": "python",
"args": ["/absolute/path/to/server.py"],
"env": {
"TXTFETCH_KEY": "your-key-here"
}
}
}
}- MCP Python SDK migration guide: FastMCP renamed to MCPServer
- Claude Code docs: MCP output token limits and claude mcp add
- Model Context Protocol docs: connect to local servers (Claude Desktop config)
- Cursor docs: MCP
pass-a-link-not-a-file
curl -X POST "https://api.txtfetch.com/v1/extract?url=https://example.com/report.pdf" \
-H "Authorization: Bearer $TXTFETCH_KEY"import os
import requests
r = requests.post(
"https://api.txtfetch.com/v1/extract",
headers={"Authorization": f"Bearer {os.environ['TXTFETCH_KEY']}"},
params={"url": "https://example.com/report.pdf"},
)
print(r.json()["extracted_text"])const endpoint = new URL("https://api.txtfetch.com/v1/extract");
endpoint.searchParams.set("url", "https://example.com/report.pdf");
const res = await fetch(endpoint, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.TXTFETCH_KEY}` },
});
const { extracted_text } = await res.json();
console.log(extracted_text);package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
)
type extractResponse struct {
Status string `json:"status"`
ExtractedText string `json:"extracted_text"`
}
func main() {
endpoint, err := url.Parse("https://api.txtfetch.com/v1/extract")
if err != nil {
panic(err)
}
q := endpoint.Query()
q.Set("url", "https://example.com/report.pdf")
endpoint.RawQuery = q.Encode()
req, err := http.NewRequest("POST", endpoint.String(), nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("TXTFETCH_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var result extractResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
panic(err)
}
fmt.Println(result.ExtractedText)
}{
"status": "success",
"extracted_text": "..."
}large-or-slow-documents
POST /v1/extract returns 202 with a job_id whenever the document is large or slow to process, or whenever async=true is set. Poll GET /v1/extract/{job_id} for the result, or set webhook_url and have txtfetch push it instead. No single request on this page is safe to assume will always finish synchronously.
curl -X POST "https://api.txtfetch.com/v1/extract?url=https://example.com/report.pdf&async=true" \
-H "Authorization: Bearer $TXTFETCH_KEY"
# {"status": "processing", "job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"}curl "https://api.txtfetch.com/v1/extract/3fa85f64-5717-4562-b3fc-2c963f66afa6" \
-H "Authorization: Bearer $TXTFETCH_KEY"
# {"status": "processing", "job_id": "..."} while running, then the same
# {"status": "success", "extracted_text": "...", "metadata": {...}} shape
# POST /v1/extract would have returned synchronously.curl -X POST "https://api.txtfetch.com/v1/extract?url=https://example.com/report.pdf" \
-H "Authorization: Bearer $TXTFETCH_KEY" \
--data-urlencode "webhook_url=https://example.com/webhooks/txtfetch"setup-steps
- Save the Python server as server.py, or the Node.js server as server.mjs.
- Install the dependencies. For Python, that is mcp 2.x and httpx. For Node.js, that is @modelcontextprotocol/sdk and zod.
- Register the server with Claude Code, and pass your API key as TXTFETCH_KEY.
- For Claude Desktop or Cursor, add the JSON block to the client's config. Use an absolute path to the server file.
- For the Node.js server in that JSON block, set command to node and args to the path of server.mjs.
- Restart the client, so that it loads the new server.
- Ask the agent to read a document. It calls extract_text or extract_file and gets the text back in its reply.
faq
- Does txtfetch have an official MCP server?
- No. txtfetch ships no MCP server or directory listing. The code on this page is a server you run yourself, not a hosted txtfetch product.
- Why run the server locally instead of using a hosted one?
- A local server keeps your API key in your own config file, never on a host you don't control. It also reads files already on your disk, which a hosted server couldn't reach.
- My tutorial imports FastMCP and it fails. Why?
- It was written for mcp 1.x. Version 2.x renamed the class to MCPServer, in mcp.server.mcpserver. Pin mcp<2 to keep old code running, or update the import as shown on this page.
- Why does my agent only see 'Error executing tool extract_text'?
- A plain exception in an mcp 2.x tool hides its own message from the agent. Raise ToolError instead, and the agent reads the real error.code and message.
- What happens with a very large document, like a 300-page PDF?
- The extracted text still has to fit in the agent's context window. Claude Code warns above 10,000 tokens of tool output. By default it caps the output at 25,000 tokens. The MAX_MCP_OUTPUT_TOKENS environment variable raises that cap. See /docs/limits for txtfetch's own size limits.
- Can't the model just read the PDF itself, with no txtfetch tool at all?
- Sometimes. A model with vision can read a short, simple PDF directly. See /compare/multimodal-llm for where that breaks down and a dedicated extraction step still wins.
related-reading
other-integrations