@anyshift/mcp-proxy 0.5.0 → 0.6.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.
@@ -0,0 +1,29 @@
1
+ import type { DocsConfig } from './types.js';
2
+ /**
3
+ * Execute docs_glob to find files matching a pattern
4
+ * Results are filtered against the indexed paths to ensure consistency with docs_read
5
+ */
6
+ export declare function executeDocsGlob(config: DocsConfig, pattern: string): Promise<{
7
+ content: Array<{
8
+ type: 'text';
9
+ text: string;
10
+ }>;
11
+ }>;
12
+ /**
13
+ * Execute docs_grep to search documentation content
14
+ */
15
+ export declare function executeDocsGrep(config: DocsConfig, query: string, maxResults?: number): Promise<{
16
+ content: Array<{
17
+ type: 'text';
18
+ text: string;
19
+ }>;
20
+ }>;
21
+ /**
22
+ * Execute docs_read to read a documentation file
23
+ */
24
+ export declare function executeDocsRead(config: DocsConfig, relativePath: string): Promise<{
25
+ content: Array<{
26
+ type: 'text';
27
+ text: string;
28
+ }>;
29
+ }>;
@@ -0,0 +1,192 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { glob } from 'glob';
4
+ // Maximum file size to read (5MB) - prevents DoS with large files
5
+ const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
6
+ /**
7
+ * Validate that the docs base path exists and is a directory
8
+ */
9
+ function validateDocsBasePath(docsBasePath) {
10
+ if (!path.isAbsolute(docsBasePath)) {
11
+ throw new Error(`Docs base path must be absolute: ${docsBasePath}`);
12
+ }
13
+ if (!fs.existsSync(docsBasePath)) {
14
+ throw new Error(`Docs base path does not exist: ${docsBasePath}`);
15
+ }
16
+ const stat = fs.statSync(docsBasePath);
17
+ if (!stat.isDirectory()) {
18
+ throw new Error(`Docs base path is not a directory: ${docsBasePath}`);
19
+ }
20
+ }
21
+ /**
22
+ * Validate that a path is strictly within the docs base directory (prevent path traversal).
23
+ * Also resolves symlinks to prevent symlink-based escapes.
24
+ * Returns the real (symlink-resolved) absolute path.
25
+ */
26
+ function validatePathWithinDocs(filePath, docsBasePath) {
27
+ // Resolve to absolute path
28
+ const absolutePath = path.resolve(docsBasePath, filePath);
29
+ // Get the real path of the base directory (resolve any symlinks)
30
+ const realBase = fs.realpathSync(docsBasePath);
31
+ // Ensure the resolved path is strictly within the docs directory (not equal to it)
32
+ if (absolutePath === realBase || !absolutePath.startsWith(realBase + path.sep)) {
33
+ throw new Error(`Path traversal detected: ${filePath} resolves outside docs directory`);
34
+ }
35
+ // If the file exists, resolve symlinks and validate the real path
36
+ if (fs.existsSync(absolutePath)) {
37
+ const realPath = fs.realpathSync(absolutePath);
38
+ if (!realPath.startsWith(realBase + path.sep)) {
39
+ throw new Error(`Symlink escape detected: ${filePath} points outside docs directory`);
40
+ }
41
+ return realPath;
42
+ }
43
+ return absolutePath;
44
+ }
45
+ /**
46
+ * Validate that a relative path doesn't contain path traversal sequences.
47
+ * Used to validate indexed paths before use.
48
+ */
49
+ function isValidRelativePath(relativePath) {
50
+ // Reject paths with traversal sequences
51
+ const normalized = path.normalize(relativePath);
52
+ return !normalized.startsWith('..') && !path.isAbsolute(normalized);
53
+ }
54
+ /**
55
+ * Execute docs_glob to find files matching a pattern
56
+ * Results are filtered against the indexed paths to ensure consistency with docs_read
57
+ */
58
+ export async function executeDocsGlob(config, pattern) {
59
+ validateDocsBasePath(config.docsBasePath);
60
+ // Execute glob within the docs directory
61
+ const globMatches = await glob(pattern, {
62
+ cwd: config.docsBasePath,
63
+ nodir: true, // Only match files, not directories
64
+ dot: false, // Don't match hidden files
65
+ });
66
+ // Filter to only include paths that are in the index (ensures docs_read will accept them)
67
+ const indexedSet = new Set(config.indexedPaths);
68
+ const matches = globMatches.filter((p) => indexedSet.has(p));
69
+ // Sort matches alphabetically for consistent results
70
+ matches.sort();
71
+ const result = {
72
+ matchingPaths: matches,
73
+ totalMatches: matches.length,
74
+ };
75
+ const output = matches.length > 0
76
+ ? `Found ${matches.length} matching file(s):\n${matches.map((p) => ` - ${p}`).join('\n')}`
77
+ : `No files found matching pattern: ${pattern}`;
78
+ return {
79
+ content: [{ type: 'text', text: output }],
80
+ };
81
+ }
82
+ /**
83
+ * Execute docs_grep to search documentation content
84
+ */
85
+ export async function executeDocsGrep(config, query, maxResults = 50) {
86
+ validateDocsBasePath(config.docsBasePath);
87
+ if (!query || query.trim() === '') {
88
+ throw new Error('Search query cannot be empty');
89
+ }
90
+ const searchQuery = query.toLowerCase();
91
+ const matches = [];
92
+ // Search through all indexed paths
93
+ for (const relativePath of config.indexedPaths) {
94
+ // Validate indexed path doesn't contain traversal sequences
95
+ if (!isValidRelativePath(relativePath)) {
96
+ continue; // Skip potentially malicious paths
97
+ }
98
+ const absolutePath = path.join(config.docsBasePath, relativePath);
99
+ // Skip if file doesn't exist
100
+ if (!fs.existsSync(absolutePath)) {
101
+ continue;
102
+ }
103
+ try {
104
+ // Check file size to prevent DoS
105
+ const stat = fs.statSync(absolutePath);
106
+ if (stat.size > MAX_FILE_SIZE_BYTES) {
107
+ console.warn(`[docs] Skipping large file during grep: ${relativePath} (${Math.round(stat.size / 1024)}KB exceeds ${MAX_FILE_SIZE_BYTES / 1024}KB limit)`);
108
+ continue; // Skip files that are too large
109
+ }
110
+ const content = fs.readFileSync(absolutePath, 'utf-8');
111
+ const lines = content.split('\n');
112
+ for (let i = 0; i < lines.length; i++) {
113
+ if (lines[i].toLowerCase().includes(searchQuery)) {
114
+ matches.push({
115
+ path: relativePath,
116
+ lineNumber: i + 1,
117
+ lineContent: lines[i].trim().substring(0, 200), // Truncate long lines
118
+ });
119
+ if (matches.length >= maxResults) {
120
+ break;
121
+ }
122
+ }
123
+ }
124
+ if (matches.length >= maxResults) {
125
+ break;
126
+ }
127
+ }
128
+ catch {
129
+ // Skip files that can't be read
130
+ continue;
131
+ }
132
+ }
133
+ const result = {
134
+ matches,
135
+ totalMatches: matches.length,
136
+ truncated: matches.length >= maxResults,
137
+ };
138
+ let output;
139
+ if (matches.length === 0) {
140
+ output = `No matches found for query: "${query}"`;
141
+ }
142
+ else {
143
+ const lines = matches.map((m) => `${m.path}:${m.lineNumber}: ${m.lineContent}`);
144
+ output = `Found ${matches.length} match(es)${result.truncated ? ' (truncated)' : ''}:\n\n${lines.join('\n')}`;
145
+ }
146
+ return {
147
+ content: [{ type: 'text', text: output }],
148
+ };
149
+ }
150
+ /**
151
+ * Execute docs_read to read a documentation file
152
+ */
153
+ export async function executeDocsRead(config, relativePath) {
154
+ validateDocsBasePath(config.docsBasePath);
155
+ if (!relativePath || relativePath.trim() === '') {
156
+ throw new Error('File path cannot be empty');
157
+ }
158
+ // Normalize the path
159
+ const normalizedPath = path.normalize(relativePath).replace(/^\/+/, '');
160
+ // Validate path doesn't contain traversal sequences
161
+ if (!isValidRelativePath(normalizedPath)) {
162
+ throw new Error(`Invalid path: ${relativePath}`);
163
+ }
164
+ // Check if the path is in the indexed paths
165
+ if (!config.indexedPaths.includes(normalizedPath)) {
166
+ throw new Error(`Path not in documentation index: ${relativePath}\n\n` +
167
+ `Use docs_glob to find valid paths. The documentation index contains ${config.indexedPaths.length} pages.`);
168
+ }
169
+ // Validate path is within docs directory and resolve symlinks
170
+ // This returns the real (symlink-resolved) path
171
+ const realPath = validatePathWithinDocs(normalizedPath, config.docsBasePath);
172
+ // Check if file exists
173
+ if (!fs.existsSync(realPath)) {
174
+ throw new Error(`Documentation file not found: ${relativePath}`);
175
+ }
176
+ // Check file size to prevent DoS
177
+ const stat = fs.statSync(realPath);
178
+ if (stat.size > MAX_FILE_SIZE_BYTES) {
179
+ throw new Error(`File too large: ${relativePath} (${Math.round(stat.size / 1024)}KB exceeds ${MAX_FILE_SIZE_BYTES / 1024}KB limit)`);
180
+ }
181
+ // Read the file using the validated real path
182
+ const content = fs.readFileSync(realPath, 'utf-8');
183
+ const lineCount = content.split('\n').length;
184
+ const result = {
185
+ path: normalizedPath,
186
+ content,
187
+ lineCount,
188
+ };
189
+ return {
190
+ content: [{ type: 'text', text: content }],
191
+ };
192
+ }
@@ -0,0 +1,37 @@
1
+ import type { CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
2
+ import { DOCS_GLOB_TOOL_DEFINITION, DOCS_GREP_TOOL_DEFINITION, DOCS_READ_TOOL_DEFINITION } from './tool.js';
3
+ import type { DocsConfig } from './types.js';
4
+ export type { DocsConfig } from './types.js';
5
+ export { DOCS_GLOB_TOOL_DEFINITION, DOCS_GREP_TOOL_DEFINITION, DOCS_READ_TOOL_DEFINITION, };
6
+ /**
7
+ * Tool definition with handler for MCP registration
8
+ */
9
+ export interface DocsTool {
10
+ toolDefinition: {
11
+ name: string;
12
+ description: string;
13
+ inputSchema: object;
14
+ };
15
+ handler: (request: CallToolRequest) => Promise<{
16
+ content: Array<{
17
+ type: 'text';
18
+ text: string;
19
+ }>;
20
+ }>;
21
+ }
22
+ /**
23
+ * Create the docs_glob tool
24
+ */
25
+ export declare function createDocsGlobTool(config: DocsConfig): DocsTool;
26
+ /**
27
+ * Create the docs_grep tool
28
+ */
29
+ export declare function createDocsGrepTool(config: DocsConfig): DocsTool;
30
+ /**
31
+ * Create the docs_read tool
32
+ */
33
+ export declare function createDocsReadTool(config: DocsConfig): DocsTool;
34
+ /**
35
+ * Create all docs tools at once
36
+ */
37
+ export declare function createDocsTools(config: DocsConfig): DocsTool[];
@@ -0,0 +1,49 @@
1
+ import { DocsGlobSchema, DocsGrepSchema, DocsReadSchema, DOCS_GLOB_TOOL_DEFINITION, DOCS_GREP_TOOL_DEFINITION, DOCS_READ_TOOL_DEFINITION, } from './tool.js';
2
+ import { executeDocsGlob, executeDocsGrep, executeDocsRead } from './handler.js';
3
+ export { DOCS_GLOB_TOOL_DEFINITION, DOCS_GREP_TOOL_DEFINITION, DOCS_READ_TOOL_DEFINITION, };
4
+ /**
5
+ * Create the docs_glob tool
6
+ */
7
+ export function createDocsGlobTool(config) {
8
+ return {
9
+ toolDefinition: DOCS_GLOB_TOOL_DEFINITION,
10
+ handler: async (request) => {
11
+ const parsed = DocsGlobSchema.parse(request.params.arguments);
12
+ return executeDocsGlob(config, parsed.pattern);
13
+ },
14
+ };
15
+ }
16
+ /**
17
+ * Create the docs_grep tool
18
+ */
19
+ export function createDocsGrepTool(config) {
20
+ return {
21
+ toolDefinition: DOCS_GREP_TOOL_DEFINITION,
22
+ handler: async (request) => {
23
+ const parsed = DocsGrepSchema.parse(request.params.arguments);
24
+ return executeDocsGrep(config, parsed.query, parsed.maxResults);
25
+ },
26
+ };
27
+ }
28
+ /**
29
+ * Create the docs_read tool
30
+ */
31
+ export function createDocsReadTool(config) {
32
+ return {
33
+ toolDefinition: DOCS_READ_TOOL_DEFINITION,
34
+ handler: async (request) => {
35
+ const parsed = DocsReadSchema.parse(request.params.arguments);
36
+ return executeDocsRead(config, parsed.path);
37
+ },
38
+ };
39
+ }
40
+ /**
41
+ * Create all docs tools at once
42
+ */
43
+ export function createDocsTools(config) {
44
+ return [
45
+ createDocsGlobTool(config),
46
+ createDocsGrepTool(config),
47
+ createDocsReadTool(config),
48
+ ];
49
+ }
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod';
2
+ export declare const DocsGlobSchema: z.ZodObject<{
3
+ pattern: z.ZodString;
4
+ }, "strip", z.ZodTypeAny, {
5
+ pattern: string;
6
+ }, {
7
+ pattern: string;
8
+ }>;
9
+ export declare const DOCS_GLOB_TOOL_DEFINITION: {
10
+ name: string;
11
+ description: string;
12
+ inputSchema: {
13
+ type: string;
14
+ properties: {
15
+ pattern: {
16
+ type: string;
17
+ description: string;
18
+ };
19
+ };
20
+ required: string[];
21
+ };
22
+ };
23
+ export declare const DocsGrepSchema: z.ZodObject<{
24
+ query: z.ZodString;
25
+ maxResults: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
26
+ }, "strip", z.ZodTypeAny, {
27
+ query: string;
28
+ maxResults: number;
29
+ }, {
30
+ query: string;
31
+ maxResults?: number | undefined;
32
+ }>;
33
+ export declare const DOCS_GREP_TOOL_DEFINITION: {
34
+ name: string;
35
+ description: string;
36
+ inputSchema: {
37
+ type: string;
38
+ properties: {
39
+ query: {
40
+ type: string;
41
+ description: string;
42
+ };
43
+ maxResults: {
44
+ type: string;
45
+ description: string;
46
+ };
47
+ };
48
+ required: string[];
49
+ };
50
+ };
51
+ export declare const DocsReadSchema: z.ZodObject<{
52
+ path: z.ZodString;
53
+ }, "strip", z.ZodTypeAny, {
54
+ path: string;
55
+ }, {
56
+ path: string;
57
+ }>;
58
+ export declare const DOCS_READ_TOOL_DEFINITION: {
59
+ name: string;
60
+ description: string;
61
+ inputSchema: {
62
+ type: string;
63
+ properties: {
64
+ path: {
65
+ type: string;
66
+ description: string;
67
+ };
68
+ };
69
+ required: string[];
70
+ };
71
+ };
@@ -0,0 +1,105 @@
1
+ import { z } from 'zod';
2
+ // =============================================================================
3
+ // docs_glob - Find documentation files matching a pattern
4
+ // =============================================================================
5
+ export const DocsGlobSchema = z.object({
6
+ pattern: z
7
+ .string()
8
+ .describe('Glob pattern to match documentation files. Examples: "*.mdx", "integrations/**/*.mdx", "**/datadog*"'),
9
+ });
10
+ export const DOCS_GLOB_TOOL_DEFINITION = {
11
+ name: 'docs_glob',
12
+ description: `Find documentation files matching a glob pattern.
13
+
14
+ Use this tool to discover which documentation pages exist before reading them.
15
+
16
+ **Examples:**
17
+ - Find all MDX files: \`pattern: "**/*.mdx"\`
18
+ - Find integration docs: \`pattern: "integrations/**/*.mdx"\`
19
+ - Find pages mentioning datadog: \`pattern: "**/datadog*"\`
20
+ - Find all pages in a section: \`pattern: "setup/*.mdx"\`
21
+
22
+ **Returns:** List of matching file paths (relative to docs root).`,
23
+ inputSchema: {
24
+ type: 'object',
25
+ properties: {
26
+ pattern: {
27
+ type: 'string',
28
+ description: 'Glob pattern to match documentation files. Examples: "*.mdx", "integrations/**/*.mdx"',
29
+ },
30
+ },
31
+ required: ['pattern'],
32
+ },
33
+ };
34
+ // =============================================================================
35
+ // docs_grep - Search documentation content
36
+ // =============================================================================
37
+ export const DocsGrepSchema = z.object({
38
+ query: z
39
+ .string()
40
+ .describe('Search query (case-insensitive substring match). Examples: "datadog integration", "API key", "webhook"'),
41
+ maxResults: z
42
+ .number()
43
+ .optional()
44
+ .default(50)
45
+ .describe('Maximum number of matches to return. Default: 50'),
46
+ });
47
+ export const DOCS_GREP_TOOL_DEFINITION = {
48
+ name: 'docs_grep',
49
+ description: `Search documentation content for a query string.
50
+
51
+ Use this tool to find which documentation pages contain specific information.
52
+
53
+ **Examples:**
54
+ - Find pages about Datadog: \`query: "datadog"\`
55
+ - Find API setup instructions: \`query: "API key"\`
56
+ - Find webhook documentation: \`query: "webhook configuration"\`
57
+
58
+ **Returns:** List of matches with file path, line number, and line content.
59
+ Results are sorted by relevance (number of matches per file).`,
60
+ inputSchema: {
61
+ type: 'object',
62
+ properties: {
63
+ query: {
64
+ type: 'string',
65
+ description: 'Search query (case-insensitive substring match). Examples: "datadog", "API key"',
66
+ },
67
+ maxResults: {
68
+ type: 'number',
69
+ description: 'Maximum number of matches to return. Default: 50',
70
+ },
71
+ },
72
+ required: ['query'],
73
+ },
74
+ };
75
+ // =============================================================================
76
+ // docs_read - Read a documentation page
77
+ // =============================================================================
78
+ export const DocsReadSchema = z.object({
79
+ path: z
80
+ .string()
81
+ .describe('Path to the documentation file (relative to docs root). Must be an indexed path from docs_glob results.'),
82
+ });
83
+ export const DOCS_READ_TOOL_DEFINITION = {
84
+ name: 'docs_read',
85
+ description: `Read the content of a documentation page.
86
+
87
+ **Important:** You can only read paths that exist in the documentation index.
88
+ Use docs_glob first to find valid paths.
89
+
90
+ **Examples:**
91
+ - Read a specific page: \`path: "integrations/datadog.mdx"\`
92
+ - Read setup guide: \`path: "setup/getting-started.mdx"\`
93
+
94
+ **Returns:** Full content of the documentation page.`,
95
+ inputSchema: {
96
+ type: 'object',
97
+ properties: {
98
+ path: {
99
+ type: 'string',
100
+ description: 'Path to the documentation file (relative to docs root). Must be an indexed path.',
101
+ },
102
+ },
103
+ required: ['path'],
104
+ },
105
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Configuration for the docs MCP tools
3
+ */
4
+ export interface DocsConfig {
5
+ /**
6
+ * Base directory containing the documentation files.
7
+ * All tool operations are restricted to this directory.
8
+ */
9
+ docsBasePath: string;
10
+ /**
11
+ * Pre-indexed list of allowed document paths (relative to docsBasePath).
12
+ * Only these paths can be read. Glob and grep can search the directory,
13
+ * but read operations are restricted to indexed paths.
14
+ */
15
+ indexedPaths: string[];
16
+ /**
17
+ * Timeout for operations in milliseconds.
18
+ * Default: 30000 (30 seconds)
19
+ */
20
+ timeoutMs?: number;
21
+ }
22
+ /**
23
+ * Result from docs_glob operation
24
+ */
25
+ export interface DocsGlobResult {
26
+ matchingPaths: string[];
27
+ totalMatches: number;
28
+ }
29
+ /**
30
+ * Result from docs_grep operation
31
+ */
32
+ export interface DocsGrepMatch {
33
+ path: string;
34
+ lineNumber: number;
35
+ lineContent: string;
36
+ }
37
+ export interface DocsGrepResult {
38
+ matches: DocsGrepMatch[];
39
+ totalMatches: number;
40
+ truncated: boolean;
41
+ }
42
+ /**
43
+ * Result from docs_read operation
44
+ */
45
+ export interface DocsReadResult {
46
+ path: string;
47
+ content: string;
48
+ lineCount: number;
49
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
22
22
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
23
23
  import { createJqTool } from './jq/index.js';
24
24
  import { createTimeseriesTool } from './timeseries/index.js';
25
+ import { createDocsTools } from './docs/index.js';
25
26
  import { truncateResponseIfNeeded } from './truncation/index.js';
26
27
  import { createFileWriter } from './fileWriter/index.js';
27
28
  import { generateToolId } from './utils/filename.js';
@@ -185,6 +186,27 @@ const JQ_TIMEOUT_MS = parseInt(process.env.MCP_PROXY_JQ_TIMEOUT_MS || '30000');
185
186
  * This tool allows detecting anomalies in time series data extracted from JSON files
186
187
  */
187
188
  const ENABLE_TIMESERIES = process.env.MCP_PROXY_ENABLE_TIMESERIES !== 'false'; // default true
189
+ /**
190
+ * MCP_PROXY_ENABLE_DOCS (OPTIONAL, default: false)
191
+ * Enable the documentation tools (docs_glob, docs_grep, docs_read)
192
+ * Requires DOCS_BASE_PATH and DOCS_INDEXED_PATHS to be configured
193
+ */
194
+ const ENABLE_DOCS = process.env.MCP_PROXY_ENABLE_DOCS === 'true'; // default false
195
+ /**
196
+ * MCP_PROXY_DOCS_BASE_PATH (REQUIRED if ENABLE_DOCS=true)
197
+ * Base directory containing the documentation files
198
+ * All doc tool operations are restricted to this directory
199
+ */
200
+ const DOCS_BASE_PATH = process.env.MCP_PROXY_DOCS_BASE_PATH || '';
201
+ /**
202
+ * MCP_PROXY_DOCS_INDEXED_PATHS (REQUIRED if ENABLE_DOCS=true)
203
+ * Comma-separated list of indexed document paths (relative to DOCS_BASE_PATH)
204
+ * Only these paths can be read via docs_read
205
+ * Example: "integrations/datadog.mdx,setup/getting-started.mdx,faq.mdx"
206
+ */
207
+ const DOCS_INDEXED_PATHS = process.env.MCP_PROXY_DOCS_INDEXED_PATHS
208
+ ? process.env.MCP_PROXY_DOCS_INDEXED_PATHS.split(',').map(s => s.trim()).filter(Boolean)
209
+ : [];
188
210
  /**
189
211
  * MCP_PROXY_SCHEMA_MAX_DEPTH (OPTIONAL, default: 3)
190
212
  * Maximum depth to traverse when generating query-assist schemas
@@ -230,6 +252,16 @@ if (WRITE_TO_FILE && !OUTPUT_PATH) {
230
252
  console.error('Example: export MCP_PROXY_OUTPUT_PATH="/tmp/mcp-results"');
231
253
  process.exit(1);
232
254
  }
255
+ if (ENABLE_DOCS && !DOCS_BASE_PATH) {
256
+ console.error('ERROR: MCP_PROXY_DOCS_BASE_PATH is required when MCP_PROXY_ENABLE_DOCS=true');
257
+ console.error('Example: export MCP_PROXY_DOCS_BASE_PATH="/app/docs"');
258
+ process.exit(1);
259
+ }
260
+ if (ENABLE_DOCS && DOCS_INDEXED_PATHS.length === 0) {
261
+ console.error('ERROR: MCP_PROXY_DOCS_INDEXED_PATHS is required when MCP_PROXY_ENABLE_DOCS=true');
262
+ console.error('Example: export MCP_PROXY_DOCS_INDEXED_PATHS="page1.mdx,page2.mdx,folder/page3.mdx"');
263
+ process.exit(1);
264
+ }
233
265
  // ============================================================================
234
266
  // PASS-THROUGH ENVIRONMENT VARIABLES
235
267
  // ============================================================================
@@ -282,6 +314,11 @@ if (ENABLE_LOGGING) {
282
314
  }
283
315
  console.debug(` JQ tool enabled: ${ENABLE_JQ}`);
284
316
  console.debug(` Timeseries tool enabled: ${ENABLE_TIMESERIES}`);
317
+ console.debug(` Docs tools enabled: ${ENABLE_DOCS}`);
318
+ if (ENABLE_DOCS) {
319
+ console.debug(` Docs base path: ${DOCS_BASE_PATH}`);
320
+ console.debug(` Docs indexed paths: ${DOCS_INDEXED_PATHS.length} pages`);
321
+ }
285
322
  if (CHILD_COMMAND) {
286
323
  console.debug(` Pass-through env vars: ${Object.keys(childEnv).length}`);
287
324
  }
@@ -428,6 +465,18 @@ async function main() {
428
465
  timeoutMs: JQ_TIMEOUT_MS // Uses same timeout as JQ since it runs jq internally
429
466
  });
430
467
  }
468
+ // Docs tools configuration
469
+ let docsTools = [];
470
+ if (ENABLE_DOCS) {
471
+ docsTools = createDocsTools({
472
+ docsBasePath: DOCS_BASE_PATH,
473
+ indexedPaths: DOCS_INDEXED_PATHS,
474
+ timeoutMs: JQ_TIMEOUT_MS
475
+ });
476
+ if (ENABLE_LOGGING) {
477
+ console.debug(`[mcp-proxy] Docs tools created: ${docsTools.map(t => t.toolDefinition.name).join(', ')}`);
478
+ }
479
+ }
431
480
  // ------------------------------------------------------------------------
432
481
  // 5. REGISTER ALL TOOLS (CHILD + PROXY) WITH DESCRIPTION INJECTION
433
482
  // ------------------------------------------------------------------------
@@ -436,9 +485,10 @@ async function main() {
436
485
  const allTools = [
437
486
  ...enhancedChildTools,
438
487
  ...(jqTool ? [jqTool.toolDefinition] : []), // JQ tool already has description param
439
- ...(timeseriesTool ? [timeseriesTool.toolDefinition] : []) // Timeseries tool already has description param
488
+ ...(timeseriesTool ? [timeseriesTool.toolDefinition] : []), // Timeseries tool already has description param
489
+ ...docsTools.map(t => t.toolDefinition) // Docs tools (if enabled)
440
490
  ];
441
- const proxyToolCount = (jqTool ? 1 : 0) + (timeseriesTool ? 1 : 0);
491
+ const proxyToolCount = (jqTool ? 1 : 0) + (timeseriesTool ? 1 : 0) + docsTools.length;
442
492
  console.debug(`[mcp-proxy] Exposing ${allTools.length} tools total (${childToolsResponse.tools.length} from child + ${proxyToolCount} proxy tools)`);
443
493
  // ------------------------------------------------------------------------
444
494
  // 6. HANDLE TOOL LIST REQUESTS
@@ -515,6 +565,69 @@ async function main() {
515
565
  isError: result.isError
516
566
  };
517
567
  }
568
+ // Handle Docs tools locally (if enabled)
569
+ // Route through file writer for large doc handling (same as child MCP tools)
570
+ const docsToolNames = ['docs_glob', 'docs_grep', 'docs_read'];
571
+ if (docsToolNames.includes(toolName) && docsTools.length > 0) {
572
+ const docTool = docsTools.find(t => t.toolDefinition.name === toolName);
573
+ if (docTool) {
574
+ if (ENABLE_LOGGING) {
575
+ console.debug(`[mcp-proxy] Executing ${toolName} locally`);
576
+ }
577
+ try {
578
+ result = await docTool.handler({
579
+ params: { arguments: toolArgs }
580
+ });
581
+ const contentStr = result.content?.[0]?.text || '';
582
+ const originalLength = contentStr.length;
583
+ // Route through file writer (same pipeline as child MCP tools)
584
+ const unifiedResponse = await fileWriter.handleResponse(toolName, toolArgs, {
585
+ content: [{ type: 'text', text: contentStr }]
586
+ });
587
+ if (ENABLE_LOGGING) {
588
+ if (unifiedResponse.wroteToFile) {
589
+ console.debug(`[mcp-proxy] File written for ${toolName} (${originalLength} chars) → ${unifiedResponse.filePath}`);
590
+ }
591
+ else {
592
+ console.debug(`[mcp-proxy] Response for ${toolName} (${originalLength} chars) returned directly`);
593
+ }
594
+ }
595
+ // If not written to file, apply truncation to outputContent
596
+ if (!unifiedResponse.wroteToFile && unifiedResponse.outputContent) {
597
+ const outputStr = typeof unifiedResponse.outputContent === 'string'
598
+ ? unifiedResponse.outputContent
599
+ : JSON.stringify(unifiedResponse.outputContent);
600
+ const truncated = truncateResponseIfNeeded(truncationConfig, outputStr);
601
+ if (truncated.length < outputStr.length) {
602
+ if (ENABLE_LOGGING) {
603
+ console.debug(`[mcp-proxy] Truncated response: ${outputStr.length} → ${truncated.length} chars`);
604
+ }
605
+ // Keep as string for docs (markdown content)
606
+ unifiedResponse.outputContent = truncated;
607
+ }
608
+ }
609
+ // Add retry metadata and return unified response
610
+ addRetryMetadata(unifiedResponse, toolArgs);
611
+ return {
612
+ content: [{
613
+ type: 'text',
614
+ text: JSON.stringify(unifiedResponse, null, 2)
615
+ }],
616
+ isError: false
617
+ };
618
+ }
619
+ catch (error) {
620
+ const tool_id = generateToolId(toolName, toolArgs, fileWriterConfig.toolAbbreviations);
621
+ return {
622
+ content: [{
623
+ type: 'text',
624
+ text: JSON.stringify(createErrorResponse(tool_id, error.message || String(error), toolArgs), null, 2)
625
+ }],
626
+ isError: true
627
+ };
628
+ }
629
+ }
630
+ }
518
631
  // Forward all other tools to child MCP (if child exists)
519
632
  if (!childClient) {
520
633
  const tool_id = generateToolId(toolName, toolArgs, fileWriterConfig.toolAbbreviations);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anyshift/mcp-proxy",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Generic MCP proxy that adds truncation, file writing, and JQ capabilities to any MCP server",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,6 +14,7 @@
14
14
  ],
15
15
  "dependencies": {
16
16
  "@modelcontextprotocol/sdk": "^1.24.0",
17
+ "glob": "^13.0.0",
17
18
  "zod": "^3.24.2"
18
19
  },
19
20
  "devDependencies": {