http-client-mcp-server 1.0.0 → 1.1.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.
Files changed (4) hide show
  1. package/README.md +1 -1
  2. package/index.js +289 -23
  3. package/index.ts +349 -30
  4. package/package.json +4 -2
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # ACW Repo Reader (HTTP Client MCP Server)
1
+ # http-client-mcp-server
2
2
 
3
3
  A Model Context Protocol (MCP) server that provides a set of tools to explore and read files from a repository. This is specifically designed to help AI agents understand and navigate large codebases.
4
4
 
package/index.js CHANGED
@@ -4,25 +4,131 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
5
  import fs from "node:fs/promises";
6
6
  import path from "node:path";
7
+ import yaml from "js-yaml";
8
+ const IGNORED_DIRS = [
9
+ "node_modules",
10
+ ".git",
11
+ ".idea",
12
+ ".vscode",
13
+ "dist",
14
+ "build",
15
+ ".next",
16
+ ".cache",
17
+ ];
18
+ const MAX_SEARCH_RESULTS = 50;
19
+ const MAX_LIST_ENTRIES = 100;
20
+ const MAX_FILE_SIZE_BYTES = 100 * 1024; // 100KB
21
+ function pruneOpenApiObject(obj) {
22
+ if (!obj || typeof obj !== "object")
23
+ return obj;
24
+ if (Array.isArray(obj)) {
25
+ return obj.map(pruneOpenApiObject);
26
+ }
27
+ const pruned = {};
28
+ const skipKeys = ["description", "summary", "example", "examples"];
29
+ for (const [key, value] of Object.entries(obj)) {
30
+ if (skipKeys.includes(key) || key.startsWith("x-")) {
31
+ continue;
32
+ }
33
+ pruned[key] = pruneOpenApiObject(value);
34
+ }
35
+ return pruned;
36
+ }
37
+ /**
38
+ * Minifies JSON code blocks in Markdown.
39
+ */
40
+ function minifyJsonInMarkdown(text) {
41
+ return text.replace(/```json([\s\S]*?)```/g, (match, jsonStr) => {
42
+ try {
43
+ const minified = JSON.stringify(JSON.parse(jsonStr.trim()));
44
+ return `\`\`\`json\n${minified}\n\`\`\``;
45
+ }
46
+ catch {
47
+ return match;
48
+ }
49
+ });
50
+ }
51
+ /**
52
+ * Flattens Markdown tables into concise bulleted lists.
53
+ */
54
+ function flattenMarkdownTables(text) {
55
+ const lines = text.split("\n");
56
+ const result = [];
57
+ let inTable = false;
58
+ let headers = [];
59
+ for (const line of lines) {
60
+ const isDivider = /^[\s|:-]+$/.test(line);
61
+ const isRow = line.trim().startsWith("|") && line.trim().endsWith("|");
62
+ if (isRow && !isDivider) {
63
+ const cells = line
64
+ .split("|")
65
+ .map((c) => c.trim())
66
+ .filter((c, i, arr) => i > 0 && i < arr.length - 1);
67
+ if (!inTable) {
68
+ inTable = true;
69
+ headers = cells;
70
+ continue;
71
+ }
72
+ // It's a data row
73
+ if (cells.length > 0) {
74
+ const formatted = cells
75
+ .map((cell, i) => {
76
+ const header = headers[i] || `col${i}`;
77
+ return cell ? `${header}: ${cell}` : "";
78
+ })
79
+ .filter(Boolean)
80
+ .join(", ");
81
+ result.push(`- ${formatted}`);
82
+ }
83
+ }
84
+ else {
85
+ if (inTable && !isDivider && !isRow) {
86
+ inTable = false;
87
+ }
88
+ if (!isDivider) {
89
+ result.push(line);
90
+ }
91
+ }
92
+ }
93
+ return result.join("\n");
94
+ }
95
+ function optimizeMarkdown(text) {
96
+ let optimized = text;
97
+ optimized = optimized.replace(/-- no response --/gi, "");
98
+ optimized = optimized.replace(/Content-Type: application\/json/gi, "");
99
+ optimized = flattenMarkdownTables(optimized);
100
+ optimized = minifyJsonInMarkdown(optimized);
101
+ const seenBlocks = new Set();
102
+ optimized = optimized.replace(/```[\s\S]*?```/g, (match) => {
103
+ const cleaned = match.replace(/\s/g, ""); // canonicalize
104
+ if (seenBlocks.has(cleaned) && cleaned.length > 50) {
105
+ return "[Same body as above]";
106
+ }
107
+ seenBlocks.add(cleaned);
108
+ return match;
109
+ });
110
+ optimized = optimized.replace(/\n\s*\n\s*\n+/g, "\n\n");
111
+ return optimized.trim();
112
+ }
7
113
  const rawRepoPath = process.env.REPO_BASE_PATH || process.argv[2];
