MCP Node Fetch
by mcollina
- src
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
CallToolResultSchema,
} from "@modelcontextprotocol/sdk/types.js";
import type {
Tool
} from "@modelcontextprotocol/sdk/types.js";
import { fetch } from "undici";
import { z } from "zod";
import { JSDOM } from "jsdom";
// Define the arguments schemas for our tools
const FetchUrlArgsSchema = z.object({
url: z.string().url().describe("URL to fetch"),
method: z.enum(["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH"]).default("GET").describe("HTTP method"),
headers: z.record(z.string()).optional().describe("HTTP headers"),
body: z.string().optional().describe("Request body for POST/PUT/PATCH requests"),
timeout: z.number().positive().optional().describe("Request timeout in milliseconds"),
responseType: z.enum(["text", "json", "binary", "html-fragment"]).default("text").describe("How to parse the response"),
followRedirects: z.boolean().default(true).describe("Whether to follow redirects"),
fragmentSelector: z.string().optional().describe("CSS selector for the HTML fragment to extract (when responseType is html-fragment)")
});
const ExtractHtmlFragmentArgsSchema = z.object({
url: z.string().url().describe("URL to fetch"),
selector: z.string().describe("CSS selector for the HTML fragment to extract"),
anchorId: z.string().optional().describe("Optional anchor ID to locate a specific fragment"),
method: z.enum(["GET", "POST"]).default("GET").describe("HTTP method"),
headers: z.record(z.string()).optional().describe("HTTP headers"),
body: z.string().optional().describe("Request body for POST requests"),
timeout: z.number().positive().optional().describe("Request timeout in milliseconds"),
followRedirects: z.boolean().default(true).describe("Whether to follow redirects")
});
const CheckStatusArgsSchema = z.object({
url: z.string().url().describe("URL to check"),
timeout: z.number().positive().optional().describe("Request timeout in milliseconds")
});
// Define type interfaces
type FetchUrlArgs = z.infer<typeof FetchUrlArgsSchema>;
type ExtractHtmlFragmentArgs = z.infer<typeof ExtractHtmlFragmentArgsSchema>;
type CheckStatusArgs = z.infer<typeof CheckStatusArgsSchema>;
// Available tools
const TOOLS: Tool[] = [
{
name: "extract-html-fragment",
description: "Extract a specific HTML fragment from a webpage using CSS selectors",
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "URL to fetch" },
selector: { type: "string", description: "CSS selector for the HTML fragment to extract" },
anchorId: { type: "string", description: "Optional anchor ID to locate a specific fragment" },
method: {
type: "string",
enum: ["GET", "POST"],
default: "GET",
description: "HTTP method"
},
headers: {
type: "object",
additionalProperties: { type: "string" },
description: "HTTP headers"
},
body: { type: "string", description: "Request body for POST requests" },
timeout: { type: "number", description: "Request timeout in milliseconds" },
followRedirects: {
type: "boolean",
default: true,
description: "Whether to follow redirects"
},
fragmentSelector: {
type: "string",
description: "CSS selector for the HTML fragment to extract (when responseType is html-fragment)"
}
},
required: ["url", "selector"]
}
},
{
name: "fetch-url",
description: "Fetch content from a URL",
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "URL to fetch" },
method: {
type: "string",
enum: ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH"],
default: "GET",
description: "HTTP method"
},
headers: {
type: "object",
additionalProperties: { type: "string" },
description: "HTTP headers"
},
body: { type: "string", description: "Request body for POST/PUT/PATCH requests" },
timeout: { type: "number", description: "Request timeout in milliseconds" },
responseType: {
type: "string",
enum: ["text", "json", "binary", "html-fragment"],
default: "text",
description: "How to parse the response"
},
followRedirects: {
type: "boolean",
default: true,
description: "Whether to follow redirects"
}
},
required: ["url"]
}
},
{
name: "check-status",
description: "Check if a URL is accessible (HEAD request)",
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "URL to check" },
timeout: { type: "number", description: "Request timeout in milliseconds" }
},
required: ["url"]
}
}
];
// Initialize server
const server = new Server(
{
name: "node-fetch",
version: "1.0.0"
},
{
capabilities: {
tools: {}
}
}
);
// Register the tools/list handler
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: TOOLS
};
});
// Register tools/call handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case "fetch-url":
return handleFetchUrl(FetchUrlArgsSchema.parse(args));
case "extract-html-fragment":
return handleExtractHtmlFragment(ExtractHtmlFragmentArgsSchema.parse(args));
case "check-status":
return handleCheckStatus(CheckStatusArgsSchema.parse(args));
default:
return {
isError: true,
content: [
{
type: "text",
text: `Unknown tool: ${name}`
}
]
};
}
});
// Handler for fetch-url tool
async function handleFetchUrl(args: FetchUrlArgs): Promise<z.infer<typeof CallToolResultSchema>> {
try {
const { url, method, headers, body, timeout, responseType, followRedirects } = args;
// Log the fetch request
console.error(`Fetching ${url} with method ${method}`);
// Create request options
const options: any = {
method,
headers: headers || {},
body: body || undefined,
redirect: followRedirects ? "follow" : "manual",
};
if (timeout) {
// @ts-ignore - undici specific options
options.bodyTimeout = timeout;
// @ts-ignore - undici specific options
options.headersTimeout = timeout;
}
// Perform the request
const response = await fetch(url, options);
// Create a result object with metadata
const result: Record<string, any> = {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
url: response.url,
};
// Process the response based on the requested type
switch (responseType) {
case "json":
try {
const jsonContent = await response.json();
result.content = JSON.stringify(jsonContent, null, 2);
} catch (e) {
throw new Error(`Failed to parse response as JSON: ${(e as Error).message}`);
}
break;
case "binary":
const buffer = await response.arrayBuffer();
result.content = Buffer.from(buffer).toString("base64");
result.encoding = "base64";
break;
case "html-fragment":
try {
const htmlContent = await response.text();
// Use JSDOM to parse the HTML
const dom = new JSDOM(htmlContent);
const document = dom.window.document;
// Only look for fragments if a selector is provided
if (args.fragmentSelector) {
const elements = document.querySelectorAll(args.fragmentSelector);
if (elements.length === 0) {
throw new Error(`No elements found matching selector "${args.fragmentSelector}"`);
}
// Extract the HTML from the selected element(s)
if (elements.length === 1) {
result.content = elements[0].outerHTML;
} else {
result.content = Array.from(elements).map(el => el.outerHTML).join('\n');
}
result.matchCount = elements.length;
} else {
// No selector provided, return the full HTML
result.content = htmlContent;
}
result.contentType = 'text/html';
} catch (e) {
throw new Error(`Failed to parse or extract HTML fragment: ${(e as Error).message}`);
}
break;
case "text":
default:
result.content = await response.text();
break;
}
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
]
};
} catch (error) {
console.error(`Error fetching URL:`, error);
return {
isError: true,
content: [
{
type: "text",
text: `Error fetching URL: ${(error as Error).message}`
}
]
};
}
}
// Handler for extract-html-fragment tool
async function handleExtractHtmlFragment(args: ExtractHtmlFragmentArgs): Promise<z.infer<typeof CallToolResultSchema>> {
try {
const { url, selector, anchorId, method, headers, body, timeout, followRedirects } = args;
console.error(`Extracting HTML fragment from ${url} using selector "${selector}"`);
// Create request options
const options: any = {
method,
headers: headers || {},
body: body || undefined,
redirect: followRedirects ? "follow" : "manual",
};
if (timeout) {
// @ts-ignore - undici specific options
options.bodyTimeout = timeout;
// @ts-ignore - undici specific options
options.headersTimeout = timeout;
}
// Perform the request
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Failed to fetch URL: HTTP ${response.status} ${response.statusText}`);
}
// Get the HTML content
const html = await response.text();
// Parse the HTML using JSDOM
const dom = new JSDOM(html);
const document = dom.window.document;
// Handle anchor if provided
if (anchorId) {
// Scroll to the anchor element
const anchorElement = document.getElementById(anchorId);
if (!anchorElement) {
throw new Error(`Anchor element with ID "${anchorId}" not found`);
}
}
// Find the element(s) matching the selector
const elements = document.querySelectorAll(selector);
if (elements.length === 0) {
throw new Error(`No elements found matching selector "${selector}"`);
}
// Create a result object
const result: Record<string, any> = {
status: response.status,
statusText: response.statusText,
url: response.url,
selector: selector,
matchCount: elements.length,
};
// Get the HTML content of the selected elements
if (elements.length === 1) {
// If only one element found, return its outer HTML
result.html = elements[0].outerHTML;
} else {
// If multiple elements found, return an array of their outer HTML
result.html = Array.from(elements).map(el => el.outerHTML);
}
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
]
};
} catch (error) {
console.error(`Error extracting HTML fragment:`, error);
return {
isError: true,
content: [
{
type: "text",
text: `Error extracting HTML fragment: ${(error as Error).message}`
}
]
};
}
}
// Handler for check-status tool
async function handleCheckStatus(args: CheckStatusArgs): Promise<z.infer<typeof CallToolResultSchema>> {
try {
const { url, timeout } = args;
console.error(`Checking status of ${url}`);
// Create request options
const options: any = {
method: "HEAD",
};
if (timeout) {
// @ts-ignore - undici specific options
options.headersTimeout = timeout;
}
// Perform the request
const response = await fetch(url, options);
// Create a result object
const result = {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
url: response.url,
isAvailable: response.ok,
};
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
]
};
} catch (error) {
console.error(`Error checking URL status:`, error);
return {
isError: true,
content: [
{
type: "text",
text: `Error checking URL status: ${(error as Error).message}`
}
]
};
}
}
// Start the server using stdio transport
async function main() {
try {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP Node Fetch server started");
} catch (error) {
console.error("Failed to start server:", error);
process.exit(1);
}
}
main();