@anyshift/mcp-proxy 0.4.2 → 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
@@ -18,9 +18,11 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
18
18
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
19
19
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
20
20
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
21
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
21
22
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
22
23
  import { createJqTool } from './jq/index.js';
23
24
  import { createTimeseriesTool } from './timeseries/index.js';
25
+ import { createDocsTools } from './docs/index.js';
24
26
  import { truncateResponseIfNeeded } from './truncation/index.js';
25
27
  import { createFileWriter } from './fileWriter/index.js';
26
28
  import { generateToolId } from './utils/filename.js';
@@ -115,6 +117,27 @@ const CHILD_COMMAND = process.env.MCP_PROXY_CHILD_COMMAND;
115
117
  const CHILD_ARGS = process.env.MCP_PROXY_CHILD_ARGS
116
118
  ? process.env.MCP_PROXY_CHILD_ARGS.split(',').map(s => s.trim()).filter(Boolean)
117
119
  : [];
120
+ /**
121
+ * MCP_PROXY_REMOTE_URL (OPTIONAL)
122
+ * SSE URL of a remote MCP server to connect to instead of spawning a child process
123
+ * When set, the proxy connects as an SSE client to the remote server
124
+ * Example: "http://internal-server:3100/sse"
125
+ */
126
+ const REMOTE_URL = process.env.MCP_PROXY_REMOTE_URL;
127
+ /**
128
+ * MCP_PROXY_REMOTE_HEADERS (OPTIONAL)
129
+ * JSON string of custom headers to send with SSE/POST requests to the remote server
130
+ * Example: '{"X-Client-Credentials":"{\"prod\":{\"AWS_ACCESS_KEY_ID\":\"AKIA...\"}}"}'
131
+ */
132
+ const REMOTE_HEADERS = process.env.MCP_PROXY_REMOTE_HEADERS;
133
+ /**
134
+ * MCP_PROXY_REMOTE_CREDENTIALS (OPTIONAL)
135
+ * JSON string of credentials to send as X-Client-Credentials header to the remote server.
136
+ * This is a convenience alternative to embedding credentials inside MCP_PROXY_REMOTE_HEADERS,
137
+ * which requires triple-escaping nested JSON.
138
+ * Example: '{"prod":{"AWS_ACCESS_KEY_ID":"AKIA...","AWS_SECRET_ACCESS_KEY":"..."}}'
139
+ */
140
+ const REMOTE_CREDENTIALS = process.env.MCP_PROXY_REMOTE_CREDENTIALS;
118
141
  /**
119
142
  * MCP_PROXY_MAX_TOKENS (OPTIONAL, default: 10000)
120
143
  * Maximum tokens before truncating responses
@@ -163,6 +186,27 @@ const JQ_TIMEOUT_MS = parseInt(process.env.MCP_PROXY_JQ_TIMEOUT_MS || '30000');
163
186
  * This tool allows detecting anomalies in time series data extracted from JSON files
164
187
  */