8
114
  if (!rawRepoPath) {
9
115
  const currentPath = process.cwd();
10
116
  console.error("Error: REPO_BASE_PATH environment variable or a command-line argument is required.");
11
117
  console.error(`\nHint: If you want to use the current directory, the absolute path is:\n${currentPath}`);
12
- console.error(`\nUsage example:\nacw-repo-reader ${currentPath}`);
118
+ console.error(`\nUsage example:\nhttp-client-mcp-server ${currentPath}`);
13
119
  console.error("\nTips to find your repository path:");
14
120
  console.error("- macOS: Right-click folder + hold 'Option' key -> Select 'Copy as Pathname'");
15
121
  console.error("- Windows: Shift + Right-click folder -> Select 'Copy as path'");
16
122
  process.exit(1);
17
123
  }
18
124
  const REPO_BASE_PATH = path.resolve(rawRepoPath);
19
- const server = new Server({ name: "acw-repo-reader", version: "1.0.0" }, { capabilities: { tools: {} } });
125
+ const server = new Server({ name: "http-client-mcp-server", version: "1.0.0" }, { capabilities: { tools: {} } });
20
126
  server.setRequestHandler(ListToolsRequestSchema, async () => {
21
127
  return {
22
128
  tools: [
23
129
  {
24
130
  name: "read_api_spec",
25
- description: "Reads API specifications, sequence diagrams, or any files from repository.",
131
+ description: "Reads API specifications, source code, or any files. Supports line ranges for large files.",
26
132
  inputSchema: {
27
133
  type: "object",
28
134
  properties: {
@@ -30,13 +136,43 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
30
136
  type: "string",
31
137
  description: "Relative path to the file inside the repo",
32
138
  },
139
+ startLine: {
140
+ type: "number",
141
+ description: "Optional start line number (1-based)",
142
+ },
143
+ endLine: {
144
+ type: "number",
145
+ description: "Optional end line number (1-based)",
146
+ },
33
147
  },
34
148
  required: ["filepath"],
35
149
  },
36
150
  },
151
+ {
152
+ name: "read_specific_endpoint",
153
+ description: "Reads a specific endpoint and method from an OpenAPI spec (YAML/JSON) or Markdown doc. Optimizes output to save tokens.",
154
+ inputSchema: {
155
+ type: "object",
156
+ properties: {
157
+ filepath: {
158
+ type: "string",
159
+ description: "Relative path to the YAML/JSON/MD spec file inside the repo",
160
+ },
161
+ endpointPath: {
162
+ type: "string",
163
+ description: "The API path (e.g., '/v1/account/work-information')",
164
+ },
165
+ method: {
166
+ type: "string",
167
+ description: "The HTTP method (e.g., 'get', 'post', 'put', 'delete')",
168
+ },
169
+ },
170
+ required: ["filepath", "endpointPath", "method"],
171
+ },
172
+ },
37
173
  {
38
174
  name: "list_directory",
39
- description: "List files and folders in a specific directory inside repository. Use this to find the exact filename before reading.",
175
+ description: "List files and folders in a specific directory. Limits output to prevent context blowout.",
40
176
  inputSchema: {
41
177
  type: "object",
42
178
  properties: {
@@ -50,13 +186,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
50
186
  },
51
187
  {
52
188
  name: "search_files",
53
- description: "Search for files across the entire repository using a keyword (e.g., 'address', 'account'). Use this to find the exact filepath before reading.",
189
+ description: "Search for files across the repo using a keyword. Limits results to prevent context blowout.",
54
190
  inputSchema: {
55
191
  type: "object",
56
192
  properties: {
57
193
  keyword: {
58
194
  type: "string",
59
- description: "Keyword to search for in filenames (e.g., 'update-address' or '.http')",
195
+ description: "Keyword to search for in filenames (e.g., 'update-address')",
60
196
  },
61
197
  },
62
198
  required: ["keyword"],
@@ -66,19 +202,37 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
66
202
  };
67
203
  });
68
204
  async function handleReadApiSpec(args) {
69
- const filepath = typeof args?.filepath === "string" ? args.filepath : "";
205
+ const { filepath, startLine, endLine } = args;
70
206
  if (!filepath) {
71
- throw new Error("filepath is required and must be a string");
207
+ throw new Error("filepath is required");
72
208
  }
73
209
  const fullPath = path.resolve(REPO_BASE_PATH, filepath);
74
210
  if (!fullPath.startsWith(REPO_BASE_PATH)) {
75
- console.error(`[Security Warning] Attempted path traversal: ${filepath}`);
76
211
  throw new Error("Access denied: File is outside the repository base path.");
77
212
  }
78
213
  try {
214
+ const stats = await fs.stat(fullPath);
215
+ if (stats.size > MAX_FILE_SIZE_BYTES && !startLine && !endLine) {
216
+ return {
217
+ content: [
218
+ {
219
+ type: "text",
220
+ text: `File is too large (${Math.round(stats.size / 1024)}KB). Please use 'read_specific_endpoint' for API specs or specify 'startLine' and 'endLine' to read specific segments.`,
221
+ },
222
+ ],
223
+ isError: true,
224
+ };
225
+ }
79
226
  const content = await fs.readFile(fullPath, "utf-8");
227
+ let resultText = content;
228
+ if (startLine || endLine) {
229
+ const lines = content.split("\n");
230
+ const start = startLine ? Math.max(0, startLine - 1) : 0;
231
+ const end = endLine ? Math.min(lines.length, endLine) : lines.length;
232
+ resultText = lines.slice(start, end).join("\n");
233
+ }
80
234
  return {
81
- content: [{ type: "text", text: content }],
235
+ content: [{ type: "text", text: resultText }],
82
236
  };
83
237
  }
84
238
  catch (error) {
@@ -88,6 +242,102 @@ async function handleReadApiSpec(args) {
88
242
  };
89
243
  }
90
244
  }
245
+ async function handleReadSpecificEndpoint(args) {
246
+ const { filepath, endpointPath, method } = args;
247
+ if (!filepath || !endpointPath || !method) {
248
+ throw new Error("filepath, endpointPath, and method are required");
249
+ }
250
+ const fullPath = path.resolve(REPO_BASE_PATH, filepath);
251
+ if (!fullPath.startsWith(REPO_BASE_PATH)) {
252
+ throw new Error("Access denied: File is outside the repository base path.");
253
+ }
254
+ const ext = path.extname(filepath).toLowerCase();
255
+ try {
256
+ const content = await fs.readFile(fullPath, "utf-8");
257
+ if (ext === ".md" || ext === ".markdown") {
258
+ const sections = content.split(/^# /m);
259
+ const lowPath = endpointPath.toLowerCase();
260
+ const lowMethod = method.toLowerCase();
261
+ const matches = sections.filter((s) => {
262
+ const lowSection = s.toLowerCase();
263
+ const hasMethod = lowSection.includes(`[${lowMethod}]`) ||
264
+ lowSection.includes(`**${lowMethod}**`) ||
265
+ lowSection.includes(`${lowMethod} `) ||
266
+ lowSection.includes(`| ${lowMethod} `);
267
+ return hasMethod && lowSection.includes(lowPath);
268
+ });
269
+ if (matches.length === 0) {
270
+ return {
271
+ content: [
272
+ {
273
+ type: "text",
274
+ text: `No matching section found for "${method} ${endpointPath}" in Markdown file.`,
275
+ },
276
+ ],
277
+ isError: true,
278
+ };
279
+ }
280
+ const result = matches
281
+ .map((m) => (m.trim() ? `# ${m.trim()}` : ""))
282
+ .join("\n\n---\n\n");
283
+ return {
284
+ content: [{ type: "text", text: optimizeMarkdown(result) }],
285
+ };
286
+ }
287
+ const spec = yaml.load(content);
288
+ if (!spec || typeof spec !== "object") {
289
+ throw new Error("Invalid spec file format");
290
+ }
291
+ const paths = spec.paths || spec.endpoints;
292
+ if (!paths) {
293
+ throw new Error("Spec file does not contain 'paths' or 'endpoints' section");
294
+ }
295
+ const lowPath = endpointPath.toLowerCase();
296
+ const actualPathKey = Object.keys(paths).find((k) => k.toLowerCase() === lowPath);
297
+ if (!actualPathKey) {
298
+ return {
299
+ content: [
300
+ {
301
+ type: "text",
302
+ text: `Endpoint path "${endpointPath}" not found in spec.`,
303
+ },
304
+ ],
305
+ isError: true,
306
+ };
307
+ }
308
+ const pathObj = paths[actualPathKey];
309
+ const lowMethod = method.toLowerCase();
310
+ const actualMethodKey = Object.keys(pathObj).find((m) => m.toLowerCase() === lowMethod);
311
+ if (!actualMethodKey) {
312
+ return {
313
+ content: [
314
+ {
315
+ type: "text",
316
+ text: `Method "${method}" not found for path "${endpointPath}".`,
317
+ },
318
+ ],
319
+ isError: true,
320
+ };
321
+ }
322
+ const prunedMethodObj = pruneOpenApiObject(pathObj[actualMethodKey]);
323
+ const result = {
324
+ [actualPathKey]: {
325
+ [actualMethodKey]: prunedMethodObj,
326
+ },
327
+ };
328
+ return {
329
+ content: [{ type: "text", text: yaml.dump(result) }],
330
+ };
331
+ }
332
+ catch (error) {
333
+ return {
334
+ content: [
335
+ { type: "text", text: `Error processing spec: ${error.message}` },
336
+ ],
337
+ isError: true,
338
+ };
339
+ }
340
+ }
91
341
  async function handleListDirectory(args) {
92
342
  const dirPath = typeof args?.dirPath === "string" ? args.dirPath : ".";
93
343
  const fullDirPath = path.resolve(REPO_BASE_PATH, dirPath);
@@ -96,11 +346,21 @@ async function handleListDirectory(args) {
96
346
  }
97
347
  try {
98
348
  const files = await fs.readdir(fullDirPath, { withFileTypes: true });
99
- const fileList = files
100
- .map((dirent) => `${dirent.isDirectory() ? "[DIR]" : "[FILE]"} ${dirent.name}`)
101
- .join("\n");
349
+ let fileList = files
350
+ .filter((d) => !IGNORED_DIRS.includes(d.name))
351
+ .map((dirent) => `${dirent.isDirectory() ? "[DIR]" : "[FILE]"} ${dirent.name}`);
352
+ const total = fileList.length;
353
+ if (fileList.length > MAX_LIST_ENTRIES) {
354
+ fileList = fileList.slice(0, MAX_LIST_ENTRIES);
355
+ fileList.push(`\n... and ${total - MAX_LIST_ENTRIES} more entries.`);
356
+ }
102
357
  return {
103
- content: [{ type: "text", text: `Contents of ${dirPath}:\n${fileList}` }],
358
+ content: [
359
+ {
360
+ type: "text",
361
+ text: `Contents of ${dirPath}:\n${fileList.join("\n")}`,
362
+ },
363
+ ],
104
364
  };
105
365
  }
106
366
  catch (error) {
@@ -116,17 +376,15 @@ async function findFilesRecursive(dir, keyword, basePath) {
116
376
  let results = [];
117
377
  const list = await fs.readdir(dir, { withFileTypes: true });
118
378
  for (const file of list) {
119
- if (file.isDirectory() &&
120
- (file.name === "node_modules" ||
121
- file.name === ".git" ||
122
- file.name === ".idea" ||
123
- file.name === ".vscode")) {
379
+ if (file.isDirectory() && IGNORED_DIRS.includes(file.name)) {
124
380
  continue;
125
381
  }
126
382
  const fullPath = path.resolve(dir, file.name);
127
383
  if (file.isDirectory()) {
128
384
  const subResults = await findFilesRecursive(fullPath, keyword, basePath);
129
385
  results = results.concat(subResults);
386
+ if (results.length > MAX_SEARCH_RESULTS)
387
+ break;
130
388
  }
131
389
  else if (file.name.toLowerCase().includes(keyword.toLowerCase())) {
132
390
  results.push(path.relative(basePath, fullPath));
@@ -137,10 +395,16 @@ async function findFilesRecursive(dir, keyword, basePath) {
137
395
  async function handleSearchFiles(args) {
138
396
  const keyword = typeof args?.keyword === "string" ? args.keyword : "";
139
397
  if (!keyword) {
140
- throw new Error("keyword is required and must be a string");
398
+ throw new Error("keyword is required");
141
399
  }
142
400
  try {
143
- const matchedFiles = await findFilesRecursive(REPO_BASE_PATH, keyword, REPO_BASE_PATH);
401
+ let matchedFiles = await findFilesRecursive(REPO_BASE_PATH, keyword, REPO_BASE_PATH);
402
+ const total = matchedFiles.length;
403
+ let warning = "";
404
+ if (matchedFiles.length > MAX_SEARCH_RESULTS) {
405
+ matchedFiles = matchedFiles.slice(0, MAX_SEARCH_RESULTS);
406
+ warning = `\n\nWarning: Showing first ${MAX_SEARCH_RESULTS} of ${total} results. Please use a more specific keyword.`;
407
+ }
144
408
  if (matchedFiles.length === 0) {
145
409
  return {
146
410
  content: [
@@ -155,7 +419,7 @@ async function handleSearchFiles(args) {
155
419
  content: [
156
420
  {
157
421
  type: "text",
158
- text: `Found ${matchedFiles.length} matching files:\n${matchedFiles.join("\n")}`,
422
+ text: `Found ${matchedFiles.length} matching files:\n${matchedFiles.join("\n")}${warning}`,
159
423
  },
160
424
  ],
161
425
  };
@@ -174,6 +438,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
174
438
  switch (name) {
175
439
  case "read_api_spec":
176
440
  return await handleReadApiSpec(args);
441
+ case "read_specific_endpoint":
442
+ return await handleReadSpecificEndpoint(args);
177
443
  case "list_directory":
178
444
  return await handleListDirectory(args);
179
445
  case "search_files":
@@ -185,7 +451,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
185
451
  async function run() {
186
452
  const transport = new StdioServerTransport();
187
453
  await server.connect(transport);
188
- console.error(`MCP Server "acw-repo-reader" is running on stdio.`);
454
+ console.error(`MCP Server "http-client-mcp-server" is running on stdio.`);
189
455
  console.error(`Watching repository path: ${REPO_BASE_PATH}`);
190
456
  }
191
457
  try {
package/index.ts CHANGED
@@ -7,24 +7,153 @@ import {
7
7
  } from "@modelcontextprotocol/sdk/types.js";
8
8
  import fs from "node:fs/promises";
9
9
  import path from "node:path";
10
+ import yaml from "js-yaml";
11
+
12
+ const IGNORED_DIRS = [
13
+ "node_modules",
14
+ ".git",
15
+ ".idea",
16
+ ".vscode",
17
+ "dist",
18
+ "build",
19
+ ".next",
20
+ ".cache",
21
+ ];
22
+ const MAX_SEARCH_RESULTS = 50;
23
+ const MAX_LIST_ENTRIES = 100;
24
+ const MAX_FILE_SIZE_BYTES = 100 * 1024; // 100KB
25
+
26
+ function pruneOpenApiObject(obj: any): any {
27
+ if (!obj || typeof obj !== "object") return obj;
28
+
29
+ if (Array.isArray(obj)) {
30
+ return obj.map(pruneOpenApiObject);
31
+ }
32
+
33
+ const pruned: any = {};
34
+ const skipKeys = ["description", "summary", "example", "examples"];
35
+
36
+ for (const [key, value] of Object.entries(obj)) {
37
+ if (skipKeys.includes(key) || key.startsWith("x-")) {
38
+ continue;
39
+ }
40
+ pruned[key] = pruneOpenApiObject(value);
41
+ }
42
+ return pruned;
43
+ }
44
+
45
+ /**
46
+ * Minifies JSON code blocks in Markdown.
47
+ */
48
+ function minifyJsonInMarkdown(text: string): string {
49
+ return text.replace(/```json([\s\S]*?)```/g, (match, jsonStr) => {
50
+ try {
51
+ const minified = JSON.stringify(JSON.parse(jsonStr.trim()));
52
+ return `\`\`\`json\n${minified}\n\`\`\``;
53
+ } catch {
54
+ return match;
55
+ }
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Flattens Markdown tables into concise bulleted lists.
61
+ */
62
+ function flattenMarkdownTables(text: string): string {
63
+ const lines = text.split("\n");
64
+ const result: string[] = [];
65
+ let inTable = false;
66
+ let headers: string[] = [];
67
+
68
+ for (const line of lines) {
69
+ const isDivider = /^[\s|:-]+$/.test(line);
70
+ const isRow = line.trim().startsWith("|") && line.trim().endsWith("|");
71
+
72
+ if (isRow && !isDivider) {
73
+ const cells = line
74
+ .split("|")
75
+ .map((c) => c.trim())
76
+ .filter((c, i, arr) => i > 0 && i < arr.length - 1);
77
+
78
+ if (!inTable) {
79
+ inTable = true;
80
+ headers = cells;
81
+ continue;
82
+ }
83
+
84
+ // It's a data row
85
+ if (cells.length > 0) {
86
+ const formatted = cells
87
+ .map((cell, i) => {
88
+ const header = headers[i] || `col${i}`;
89
+ return cell ? `${header}: ${cell}` : "";
90
+ })
91
+ .filter(Boolean)
92
+ .join(", ");
93
+ result.push(`- ${formatted}`);
94
+ }
95
+ } else {
96
+ if (inTable && !isDivider && !isRow) {
97
+ inTable = false;
98
+ }
99
+ if (!isDivider) {
100
+ result.push(line);
101
+ }
102
+ }
103
+ }
104
+ return result.join("\n");
105
+ }
106
+
107
+ function optimizeMarkdown(text: string): string {
108
+ let optimized = text;
109
+
110
+ optimized = optimized.replace(/-- no response --/gi, "");
111
+ optimized = optimized.replace(/Content-Type: application\/json/gi, "");
112
+
113
+ optimized = flattenMarkdownTables(optimized);
114
+
115
+ optimized = minifyJsonInMarkdown(optimized);
116
+
117
+ const seenBlocks = new Set<string>();
118
+ optimized = optimized.replace(/```[\s\S]*?```/g, (match) => {
119
+ const cleaned = match.replace(/\s/g, ""); // canonicalize
120
+ if (seenBlocks.has(cleaned) && cleaned.length > 50) {
121
+ return "[Same body as above]";
122
+ }
123
+ seenBlocks.add(cleaned);
124
+ return match;
125
+ });
126
+
127
+ optimized = optimized.replace(/\n\s*\n\s*\n+/g, "\n\n");
128
+
129
+ return optimized.trim();
130
+ }
10
131
 
11
132
  const rawRepoPath = process.env.REPO_BASE_PATH || process.argv[2];
12
133
 
13
134
  if (!rawRepoPath) {
14
135
  const currentPath = process.cwd();
15
- console.error("Error: REPO_BASE_PATH environment variable or a command-line argument is required.");
16
- console.error(`\nHint: If you want to use the current directory, the absolute path is:\n${currentPath}`);
17
- console.error(`\nUsage example:\nacw-repo-reader ${currentPath}`);
136
+ console.error(
137
+ "Error: REPO_BASE_PATH environment variable or a command-line argument is required.",
138
+ );
139
+ console.error(
140
+ `\nHint: If you want to use the current directory, the absolute path is:\n${currentPath}`,
141
+ );
142
+ console.error(`\nUsage example:\nhttp-client-mcp-server ${currentPath}`);
18
143
  console.error("\nTips to find your repository path:");
19
- console.error("- macOS: Right-click folder + hold 'Option' key -> Select 'Copy as Pathname'");
20
- console.error("- Windows: Shift + Right-click folder -> Select 'Copy as path'");
144
+ console.error(
145
+ "- macOS: Right-click folder + hold 'Option' key -> Select 'Copy as Pathname'",
146
+ );
147
+ console.error(
148
+ "- Windows: Shift + Right-click folder -> Select 'Copy as path'",
149
+ );
21
150
  process.exit(1);
22
151
  }
23
152
 
24
153
  const REPO_BASE_PATH = path.resolve(rawRepoPath);
25
154
 
26
155
  const server = new Server(
27
- { name: "acw-repo-reader", version: "1.0.0" },
156
+ { name: "http-client-mcp-server", version: "1.0.0" },
28
157
  { capabilities: { tools: {} } },
29
158
  );
30
159
 
@@ -34,7 +163,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
34
163
  {
35
164
  name: "read_api_spec",
36
165
  description:
37
- "Reads API specifications, sequence diagrams, or any files from repository.",
166
+ "Reads API specifications, source code, or any files. Supports line ranges for large files.",
38
167
  inputSchema: {
39
168
  type: "object",
40
169
  properties: {
@@ -42,14 +171,48 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
42
171
  type: "string",
43
172
  description: "Relative path to the file inside the repo",
44
173
  },
174
+ startLine: {
175
+ type: "number",
176
+ description: "Optional start line number (1-based)",
177
+ },
178
+ endLine: {
179
+ type: "number",
180
+ description: "Optional end line number (1-based)",
181
+ },
45
182
  },
46
183
  required: ["filepath"],
47
184
  },
48
185
  },
186
+ {
187
+ name: "read_specific_endpoint",
188
+ description:
189
+ "Reads a specific endpoint and method from an OpenAPI spec (YAML/JSON) or Markdown doc. Optimizes output to save tokens.",
190
+ inputSchema: {
191
+ type: "object",
192
+ properties: {
193
+ filepath: {
194
+ type: "string",
195
+ description:
196
+ "Relative path to the YAML/JSON/MD spec file inside the repo",
197
+ },
198
+ endpointPath: {
199
+ type: "string",
200
+ description:
201
+ "The API path (e.g., '/v1/account/work-information')",
202
+ },
203
+ method: {
204
+ type: "string",
205
+ description:
206
+ "The HTTP method (e.g., 'get', 'post', 'put', 'delete')",
207
+ },
208
+ },
209
+ required: ["filepath", "endpointPath", "method"],
210
+ },
211
+ },
49
212
  {
50
213
  name: "list_directory",
51
214
  description:
52
- "List files and folders in a specific directory inside repository. Use this to find the exact filename before reading.",
215
+ "List files and folders in a specific directory. Limits output to prevent context blowout.",
53
216
  inputSchema: {
54
217
  type: "object",
55
218
  properties: {
@@ -65,14 +228,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
65
228
  {
66
229
  name: "search_files",
67
230
  description:
68
- "Search for files across the entire repository using a keyword (e.g., 'address', 'account'). Use this to find the exact filepath before reading.",
231
+ "Search for files across the repo using a keyword. Limits results to prevent context blowout.",
69
232
  inputSchema: {
70
233
  type: "object",
71
234
  properties: {
72
235
  keyword: {
73
236
  type: "string",
74
237
  description:
75
- "Keyword to search for in filenames (e.g., 'update-address' or '.http')",
238
+ "Keyword to search for in filenames (e.g., 'update-address')",
76
239
  },
77
240
  },
78
241
  required: ["keyword"],
@@ -83,23 +246,43 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
83
246
  });
84
247
 
85
248
  async function handleReadApiSpec(args: any) {
86
- const filepath = typeof args?.filepath === "string" ? args.filepath : "";
249
+ const { filepath, startLine, endLine } = args;
87
250
 
88
251
  if (!filepath) {
89
- throw new Error("filepath is required and must be a string");
252
+ throw new Error("filepath is required");
90
253
  }
91
254
 
92
255
  const fullPath = path.resolve(REPO_BASE_PATH, filepath);
93
-
94
256
  if (!fullPath.startsWith(REPO_BASE_PATH)) {
95
- console.error(`[Security Warning] Attempted path traversal: ${filepath}`);
96
257
  throw new Error("Access denied: File is outside the repository base path.");
97
258
  }
98
259
 
99
260
  try {
261
+ const stats = await fs.stat(fullPath);
262
+ if (stats.size > MAX_FILE_SIZE_BYTES && !startLine && !endLine) {
263
+ return {
264
+ content: [
265
+ {
266
+ type: "text",
267
+ text: `File is too large (${Math.round(stats.size / 1024)}KB). Please use 'read_specific_endpoint' for API specs or specify 'startLine' and 'endLine' to read specific segments.`,
268
+ },
269
+ ],
270
+ isError: true,
271
+ };
272
+ }
273
+
100
274
  const content = await fs.readFile(fullPath, "utf-8");
275
+ let resultText = content;
276
+
277
+ if (startLine || endLine) {
278
+ const lines = content.split("\n");
279
+ const start = startLine ? Math.max(0, startLine - 1) : 0;
280
+ const end = endLine ? Math.min(lines.length, endLine) : lines.length;
281
+ resultText = lines.slice(start, end).join("\n");
282
+ }
283
+
101
284
  return {
102
- content: [{ type: "text", text: content }],
285
+ content: [{ type: "text", text: resultText }],
103
286
  };
104
287
  } catch (error: any) {
105
288
  return {
@@ -109,6 +292,127 @@ async function handleReadApiSpec(args: any) {
109
292
  }
110
293
  }
111
294
 
295
+ async function handleReadSpecificEndpoint(args: any) {
296
+ const { filepath, endpointPath, method } = args;
297
+
298
+ if (!filepath || !endpointPath || !method) {
299
+ throw new Error("filepath, endpointPath, and method are required");
300
+ }
301
+
302
+ const fullPath = path.resolve(REPO_BASE_PATH, filepath);
303
+ if (!fullPath.startsWith(REPO_BASE_PATH)) {
304
+ throw new Error("Access denied: File is outside the repository base path.");
305
+ }
306
+
307
+ const ext = path.extname(filepath).toLowerCase();
308
+
309
+ try {
310
+ const content = await fs.readFile(fullPath, "utf-8");
311
+
312
+ if (ext === ".md" || ext === ".markdown") {
313
+ const sections = content.split(/^# /m);
314
+ const lowPath = endpointPath.toLowerCase();
315
+ const lowMethod = method.toLowerCase();
316
+
317
+ const matches = sections.filter((s) => {
318
+ const lowSection = s.toLowerCase();
319
+ const hasMethod =
320
+ lowSection.includes(`[${lowMethod}]`) ||
321
+ lowSection.includes(`**${lowMethod}**`) ||
322
+ lowSection.includes(`${lowMethod} `) ||
323
+ lowSection.includes(`| ${lowMethod} `);
324
+
325
+ return hasMethod && lowSection.includes(lowPath);
326
+ });
327
+
328
+ if (matches.length === 0) {
329
+ return {
330
+ content: [
331
+ {
332
+ type: "text",
333
+ text: `No matching section found for "${method} ${endpointPath}" in Markdown file.`,
334
+ },
335
+ ],
336
+ isError: true,
337
+ };
338
+ }
339
+
340
+ const result = matches
341
+ .map((m) => (m.trim() ? `# ${m.trim()}` : ""))
342
+ .join("\n\n---\n\n");
343
+
344
+ return {
345
+ content: [{ type: "text", text: optimizeMarkdown(result) }],
346
+ };
347
+ }
348
+
349
+ const spec = yaml.load(content) as any;
350
+ if (!spec || typeof spec !== "object") {
351
+ throw new Error("Invalid spec file format");
352
+ }
353
+
354
+ const paths = spec.paths || spec.endpoints;
355
+ if (!paths) {
356
+ throw new Error(
357
+ "Spec file does not contain 'paths' or 'endpoints' section",
358
+ );
359
+ }
360
+
361
+ const lowPath = endpointPath.toLowerCase();
362
+ const actualPathKey = Object.keys(paths).find(
363
+ (k) => k.toLowerCase() === lowPath,
364
+ );
365
+
366
+ if (!actualPathKey) {
367
+ return {
368
+ content: [
369
+ {
370
+ type: "text",
371
+ text: `Endpoint path "${endpointPath}" not found in spec.`,
372
+ },
373
+ ],
374
+ isError: true,
375
+ };
376
+ }
377
+
378
+ const pathObj = paths[actualPathKey];
379
+ const lowMethod = method.toLowerCase();
380
+ const actualMethodKey = Object.keys(pathObj).find(
381
+ (m) => m.toLowerCase() === lowMethod,
382
+ );
383
+
384
+ if (!actualMethodKey) {
385
+ return {
386
+ content: [
387
+ {
388
+ type: "text",
389
+ text: `Method "${method}" not found for path "${endpointPath}".`,
390
+ },
391
+ ],
392
+ isError: true,
393
+ };
394
+ }
395
+
396
+ const prunedMethodObj = pruneOpenApiObject(pathObj[actualMethodKey]);
397
+ const result = {
398
+ [actualPathKey]: {
399
+ [actualMethodKey]: prunedMethodObj,
400
+ },
401
+ };
402
+
403
+ return {
404
+ content: [{ type: "text", text: yaml.dump(result) }],
405
+ };
406
+ } catch (error: any) {
407
+ return {
408
+ content: [
409
+ { type: "text", text: `Error processing spec: ${error.message}` },
410
+ ],
411
+ isError: true,
412
+ };
413
+ }
414
+ }
415
+
112
416
  async function handleListDirectory(args: any) {
113
417
  const dirPath = typeof args?.dirPath === "string" ? args.dirPath : ".";
114
418
  const fullDirPath = path.resolve(REPO_BASE_PATH, dirPath);
@@ -119,15 +423,26 @@ async function handleListDirectory(args: any) {
119
423
 
120
424
  try {
121
425
  const files = await fs.readdir(fullDirPath, { withFileTypes: true });
122
- const fileList = files
426
+ let fileList = files
427
+ .filter((d) => !IGNORED_DIRS.includes(d.name))
123
428
  .map(
124
429
  (dirent) =>
125
430
  `${dirent.isDirectory() ? "[DIR]" : "[FILE]"} ${dirent.name}`,
126
- )
127
- .join("\n");
431
+ );
432
+
433
+ const total = fileList.length;
434
+ if (fileList.length > MAX_LIST_ENTRIES) {
435
+ fileList = fileList.slice(0, MAX_LIST_ENTRIES);
436
+ fileList.push(`\n... and ${total - MAX_LIST_ENTRIES} more entries.`);
437
+ }
128
438
 
129
439
  return {
130
- content: [{ type: "text", text: `Contents of ${dirPath}:\n${fileList}` }],
440
+ content: [
441
+ {
442
+ type: "text",
443
+ text: `Contents of ${dirPath}:\n${fileList.join("\n")}`,
444
+ },
445
+ ],
131
446
  };
132
447
  } catch (error: any) {
133
448
  return {
@@ -148,13 +463,7 @@ async function findFilesRecursive(
148
463
  const list = await fs.readdir(dir, { withFileTypes: true });
149
464
 
150
465
  for (const file of list) {
151
- if (
152
- file.isDirectory() &&
153
- (file.name === "node_modules" ||
154
- file.name === ".git" ||
155
- file.name === ".idea" ||
156
- file.name === ".vscode")
157
- ) {
466
+ if (file.isDirectory() && IGNORED_DIRS.includes(file.name)) {
158
467
  continue;
159
468
  }
160
469
 
@@ -163,6 +472,7 @@ async function findFilesRecursive(
163
472
  if (file.isDirectory()) {
164
473
  const subResults = await findFilesRecursive(fullPath, keyword, basePath);
165
474
  results = results.concat(subResults);
475
+ if (results.length > MAX_SEARCH_RESULTS) break;
166
476
  } else if (file.name.toLowerCase().includes(keyword.toLowerCase())) {
167
477
  results.push(path.relative(basePath, fullPath));
168
478
  }
@@ -174,16 +484,23 @@ async function handleSearchFiles(args: any) {
174
484
  const keyword = typeof args?.keyword === "string" ? args.keyword : "";
175
485
 
176
486
  if (!keyword) {
177
- throw new Error("keyword is required and must be a string");
487
+ throw new Error("keyword is required");
178
488
  }
179
489
 
180
490
  try {
181
- const matchedFiles = await findFilesRecursive(
491
+ let matchedFiles = await findFilesRecursive(
182
492
  REPO_BASE_PATH,
183
493
  keyword,
184
494
  REPO_BASE_PATH,
185
495
  );
186
496
 
497
+ const total = matchedFiles.length;
498
+ let warning = "";
499
+ if (matchedFiles.length > MAX_SEARCH_RESULTS) {
500
+ matchedFiles = matchedFiles.slice(0, MAX_SEARCH_RESULTS);
501
+ warning = `\n\nWarning: Showing first ${MAX_SEARCH_RESULTS} of ${total} results. Please use a more specific keyword.`;
502
+ }
503
+
187
504
  if (matchedFiles.length === 0) {
188
505
  return {
189
506
  content: [
@@ -199,7 +516,7 @@ async function handleSearchFiles(args: any) {
199
516
  content: [
200
517
  {
201
518
  type: "text",
202
- text: `Found ${matchedFiles.length} matching files:\n${matchedFiles.join("\n")}`,
519
+ text: `Found ${matchedFiles.length} matching files:\n${matchedFiles.join("\n")}${warning}`,
203
520
  },
204
521
  ],
205
522
  };
@@ -219,6 +536,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
219
536
  switch (name) {
220
537
  case "read_api_spec":
221
538
  return await handleReadApiSpec(args);
539
+ case "read_specific_endpoint":
540
+ return await handleReadSpecificEndpoint(args);
222
541
  case "list_directory":
223
542
  return await handleListDirectory(args);
224
543
  case "search_files":
@@ -231,7 +550,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
231
550
  async function run() {
232
551
  const transport = new StdioServerTransport();
233
552
  await server.connect(transport);
234
- console.error(`MCP Server "acw-repo-reader" is running on stdio.`);
553
+ console.error(`MCP Server "http-client-mcp-server" is running on stdio.`);
235
554
  console.error(`Watching repository path: ${REPO_BASE_PATH}`);
236
555
  }
237
556
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "http-client-mcp-server",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "MCP server to explore and read files from a repository",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -16,9 +16,11 @@
16
16
  "license": "ISC",
17
17
  "type": "module",
18
18
  "dependencies": {
19
- "@modelcontextprotocol/sdk": "^1.29.0"
19
+ "@modelcontextprotocol/sdk": "^1.29.0",
20
+ "js-yaml": "^4.1.1"
20
21
  },
21
22
  "devDependencies": {
23
+ "@types/js-yaml": "^4.0.9",
22
24
  "@types/node": "^25.5.2",
23
25
  "typescript": "^6.0.2"
24
26
  }