mcp-web-tools 0.2.6

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 paifas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # mcp-web-tools
2
+
3
+ MCP server providing web search tools for AI assistants, powered by [Tavily](https://tavily.com).
4
+
5
+ ## Setup
6
+
7
+ You need a [Tavily API key](https://tavily.com) (free tier available).
8
+
9
+ ### Claude Code (CLI)
10
+
11
+ ```bash
12
+ claude mcp add mcp-web-tools -e TAVILY_API_KEY=your-api-key -- npx -y mcp-web-tools
13
+ ```
14
+
15
+ ### Claude Desktop
16
+
17
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "mcp-web-tools": {
23
+ "command": "npx",
24
+ "args": ["-y", "mcp-web-tools"],
25
+ "env": {
26
+ "TAVILY_API_KEY": "your-api-key"
27
+ }
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ ### OpenCode
34
+
35
+ Add to `~/.config/opencode/opencode.json` (global) or `.opencode.json` in your project root:
36
+
37
+ ```jsonc
38
+ "mcp-web-tools": {
39
+ "type": "local",
40
+ "command": ["npx", "-y", "mcp-web-tools"],
41
+ "environment": {
42
+ "TAVILY_API_KEY": "your-api-key"
43
+ }
44
+ }
45
+ ```
46
+
47
+ ## Tools
48
+
49
+ ### `web_search`
50
+
51
+ Search the web using Tavily. Returns results with titles, URLs, and snippets.
52
+
53
+ **Parameters:**
54
+
55
+ | Parameter | Type | Default | Description |
56
+ |---|---|---|---|
57
+ | `query` | string | *required* | The search query |
58
+ | `maxResults` | number | 5 | Maximum number of results (1–20) |
59
+ | `searchDepth` | string | `"basic"` | `"basic"`, `"fast"`, `"ultra-fast"` (1 credit) or `"advanced"` (2 credits) |
60
+ | `topic` | string | `"general"` | `"general"`, `"news"`, or `"finance"` |
61
+ | `timeRange` | string | — | `"day"`, `"week"`, `"month"`, or `"year"` |
62
+ | `startDate` | string | — | Start date filter (`YYYY-MM-DD`) |
63
+ | `endDate` | string | — | End date filter (`YYYY-MM-DD`) |
64
+ | `includeDomains` | string[] | — | Only include results from these domains |
65
+ | `excludeDomains` | string[] | — | Exclude results from these domains |
66
+ | `includeAnswer` | boolean | `true` | Include an AI-generated answer summary |
67
+
68
+ ### `credit_balance`
69
+
70
+ Check your Tavily API credit balance and usage.
71
+
72
+ ## Environment Variables
73
+
74
+ | Variable | Required | Default | Description |
75
+ |---|---|---|---|
76
+ | `TAVILY_API_KEY` | Yes | — | Your Tavily API key. Get one at [tavily.com](https://tavily.com) |
77
+ | `WEBTOOLS_MAX_RESULTS` | No | `5` | Default number of results |
78
+ | `WEBTOOLS_SEARCH_DEPTH` | No | `basic` | Default search depth |
79
+ | `WEBTOOLS_CACHE_TTL` | No | `3600` | Cache TTL in seconds (0 to disable) |
80
+ | `WEBTOOLS_DEBUG` | No | — | Set to any value to enable debug logging to stderr |
81
+
82
+ ## Example Output
83
+
84
+ ```
85
+ Node.js 22 introduces require() support for ES modules, a WebSocket client, and updates to the V8 JavaScript engine.
86
+
87
+ ### 1. [Node.js — Node.js 22 is now available!](https://nodejs.org/blog/announcements/v22-release-announce)
88
+ > We're excited to announce the release of Node.js 22! Highlights include require()ing ES modules, a WebSocket client, updates of the V8 JavaScript engine, and more!
89
+
90
+ *Response time: 1.2s*
91
+
92
+ ---
93
+
94
+ Sources:
95
+ - [Node.js — Node.js 22 is now available!](https://nodejs.org/blog/announcements/v22-release-announce)
96
+ ```
97
+
98
+ ## License
99
+
100
+ MIT
@@ -0,0 +1,9 @@
1
+ export interface ServerConfig {
2
+ tavilyApiKey: string;
3
+ defaultMaxResults: number;
4
+ defaultSearchDepth: "advanced" | "basic" | "fast" | "ultra-fast";
5
+ cacheTtl: number;
6
+ serverName: string;
7
+ serverVersion: string;
8
+ }
9
+ export declare function loadConfig(): ServerConfig;
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ function getPackageVersion() {
6
+ try {
7
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf-8"));
8
+ return pkg.version ?? "0.0.0";
9
+ }
10
+ catch {
11
+ return "0.0.0";
12
+ }
13
+ }
14
+ export function loadConfig() {
15
+ const tavilyApiKey = process.env.TAVILY_API_KEY;
16
+ if (!tavilyApiKey) {
17
+ throw new Error("TAVILY_API_KEY environment variable is required. Get one at https://tavily.com");
18
+ }
19
+ const defaultMaxResults = parseInt(process.env.WEBTOOLS_MAX_RESULTS ?? "5", 10);
20
+ if (Number.isNaN(defaultMaxResults) || defaultMaxResults < 1) {
21
+ throw new Error(`Invalid WEBTOOLS_MAX_RESULTS value: "${process.env.WEBTOOLS_MAX_RESULTS}". Must be a positive integer.`);
22
+ }
23
+ const defaultSearchDepth = (process.env.WEBTOOLS_SEARCH_DEPTH ?? "basic");
24
+ const cacheTtl = parseInt(process.env.WEBTOOLS_CACHE_TTL ?? "3600", 10);
25
+ if (Number.isNaN(cacheTtl) || cacheTtl < 0) {
26
+ throw new Error(`Invalid WEBTOOLS_CACHE_TTL value: "${process.env.WEBTOOLS_CACHE_TTL}". Must be a non-negative integer (seconds).`);
27
+ }
28
+ return {
29
+ tavilyApiKey,
30
+ defaultMaxResults,
31
+ defaultSearchDepth,
32
+ cacheTtl,
33
+ serverName: "mcp-web-tools",
34
+ serverVersion: getPackageVersion(),
35
+ };
36
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/build/index.js ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { loadConfig } from "./config.js";
5
+ import { registerWebSearchTool } from "./tools/web-search/index.js";
6
+ import { registerWebReaderTool } from "./tools/web-reader/index.js";
7
+ import { registerCreditBalanceTool } from "./tools/credit-balance/index.js";
8
+ async function main() {
9
+ const config = loadConfig();
10
+ const server = new McpServer({
11
+ name: config.serverName,
12
+ version: config.serverVersion,
13
+ });
14
+ registerWebSearchTool(server, config);
15
+ registerWebReaderTool(server, config);
16
+ registerCreditBalanceTool(server, config);
17
+ const transport = new StdioServerTransport();
18
+ await server.connect(transport);
19
+ }
20
+ main().catch((error) => {
21
+ console.error("Fatal error:", error);
22
+ process.exit(1);
23
+ });
@@ -0,0 +1,29 @@
1
+ import type { SearchResponse, ExtractResponse } from "../types.js";
2
+ /** Parameters common to all search providers */
3
+ export interface SearchParams {
4
+ query: string;
5
+ maxResults?: number;
6
+ searchDepth?: "advanced" | "basic" | "fast" | "ultra-fast";
7
+ topic?: "general" | "news" | "finance";
8
+ timeRange?: "day" | "week" | "month" | "year";
9
+ startDate?: string;
10
+ endDate?: string;
11
+ includeDomains?: string[];
12
+ excludeDomains?: string[];
13
+ includeAnswer?: boolean;
14
+ }
15
+ /** Parameters for extract providers */
16
+ export interface ExtractParams {
17
+ urls: string[];
18
+ extractDepth?: "basic" | "advanced";
19
+ includeImages?: boolean;
20
+ }
21
+ /**
22
+ * Interface that every search provider must implement.
23
+ * Tavily is the first; Brave, SearXNG, Google can follow.
24
+ */
25
+ export interface SearchProvider {
26
+ readonly name: string;
27
+ search(params: SearchParams): Promise<SearchResponse>;
28
+ extract?(params: ExtractParams): Promise<ExtractResponse>;
29
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
1
+ /** Tavily API error types */
2
+ export declare class TavilyError extends Error {
3
+ readonly status: number;
4
+ constructor(message: string, status: number);
5
+ }
6
+ /** Raw Tavily search API response */
7
+ export interface TavilySearchResponse {
8
+ query: string;
9
+ answer?: string;
10
+ results: {
11
+ title: string;
12
+ url: string;
13
+ content: string;
14
+ score: number;
15
+ }[];
16
+ response_time?: number;
17
+ }
18
+ /** Raw Tavily extract API response */
19
+ export interface TavilyExtractResponse {
20
+ results: {
21
+ url: string;
22
+ raw_content: string;
23
+ images?: string[];
24
+ }[];
25
+ failed_results: {
26
+ url: string;
27
+ error: string;
28
+ }[];
29
+ response_time?: number;
30
+ }
31
+ /** Parameters for the Tavily extract request body */
32
+ export interface TavilyExtractParams {
33
+ urls: string[];
34
+ extract_depth?: "basic" | "advanced";
35
+ include_images?: boolean;
36
+ }
37
+ /** Parameters for the Tavily search request body */
38
+ export interface TavilySearchParams {
39
+ query: string;
40
+ search_depth?: "advanced" | "basic" | "fast" | "ultra-fast";
41
+ max_results?: number;
42
+ topic?: "general" | "news" | "finance";
43
+ time_range?: "day" | "week" | "month" | "year";
44
+ start_date?: string;
45
+ end_date?: string;
46
+ include_answer?: boolean;
47
+ include_domains?: string[];
48
+ exclude_domains?: string[];
49
+ }
50
+ /** Usage response from Tavily API */
51
+ export interface TavilyUsageResponse {
52
+ key: {
53
+ usage: number;
54
+ limit: number;
55
+ search_usage: number;
56
+ extract_usage: number;
57
+ crawl_usage: number;
58
+ map_usage: number;
59
+ research_usage: number;
60
+ };
61
+ account: {
62
+ current_plan: string;
63
+ plan_usage: number;
64
+ plan_limit: number;
65
+ paygo_usage: number;
66
+ paygo_limit: number;
67
+ search_usage: number;
68
+ extract_usage: number;
69
+ crawl_usage: number;
70
+ map_usage: number;
71
+ research_usage: number;
72
+ };
73
+ }
74
+ /**
75
+ * Low-level HTTP client for the Tavily Search API.
76
+ */
77
+ export declare class TavilyClient {
78
+ private readonly baseUrl;
79
+ private readonly apiKey;
80
+ private readonly timeout;
81
+ constructor(apiKey: string, timeout?: number);
82
+ private request;
83
+ search(params: TavilySearchParams): Promise<TavilySearchResponse>;
84
+ extract(params: TavilyExtractParams): Promise<TavilyExtractResponse>;
85
+ getUsage(): Promise<TavilyUsageResponse>;
86
+ }
87
+ /** Debug logger -- outputs to stderr when WEBTOOLS_DEBUG is set */
88
+ export declare function log(message: string): void;
@@ -0,0 +1,122 @@
1
+ /** Tavily API error types */
2
+ export class TavilyError extends Error {
3
+ status;
4
+ constructor(message, status) {
5
+ super(message);
6
+ this.status = status;
7
+ this.name = "TavilyError";
8
+ }
9
+ }
10
+ const MAX_RETRIES = 3;
11
+ const BASE_DELAY_MS = 1000;
12
+ function isRetryable(status) {
13
+ return status === 429 || status >= 500;
14
+ }
15
+ /**
16
+ * Low-level HTTP client for the Tavily Search API.
17
+ */
18
+ export class TavilyClient {
19
+ baseUrl = "https://api.tavily.com";
20
+ apiKey;
21
+ timeout;
22
+ constructor(apiKey, timeout = 30_000) {
23
+ this.apiKey = apiKey;
24
+ this.timeout = timeout;
25
+ }
26
+ async request(endpoint, body) {
27
+ let lastError;
28
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
29
+ if (attempt > 0) {
30
+ const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
31
+ log(`retry ${attempt}/${MAX_RETRIES} after ${delay}ms`);
32
+ await sleep(delay);
33
+ }
34
+ const controller = new AbortController();
35
+ const timer = setTimeout(() => controller.abort(), this.timeout);
36
+ try {
37
+ const response = await fetch(`${this.baseUrl}${endpoint}`, {
38
+ method: "POST",
39
+ headers: {
40
+ "Content-Type": "application/json",
41
+ Authorization: `Bearer ${this.apiKey}`,
42
+ },
43
+ body: JSON.stringify(body),
44
+ signal: controller.signal,
45
+ });
46
+ if (!response.ok) {
47
+ const text = await response.text().catch(() => "");
48
+ const msg = response.status === 401
49
+ ? `Invalid Tavily API key. ${text}`
50
+ : response.status === 429
51
+ ? `Tavily API rate limit exceeded. You may have used your monthly quota. ${text}`
52
+ : `Tavily API error (${response.status}): ${text}`;
53
+ lastError = new TavilyError(msg, response.status);
54
+ if (isRetryable(response.status) && attempt < MAX_RETRIES) {
55
+ continue;
56
+ }
57
+ throw lastError;
58
+ }
59
+ return (await response.json());
60
+ }
61
+ catch (error) {
62
+ if (error instanceof TavilyError) {
63
+ if (isRetryable(error.status) && attempt < MAX_RETRIES) {
64
+ lastError = error;
65
+ continue;
66
+ }
67
+ throw error;
68
+ }
69
+ if (error instanceof Error && error.name === "AbortError") {
70
+ throw new TavilyError("Tavily API request timed out", 408);
71
+ }
72
+ throw new TavilyError(`Tavily API request failed: ${error instanceof Error ? error.message : String(error)}`, 500);
73
+ }
74
+ finally {
75
+ clearTimeout(timer);
76
+ }
77
+ }
78
+ throw lastError;
79
+ }
80
+ async search(params) {
81
+ return this.request("/search", params);
82
+ }
83
+ async extract(params) {
84
+ return this.request("/extract", params);
85
+ }
86
+ async getUsage() {
87
+ const controller = new AbortController();
88
+ const timer = setTimeout(() => controller.abort(), this.timeout);
89
+ try {
90
+ const response = await fetch(`${this.baseUrl}/usage`, {
91
+ method: "GET",
92
+ headers: {
93
+ Authorization: `Bearer ${this.apiKey}`,
94
+ },
95
+ signal: controller.signal,
96
+ });
97
+ if (!response.ok) {
98
+ const text = await response.text().catch(() => "");
99
+ throw new TavilyError(`Failed to fetch usage (${response.status}): ${text}`, response.status);
100
+ }
101
+ return (await response.json());
102
+ }
103
+ catch (error) {
104
+ if (error instanceof TavilyError)
105
+ throw error;
106
+ throw new TavilyError(`Failed to fetch usage: ${error instanceof Error ? error.message : String(error)}`, 500);
107
+ }
108
+ finally {
109
+ clearTimeout(timer);
110
+ }
111
+ }
112
+ }
113
+ function sleep(ms) {
114
+ return new Promise((resolve) => setTimeout(resolve, ms));
115
+ }
116
+ /** Debug logger -- outputs to stderr when WEBTOOLS_DEBUG is set */
117
+ export function log(message) {
118
+ if (process.env.WEBTOOLS_DEBUG) {
119
+ const ts = new Date().toISOString().slice(11, 19);
120
+ process.stderr.write(`[webtools ${ts}] ${message}\n`);
121
+ }
122
+ }
@@ -0,0 +1,14 @@
1
+ import type { SearchResponse, ExtractResponse } from "../../types.js";
2
+ import type { SearchProvider, SearchParams, ExtractParams } from "../search-provider.js";
3
+ import { TavilyError } from "./client/index.js";
4
+ /**
5
+ * Tavily implementation of the SearchProvider interface.
6
+ */
7
+ export declare class TavilySearchProvider implements SearchProvider {
8
+ readonly name = "tavily";
9
+ private readonly client;
10
+ constructor(apiKey: string);
11
+ search(params: SearchParams): Promise<SearchResponse>;
12
+ extract(params: ExtractParams): Promise<ExtractResponse>;
13
+ }
14
+ export { TavilyError };
@@ -0,0 +1,57 @@
1
+ import { TavilyClient, TavilyError } from "./client/index.js";
2
+ /**
3
+ * Tavily implementation of the SearchProvider interface.
4
+ */
5
+ export class TavilySearchProvider {
6
+ name = "tavily";
7
+ client;
8
+ constructor(apiKey) {
9
+ this.client = new TavilyClient(apiKey);
10
+ }
11
+ async search(params) {
12
+ const response = await this.client.search({
13
+ query: params.query,
14
+ search_depth: params.searchDepth,
15
+ max_results: params.maxResults,
16
+ topic: params.topic,
17
+ time_range: params.timeRange,
18
+ start_date: params.startDate,
19
+ end_date: params.endDate,
20
+ include_answer: params.includeAnswer ?? true,
21
+ include_domains: params.includeDomains,
22
+ exclude_domains: params.excludeDomains,
23
+ });
24
+ return {
25
+ query: response.query,
26
+ answer: response.answer,
27
+ results: response.results.map((r) => ({
28
+ title: r.title,
29
+ url: r.url,
30
+ snippet: r.content,
31
+ score: r.score,
32
+ })),
33
+ responseTime: response.response_time,
34
+ };
35
+ }
36
+ async extract(params) {
37
+ const response = await this.client.extract({
38
+ urls: params.urls,
39
+ extract_depth: params.extractDepth,
40
+ include_images: params.includeImages,
41
+ });
42
+ return {
43
+ results: response.results.map((r) => ({
44
+ url: r.url,
45
+ content: r.raw_content,
46
+ images: r.images,
47
+ })),
48
+ failedResults: response.failed_results.map((f) => ({
49
+ url: f.url,
50
+ error: f.error,
51
+ })),
52
+ responseTime: response.response_time,
53
+ };
54
+ }
55
+ }
56
+ // Re-export for convenience
57
+ export { TavilyError };
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ServerConfig } from "../../config.js";
3
+ export declare function registerCreditBalanceTool(server: McpServer, config: ServerConfig): void;
@@ -0,0 +1,37 @@
1
+ import { TavilyClient, TavilyError } from "../../providers/tavily/client/index.js";
2
+ export function registerCreditBalanceTool(server, config) {
3
+ const client = new TavilyClient(config.tavilyApiKey);
4
+ server.tool("credit_balance", "Check your Tavily API credit balance.", {}, async () => {
5
+ try {
6
+ const usage = await client.getUsage();
7
+ const remaining = usage.account.plan_limit - usage.account.plan_usage;
8
+ const percentUsed = usage.account.plan_limit > 0 ? Math.round((usage.account.plan_usage / usage.account.plan_limit) * 100) : 0;
9
+ const lines = [
10
+ `Plan: ${usage.account.current_plan} — ${usage.account.plan_usage} / ${usage.account.plan_limit} credits (${percentUsed}% used)`,
11
+ `Remaining: ${remaining}`,
12
+ `Breakdown — search: ${usage.account.search_usage}, extract: ${usage.account.extract_usage}`,
13
+ ];
14
+ if (remaining < 50) {
15
+ lines.push("Credits running low. Top up at https://tavily.com");
16
+ }
17
+ return { content: [{ type: "text", text: lines.join("\n") }] };
18
+ }
19
+ catch (error) {
20
+ if (error instanceof TavilyError) {
21
+ return {
22
+ content: [{ type: "text", text: `Credit balance error: ${error.message}` }],
23
+ isError: true,
24
+ };
25
+ }
26
+ return {
27
+ content: [
28
+ {
29
+ type: "text",
30
+ text: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
31
+ },
32
+ ],
33
+ isError: true,
34
+ };
35
+ }
36
+ });
37
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ServerConfig } from "../../config.js";
3
+ export declare function registerWebReaderTool(server: McpServer, config: ServerConfig): void;
@@ -0,0 +1,82 @@
1
+ import { z } from "zod";
2
+ import { TavilySearchProvider, TavilyError } from "../../providers/tavily/tavily-search.js";
3
+ import { log } from "../../providers/tavily/client/index.js";
4
+ import { formatExtractResponse } from "../../utils/format/index.js";
5
+ import { cacheKey, cacheGet, cacheSet } from "../../utils/cache/index.js";
6
+ const webReaderSchema = {
7
+ urls: z.array(z.string()).min(1).max(20).describe("URLs to extract content from (1-20)"),
8
+ extractDepth: z
9
+ .enum(["basic", "advanced"])
10
+ .optional()
11
+ .describe("Extraction depth: basic (1 credit per 5 URLs, returns raw page HTML as markdown including navigation and boilerplate) or advanced (2 credits per 5 URLs, extracts clean article content with navigation/ads/sidebar/footer stripped)"),
12
+ includeImages: z.boolean().optional().describe("Include extracted image URLs (default: false)"),
13
+ };
14
+ export function registerWebReaderTool(server, config) {
15
+ const provider = new TavilySearchProvider(config.tavilyApiKey);
16
+ server.tool("web_read", "Extract clean content from web pages. Returns page text stripped of navigation, ads, and scripts. Supports up to 20 URLs per request.", webReaderSchema, async (params) => {
17
+ try {
18
+ // URL validation
19
+ for (const url of params.urls) {
20
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
21
+ return {
22
+ content: [{ type: "text", text: `Invalid URL: "${url}". Must start with http:// or https://.` }],
23
+ isError: true,
24
+ };
25
+ }
26
+ try {
27
+ new URL(url);
28
+ }
29
+ catch {
30
+ return {
31
+ content: [{ type: "text", text: `Invalid URL format: "${url}".` }],
32
+ isError: true,
33
+ };
34
+ }
35
+ }
36
+ // Cache lookup
37
+ const extractParams = {
38
+ urls: params.urls,
39
+ extractDepth: params.extractDepth,
40
+ includeImages: params.includeImages,
41
+ };
42
+ const key = cacheKey("extract", extractParams);
43
+ const cached = cacheGet(key);
44
+ if (cached) {
45
+ log("cache hit: extract");
46
+ const text = formatExtractResponse(cached);
47
+ return { content: [{ type: "text", text }] };
48
+ }
49
+ log(`cache miss: extract ${params.urls.length} url(s)`);
50
+ // Non-null assertion fix
51
+ if (!provider.extract) {
52
+ return {
53
+ content: [{ type: "text", text: "Extract not supported by current provider." }],
54
+ isError: true,
55
+ };
56
+ }
57
+ const response = await provider.extract(extractParams);
58
+ cacheSet(key, response, config.cacheTtl);
59
+ const text = formatExtractResponse(response);
60
+ return {
61
+ content: [{ type: "text", text }],
62
+ };
63
+ }
64
+ catch (error) {
65
+ if (error instanceof TavilyError) {
66
+ return {
67
+ content: [{ type: "text", text: `Extract error: ${error.message}` }],
68
+ isError: true,
69
+ };
70
+ }
71
+ return {
72
+ content: [
73
+ {
74
+ type: "text",
75
+ text: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
76
+ },
77
+ ],
78
+ isError: true,
79
+ };
80
+ }
81
+ });
82
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ServerConfig } from "../../config.js";
3
+ export declare function registerWebSearchTool(server: McpServer, config: ServerConfig): void;
@@ -0,0 +1,78 @@
1
+ import { z } from "zod";
2
+ import { TavilySearchProvider, TavilyError } from "../../providers/tavily/tavily-search.js";
3
+ import { log } from "../../providers/tavily/client/index.js";
4
+ import { formatSearchResponse } from "../../utils/format/index.js";
5
+ import { cacheKey, cacheGet, cacheSet } from "../../utils/cache/index.js";
6
+ const webSearchSchema = {
7
+ query: z.string().describe("The search query"),
8
+ maxResults: z.number().min(1).max(20).optional().describe("Maximum number of results (default: 5)"),
9
+ searchDepth: z
10
+ .enum(["advanced", "basic", "fast", "ultra-fast"])
11
+ .optional()
12
+ .describe("Search depth: basic/fast/ultra-fast (1 credit) or advanced (2 credits)"),
13
+ topic: z.enum(["general", "news", "finance"]).optional().describe("Search topic category"),
14
+ timeRange: z.enum(["day", "week", "month", "year"]).optional().describe("Time range filter for results"),
15
+ startDate: z
16
+ .string()
17
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format")
18
+ .optional()
19
+ .describe("Start date filter (YYYY-MM-DD)"),
20
+ endDate: z
21
+ .string()
22
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format")
23
+ .optional()
24
+ .describe("End date filter (YYYY-MM-DD)"),
25
+ includeDomains: z.array(z.string()).optional().describe("Only include results from these domains"),
26
+ excludeDomains: z.array(z.string()).optional().describe("Exclude results from these domains"),
27
+ includeAnswer: z.boolean().optional().describe("Include an AI-generated answer (default: true)"),
28
+ };
29
+ export function registerWebSearchTool(server, config) {
30
+ const provider = new TavilySearchProvider(config.tavilyApiKey);
31
+ server.tool("web_search", "Search the web using Tavily. Returns results with titles, URLs, and snippets. Supports filtering by domain, topic, time range, and search depth.", webSearchSchema, async (params) => {
32
+ try {
33
+ const searchParams = {
34
+ query: params.query,
35
+ maxResults: params.maxResults ?? config.defaultMaxResults,
36
+ searchDepth: params.searchDepth ?? config.defaultSearchDepth,
37
+ topic: params.topic,
38
+ timeRange: params.timeRange,
39
+ startDate: params.startDate,
40
+ endDate: params.endDate,
41
+ includeDomains: params.includeDomains,
42
+ excludeDomains: params.excludeDomains,
43
+ includeAnswer: params.includeAnswer ?? true,
44
+ };
45
+ const key = cacheKey("search", searchParams);
46
+ const cached = cacheGet(key);
47
+ if (cached) {
48
+ log("cache hit: search");
49
+ const text = formatSearchResponse(cached);
50
+ return { content: [{ type: "text", text }] };
51
+ }
52
+ log(`cache miss: search "${params.query}"`);
53
+ const response = await provider.search(searchParams);
54
+ cacheSet(key, response, config.cacheTtl);
55
+ const text = formatSearchResponse(response);
56
+ return {
57
+ content: [{ type: "text", text }],
58
+ };
59
+ }
60
+ catch (error) {
61
+ if (error instanceof TavilyError) {
62
+ return {
63
+ content: [{ type: "text", text: `Search error: ${error.message}` }],
64
+ isError: true,
65
+ };
66
+ }
67
+ return {
68
+ content: [
69
+ {
70
+ type: "text",
71
+ text: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
72
+ },
73
+ ],
74
+ isError: true,
75
+ };
76
+ }
77
+ });
78
+ }
@@ -0,0 +1,29 @@
1
+ /** Normalized search result from any provider */
2
+ export interface SearchResult {
3
+ title: string;
4
+ url: string;
5
+ snippet: string;
6
+ score?: number;
7
+ }
8
+ /** Normalized search response */
9
+ export interface SearchResponse {
10
+ query: string;
11
+ answer?: string;
12
+ results: SearchResult[];
13
+ responseTime?: number;
14
+ }
15
+ /** Normalized extract result */
16
+ export interface ExtractResult {
17
+ url: string;
18
+ content: string;
19
+ images?: string[];
20
+ }
21
+ /** Normalized extract response */
22
+ export interface ExtractResponse {
23
+ results: ExtractResult[];
24
+ failedResults: {
25
+ url: string;
26
+ error: string;
27
+ }[];
28
+ responseTime?: number;
29
+ }
package/build/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ /** Serialize params with sorted keys for deterministic cache keys. */
2
+ export declare function cacheKey(prefix: string, params: unknown): string;
3
+ /** Get a cached value. Returns undefined on miss or if expired. */
4
+ export declare function cacheGet<T>(key: string): T | undefined;
5
+ /** Store a value with the given TTL in seconds. */
6
+ export declare function cacheSet(key: string, data: unknown, ttlSeconds: number): void;
@@ -0,0 +1,23 @@
1
+ const store = new Map();
2
+ /** Serialize params with sorted keys for deterministic cache keys. */
3
+ export function cacheKey(prefix, params) {
4
+ const sorted = JSON.stringify(params, Object.keys(params).sort());
5
+ return `${prefix}:${sorted}`;
6
+ }
7
+ /** Get a cached value. Returns undefined on miss or if expired. */
8
+ export function cacheGet(key) {
9
+ const entry = store.get(key);
10
+ if (!entry)
11
+ return undefined;
12
+ if (Date.now() > entry.expires) {
13
+ store.delete(key);
14
+ return undefined;
15
+ }
16
+ return entry.data;
17
+ }
18
+ /** Store a value with the given TTL in seconds. */
19
+ export function cacheSet(key, data, ttlSeconds) {
20
+ if (ttlSeconds <= 0)
21
+ return;
22
+ store.set(key, { data, expires: Date.now() + ttlSeconds * 1000 });
23
+ }
@@ -0,0 +1,9 @@
1
+ import type { SearchResponse, ExtractResponse } from "../../types.js";
2
+ /**
3
+ * Formats a SearchResponse into a markdown string suitable for MCP tool output.
4
+ */
5
+ export declare function formatSearchResponse(response: SearchResponse): string;
6
+ /**
7
+ * Formats an ExtractResponse into a markdown string suitable for MCP tool output.
8
+ */
9
+ export declare function formatExtractResponse(response: ExtractResponse): string;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Formats a SearchResponse into a markdown string suitable for MCP tool output.
3
+ */
4
+ export function formatSearchResponse(response) {
5
+ const parts = [];
6
+ if (response.answer) {
7
+ parts.push(response.answer);
8
+ parts.push("");
9
+ }
10
+ for (let i = 0; i < response.results.length; i++) {
11
+ const result = response.results[i];
12
+ parts.push(`### ${i + 1}. [${result.title}](${result.url})`);
13
+ parts.push(`> ${result.snippet}`);
14
+ parts.push("");
15
+ }
16
+ if (response.responseTime != null) {
17
+ parts.push(`*Response time: ${response.responseTime}ms*`);
18
+ parts.push("");
19
+ }
20
+ parts.push("---");
21
+ parts.push("");
22
+ parts.push("Sources:");
23
+ for (const result of response.results) {
24
+ parts.push(`- [${result.title}](${result.url})`);
25
+ }
26
+ return parts.join("\n");
27
+ }
28
+ /**
29
+ * Formats an ExtractResponse into a markdown string suitable for MCP tool output.
30
+ */
31
+ export function formatExtractResponse(response) {
32
+ const parts = [];
33
+ for (const result of response.results) {
34
+ parts.push(`## ${result.url}`);
35
+ parts.push("");
36
+ parts.push(result.content);
37
+ parts.push("");
38
+ }
39
+ if (response.failedResults.length > 0) {
40
+ parts.push("---");
41
+ parts.push("");
42
+ parts.push("Failed:");
43
+ for (const failed of response.failedResults) {
44
+ parts.push(`- ${failed.url}: ${failed.error}`);
45
+ }
46
+ parts.push("");
47
+ }
48
+ if (response.responseTime != null) {
49
+ parts.push(`*Response time: ${response.responseTime}ms*`);
50
+ parts.push("");
51
+ }
52
+ return parts.join("\n");
53
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "mcp-web-tools",
3
+ "version": "0.2.6",
4
+ "description": "MCP server providing web search tools for AI assistants",
5
+ "type": "module",
6
+ "main": "build/index.js",
7
+ "bin": {
8
+ "mcp-web-tools": "./build/index.js"
9
+ },
10
+ "files": [
11
+ "build",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc && chmod 755 build/index.js",
17
+ "dev": "tsx src/index.ts",
18
+ "start": "node build/index.js",
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "lint": "eslint src/",
23
+ "lint:fix": "eslint src/ --fix",
24
+ "format": "prettier --write \"src/**/*.ts\"",
25
+ "format:check": "prettier --check \"src/**/*.ts\"",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/paifas/mcp-web-tools.git"
31
+ },
32
+ "homepage": "https://github.com/paifas/mcp-web-tools#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/paifas/mcp-web-tools/issues"
35
+ },
36
+ "keywords": [
37
+ "mcp",
38
+ "model-context-protocol",
39
+ "web-search",
40
+ "tavily",
41
+ "ai",
42
+ "claude",
43
+ "opencode"
44
+ ],
45
+ "license": "MIT",
46
+ "engines": {
47
+ "node": ">=18.0.0"
48
+ },
49
+ "dependencies": {
50
+ "@modelcontextprotocol/sdk": "^1.29.0",
51
+ "zod": "^3.25.76"
52
+ },
53
+ "devDependencies": {
54
+ "@eslint/js": "^10.0.1",
55
+ "@types/node": "^25.5.2",
56
+ "@vitest/coverage-v8": "^4.1.4",
57
+ "eslint": "^10.2.0",
58
+ "eslint-config-prettier": "^10.1.8",
59
+ "prettier": "^3.8.1",
60
+ "tsx": "^4.21.0",
61
+ "typescript": "^6.0.2",
62
+ "typescript-eslint": "^8.58.1",
63
+ "vitest": "^4.1.4"
64
+ }
65
+ }