165
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
+ : [];
166
210
  /**
167
211
  * MCP_PROXY_SCHEMA_MAX_DEPTH (OPTIONAL, default: 3)
168
212
  * Maximum depth to traverse when generating query-assist schemas
@@ -191,19 +235,33 @@ const ENABLE_LOGGING = process.env.MCP_PROXY_ENABLE_LOGGING === 'true';
191
235
  // VALIDATION
192
236
  // ============================================================================
193
237
  // Validate configuration
194
- // CHILD_COMMAND is now optional - if not provided, proxy runs in standalone mode (JQ only)
195
- if (!CHILD_COMMAND) {
196
- console.debug('[mcp-proxy] No child command specified - running in standalone mode (JQ only)');
238
+ // CHILD_COMMAND and REMOTE_URL are optional - if neither provided, proxy runs in standalone mode (JQ only)
239
+ if (!CHILD_COMMAND && !REMOTE_URL) {
240
+ console.debug('[mcp-proxy] No child command or remote URL specified - running in standalone mode (JQ only)');
197
241
  if (!ENABLE_JQ) {
198
242
  console.error('ERROR: Standalone mode requires JQ to be enabled (MCP_PROXY_ENABLE_JQ must not be false)');
199
243
  process.exit(1);
200
244
  }
201
245
  }
246
+ if (CHILD_COMMAND && REMOTE_URL) {
247
+ console.error('ERROR: Cannot use both MCP_PROXY_CHILD_COMMAND and MCP_PROXY_REMOTE_URL');
248
+ process.exit(1);
249
+ }
202
250
  if (WRITE_TO_FILE && !OUTPUT_PATH) {
203
251
  console.error('ERROR: MCP_PROXY_OUTPUT_PATH is required when MCP_PROXY_WRITE_TO_FILE=true');
204
252
  console.error('Example: export MCP_PROXY_OUTPUT_PATH="/tmp/mcp-results"');
205
253
  process.exit(1);
206
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
+ }
207
265
  // ============================================================================
208
266
  // PASS-THROUGH ENVIRONMENT VARIABLES
209
267
  // ============================================================================
@@ -237,8 +295,13 @@ for (const [key, value] of Object.entries(process.env)) {
237
295
  // ============================================================================
238
296
  if (ENABLE_LOGGING) {
239
297
  console.debug('[mcp-proxy] Configuration:');
240
- console.debug(` Mode: ${CHILD_COMMAND ? 'wrapper' : 'standalone (JQ only)'}`);
241
- if (CHILD_COMMAND) {
298
+ console.debug(` Mode: ${REMOTE_URL ? 'remote (SSE)' : CHILD_COMMAND ? 'wrapper' : 'standalone (JQ only)'}`);
299
+ if (REMOTE_URL) {
300
+ console.debug(` Remote URL: ${REMOTE_URL}`);
301
+ console.debug(` Remote headers: ${REMOTE_HEADERS ? 'configured' : 'none'}`);
302
+ console.debug(` Remote credentials: ${REMOTE_CREDENTIALS ? 'configured' : 'none'}`);
303
+ }
304
+ else if (CHILD_COMMAND) {
242
305
  console.debug(` Child command: ${CHILD_COMMAND}`);
243
306
  console.debug(` Child args: [${CHILD_ARGS.join(', ')}]`);
244
307
  }
@@ -251,6 +314,11 @@ if (ENABLE_LOGGING) {
251
314
  }
252
315
  console.debug(` JQ tool enabled: ${ENABLE_JQ}`);
253
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
+ }
254
322
  if (CHILD_COMMAND) {
255
323
  console.debug(` Pass-through env vars: ${Object.keys(childEnv).length}`);
256
324
  }
@@ -265,7 +333,61 @@ async function main() {
265
333
  // ------------------------------------------------------------------------
266
334
  let childClient = null;
267
335
  let childToolsResponse = { tools: [] };
268
- if (CHILD_COMMAND) {
336
+ if (REMOTE_URL) {
337
+ // Remote mode: connect to a remote MCP server over SSE
338
+ console.debug(`[mcp-proxy] Connecting to remote MCP: ${REMOTE_URL}`);
339
+ let headers = {};
340
+ if (REMOTE_HEADERS) {
341
+ try {
342
+ headers = JSON.parse(REMOTE_HEADERS);
343
+ }
344
+ catch (e) {
345
+ console.error('ERROR: MCP_PROXY_REMOTE_HEADERS contains invalid JSON');
346
+ console.error(` Value: ${REMOTE_HEADERS.substring(0, 100)}${REMOTE_HEADERS.length > 100 ? '...' : ''}`);
347
+ console.error(` Parse error: ${e instanceof Error ? e.message : String(e)}`);
348
+ process.exit(1);
349
+ }
350
+ }
351
+ if (REMOTE_CREDENTIALS) {
352
+ try {
353
+ JSON.parse(REMOTE_CREDENTIALS); // Validate JSON before sending
354
+ }
355
+ catch (e) {
356
+ console.error('ERROR: MCP_PROXY_REMOTE_CREDENTIALS contains invalid JSON');
357
+ console.error(` Value: ${REMOTE_CREDENTIALS.substring(0, 100)}${REMOTE_CREDENTIALS.length > 100 ? '...' : ''}`);
358
+ console.error(` Parse error: ${e instanceof Error ? e.message : String(e)}`);
359
+ process.exit(1);
360
+ }
361
+ headers['X-Client-Credentials'] = REMOTE_CREDENTIALS;
362
+ }
363
+ const childTransport = new SSEClientTransport(new URL(REMOTE_URL), {
364
+ eventSourceInit: {
365
+ fetch: (url, init) => {
366
+ // Safely convert init.headers (could be Headers object, array, or plain object)
367
+ const initHeaders = init?.headers
368
+ ? Object.fromEntries(new Headers(init.headers))
369
+ : {};
370
+ return fetch(url, {
371
+ ...init,
372
+ headers: { ...initHeaders, ...headers },
373
+ });
374
+ },
375
+ },
376
+ requestInit: { headers },
377
+ });
378
+ childClient = new Client({
379
+ name: 'mcp-proxy-client',
380
+ version: '1.0.0'
381
+ }, {
382
+ capabilities: {}
383
+ });
384
+ await childClient.connect(childTransport);
385
+ console.debug('[mcp-proxy] Connected to remote MCP');
386
+ // Discover tools from remote MCP
387
+ childToolsResponse = await childClient.listTools();
388
+ console.debug(`[mcp-proxy] Discovered ${childToolsResponse.tools.length} tools from remote MCP`);
389
+ }
390
+ else if (CHILD_COMMAND) {
269
391
  console.debug(`[mcp-proxy] Spawning child MCP: ${CHILD_COMMAND}`);
270
392
  const childTransport = new StdioClientTransport({
271
393
  command: CHILD_COMMAND,
@@ -343,6 +465,18 @@ async function main() {
343
465
  timeoutMs: JQ_TIMEOUT_MS // Uses same timeout as JQ since it runs jq internally
344
466
  });
345
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
+ }
346
480
  // ------------------------------------------------------------------------
347
481
  // 5. REGISTER ALL TOOLS (CHILD + PROXY) WITH DESCRIPTION INJECTION
348
482
  // ------------------------------------------------------------------------
@@ -351,9 +485,10 @@ async function main() {
351
485
  const allTools = [
352
486
  ...enhancedChildTools,
353
487
  ...(jqTool ? [jqTool.toolDefinition] : []), // JQ tool already has description param
354
- ...(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)
355
490
  ];
356
- const proxyToolCount = (jqTool ? 1 : 0) + (timeseriesTool ? 1 : 0);
491
+ const proxyToolCount = (jqTool ? 1 : 0) + (timeseriesTool ? 1 : 0) + docsTools.length;
357
492
  console.debug(`[mcp-proxy] Exposing ${allTools.length} tools total (${childToolsResponse.tools.length} from child + ${proxyToolCount} proxy tools)`);
358
493
  // ------------------------------------------------------------------------
359
494
  // 6. HANDLE TOOL LIST REQUESTS
@@ -430,6 +565,69 @@ async function main() {
430
565
  isError: result.isError
431
566
  };
432
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
+ }
433
631
  // Forward all other tools to child MCP (if child exists)
434
632
  if (!childClient) {
435
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.4.2",
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": {