Skip to content

Commit 642a320

Browse files
committed
feat: Add remove documents functionality
Adds a new tool and CLI command to remove indexed documentation for a specific library version. - **MCP Tool:** `remove_docs` - Implemented in `src/tools/RemoveTool.ts`. - Registered in `src/mcp/index.ts`. - Uses existing `DocumentManagementService.removeAllDocuments`. - **CLI Command:** `remove <library>` - Added to `src/cli.ts`. - Uses existing `DocumentManagementService.removeAllDocuments`. - Takes library name as positional argument and optional `--version` flag. - **Testing:** Added unit tests in `src/tools/RemoveTool.test.ts`.
1 parent 6b754b9 commit 642a320

File tree

6 files changed

+254
-2
lines changed

6 files changed

+254
-2
lines changed

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ The server exposes MCP tools for:
1515
- Searching documentation (`search_docs`).
1616
- Listing indexed libraries (`list_libraries`).
1717
- Finding appropriate versions (`find_version`).
18+
- Removing indexed documents (`remove_docs`).
1819

1920
A companion CLI (`docs-mcp`) is also included for local management and interaction (note: the CLI `scrape` command waits for completion).
2021

@@ -112,6 +113,7 @@ docs-mcp --help
112113
docs-mcp scrape --help
113114
docs-mcp search --help
114115
docs-mcp find-version --help
116+
docs-mcp remove --help
115117
```
116118

117119
### Scraping Documentation (`scrape`)
@@ -218,6 +220,28 @@ Lists all libraries currently indexed in the store.
218220
docs-mcp list-libraries
219221
```
220222
223+
### Removing Documentation (`remove`)
224+
225+
Removes indexed documents for a specific library and version.
226+
227+
```bash
228+
docs-mcp remove <library> [options]
229+
```
230+
231+
**Options:**
232+
233+
- `-v, --version <string>`: The specific version to remove. If omitted, removes **unversioned** documents for the library.
234+
235+
**Examples:**
236+
237+
```bash
238+
# Remove React 18.2.0 docs
239+
docs-mcp remove react --version 18.2.0
240+
241+
# Remove unversioned React docs
242+
docs-mcp remove react
243+
```
244+
221245
### Version Handling Summary
222246
223247
- **Scraping:** Requires a specific, valid version (`X.Y.Z`, `X.Y.Z-pre`, `X.Y`, `X`) or no version (for unversioned docs). Ranges (`X.x`) are invalid for scraping.

src/cli.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ async function main() {
104104
});
105105

106106
program
107-
.command("list-libraries")
107+
.command("list")
108108
.description("List all available libraries and their versions")
109109
.action(async () => {
110110
const result = await tools.listLibraries.execute();
@@ -132,6 +132,34 @@ async function main() {
132132
console.log(versionInfo); // Log the descriptive string from the tool
133133
});
134134

135+
program
136+
.command("remove <library>") // Library as positional argument
137+
.description("Remove documents for a specific library and version")
138+
.option(
139+
"-v, --version <string>",
140+
"Version to remove (optional, removes unversioned if omitted)",
141+
)
142+
.action(async (library, options) => {
143+
// library is now the first arg
144+
if (!docService) {
145+
throw new Error("Document service not initialized.");
146+
}
147+
const { version } = options; // Get version from options
148+
try {
149+
await docService.removeAllDocuments(library, version);
150+
console.log(
151+
`✅ Successfully removed documents for ${library}${version ? `@${version}` : " (unversioned)"}.`,
152+
);
153+
} catch (error) {
154+
console.error(
155+
`❌ Failed to remove documents for ${library}${version ? `@${version}` : " (unversioned)"}:`,
156+
error instanceof Error ? error.message : String(error),
157+
);
158+
// Re-throw to trigger the main catch block for shutdown
159+
throw error;
160+
}
161+
});
162+
135163
await program.parseAsync();
136164
} catch (error) {
137165
console.error("Error:", error instanceof Error ? error.message : String(error));

src/mcp/index.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
GetJobInfoTool,
1313
ListJobsTool,
1414
ListLibrariesTool,
15+
RemoveTool,
1516
ScrapeTool,
1617
SearchTool,
1718
VersionNotFoundError,
@@ -48,6 +49,7 @@ export async function startServer() {
4849
listJobs: new ListJobsTool(pipelineManager),
4950
getJobInfo: new GetJobInfoTool(pipelineManager),
5051
cancelJob: new CancelJobTool(pipelineManager),
52+
remove: new RemoveTool(docService), // Instantiate RemoveTool
5153
};
5254

5355
const server = new McpServer(
@@ -64,7 +66,7 @@ export async function startServer() {
6466
},
6567
);
6668

67-
// --- Existing Tool Definitions ---
69+
// --- Tool Definitions ---
6870

6971
// Scrape docs tool (Keep as is for now, but likely needs ScrapeTool refactor)
7072
server.tool(
@@ -333,6 +335,34 @@ ${formattedResults.join("")}`,
333335
},
334336
);
335337

338+
// Remove docs tool
339+
server.tool(
340+
"remove_docs",
341+
"Remove indexed documentation for a library version.",
342+
{
343+
library: z.string().describe("Name of the library"),
344+
version: z
345+
.string()
346+
.optional()
347+
.describe("Version of the library (optional, removes unversioned if omitted)"),
348+
},
349+
async ({ library, version }) => {
350+
try {
351+
// Execute the remove tool logic
352+
const result = await tools.remove.execute({ library, version });
353+
// Use the message from the tool's successful execution
354+
return createResponse(result.message);
355+
} catch (error) {
356+
// Catch errors thrown by the RemoveTool's execute method
357+
return createError(
358+
`Failed to remove documents: ${
359+
error instanceof Error ? error.message : String(error)
360+
}`,
361+
);
362+
}
363+
},
364+
);
365+
336366
server.prompt(
337367
"docs",
338368
"Search indexed documentation",

src/tools/RemoveTool.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import type { MockedObject } from "vitest"; // Import MockedObject
3+
import type { DocumentManagementService } from "../store";
4+
import { RemoveTool, type RemoveToolArgs } from "./RemoveTool";
5+
import { ToolError } from "./errors";
6+
7+
// Create a properly typed mock using MockedObject
8+
const mockDocService = {
9+
removeAllDocuments: vi.fn(),
10+
// Add other methods used by DocumentManagementService if needed, mocking them with vi.fn()
11+
} as MockedObject<DocumentManagementService>;
12+
13+
describe("RemoveTool", () => {
14+
let removeTool: RemoveTool;
15+
16+
beforeEach(() => {
17+
// Reset mocks before each test
18+
vi.resetAllMocks(); // Resets all mocks, including those on mockDocService
19+
removeTool = new RemoveTool(mockDocService); // Pass the typed mock
20+
});
21+
22+
it("should call removeAllDocuments with library and version", async () => {
23+
const args: RemoveToolArgs = { library: "react", version: "18.2.0" };
24+
// Now TypeScript knows mockDocService.removeAllDocuments is a mock function
25+
mockDocService.removeAllDocuments.mockResolvedValue(undefined);
26+
27+
const result = await removeTool.execute(args);
28+
29+
expect(mockDocService.removeAllDocuments).toHaveBeenCalledTimes(1);
30+
expect(mockDocService.removeAllDocuments).toHaveBeenCalledWith("react", "18.2.0");
31+
expect(result).toEqual({
32+
message: "Successfully removed documents for react@18.2.0.",
33+
});
34+
});
35+
36+
it("should call removeAllDocuments with library and undefined version for unversioned", async () => {
37+
const args: RemoveToolArgs = { library: "lodash" };
38+
mockDocService.removeAllDocuments.mockResolvedValue(undefined);
39+
40+
const result = await removeTool.execute(args);
41+
42+
expect(mockDocService.removeAllDocuments).toHaveBeenCalledTimes(1);
43+
expect(mockDocService.removeAllDocuments).toHaveBeenCalledWith("lodash", undefined);
44+
expect(result).toEqual({
45+
message: "Successfully removed documents for lodash (unversioned).",
46+
});
47+
});
48+
49+
it("should handle empty string version as unversioned", async () => {
50+
const args: RemoveToolArgs = { library: "moment", version: "" };
51+
mockDocService.removeAllDocuments.mockResolvedValue(undefined);
52+
53+
const result = await removeTool.execute(args);
54+
55+
expect(mockDocService.removeAllDocuments).toHaveBeenCalledTimes(1);
56+
expect(mockDocService.removeAllDocuments).toHaveBeenCalledWith("moment", "");
57+
expect(result).toEqual({
58+
message: "Successfully removed documents for moment (unversioned).",
59+
});
60+
});
61+
62+
it("should throw ToolError if removeAllDocuments fails", async () => {
63+
const args: RemoveToolArgs = { library: "vue", version: "3.0.0" };
64+
const testError = new Error("Database connection failed");
65+
mockDocService.removeAllDocuments.mockRejectedValue(testError);
66+
67+
// Use try-catch to ensure the mock call check happens even after rejection
68+
try {
69+
await removeTool.execute(args);
70+
} catch (e) {
71+
expect(e).toBeInstanceOf(ToolError);
72+
expect((e as ToolError).message).toContain(
73+
"Failed to remove documents for vue@3.0.0: Database connection failed",
74+
);
75+
}
76+
// Verify the call happened
77+
expect(mockDocService.removeAllDocuments).toHaveBeenCalledWith("vue", "3.0.0");
78+
});
79+
80+
it("should throw ToolError with correct message for unversioned failure", async () => {
81+
const args: RemoveToolArgs = { library: "angular" };
82+
const testError = new Error("Filesystem error");
83+
mockDocService.removeAllDocuments.mockRejectedValue(testError);
84+
85+
// Use try-catch to ensure the mock call check happens even after rejection
86+
try {
87+
await removeTool.execute(args);
88+
} catch (e) {
89+
expect(e).toBeInstanceOf(ToolError);
90+
expect((e as ToolError).message).toContain(
91+
"Failed to remove documents for angular (unversioned): Filesystem error",
92+
);
93+
}
94+
// Verify the call happened
95+
expect(mockDocService.removeAllDocuments).toHaveBeenCalledWith("angular", undefined);
96+
});
97+
});

src/tools/RemoveTool.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { JSONSchema7 } from "json-schema";
2+
import type { DocumentManagementService } from "../store";
3+
import { logger } from "../utils/logger";
4+
import { ToolError } from "./errors"; // Keep ToolError for potential internal errors
5+
6+
/**
7+
* Input schema for the remove_docs tool.
8+
*/
9+
export const RemoveToolInputSchema: JSONSchema7 = {
10+
type: "object",
11+
properties: {
12+
library: {
13+
type: "string",
14+
description: "Name of the library",
15+
},
16+
version: {
17+
type: "string",
18+
description: "Version of the library (optional, removes unversioned if omitted)",
19+
},
20+
},
21+
required: ["library"],
22+
additionalProperties: false,
23+
};
24+
25+
/**
26+
* Represents the arguments for the remove_docs tool.
27+
* The MCP server should validate the input against RemoveToolInputSchema before calling execute.
28+
*/
29+
export interface RemoveToolArgs {
30+
library: string;
31+
version?: string;
32+
}
33+
34+
/**
35+
* Tool to remove indexed documentation for a specific library version.
36+
* This class provides the core logic, intended to be called by the McpServer.
37+
*/
38+
export class RemoveTool {
39+
readonly name = "remove_docs";
40+
readonly description = "Remove indexed documentation for a library version.";
41+
readonly inputSchema = RemoveToolInputSchema;
42+
43+
constructor(private readonly documentManagementService: DocumentManagementService) {}
44+
45+
/**
46+
* Executes the tool to remove the specified library version documents.
47+
* Assumes args have been validated by the caller (McpServer) against inputSchema.
48+
* Returns a simple success message or throws an error.
49+
*/
50+
async execute(args: RemoveToolArgs): Promise<{ message: string }> {
51+
const { library, version } = args;
52+
53+
logger.info(
54+
`Executing ${this.name} for library: ${library}${version ? `, version: ${version}` : " (unversioned)"}`,
55+
);
56+
57+
try {
58+
// Core logic: Call the document management service
59+
await this.documentManagementService.removeAllDocuments(library, version);
60+
61+
const message = `Successfully removed documents for ${library}${version ? `@${version}` : " (unversioned)"}.`;
62+
logger.info(message);
63+
// Return a simple success object, the McpServer will format the final response
64+
return { message };
65+
} catch (error) {
66+
const errorMessage = `Failed to remove documents for ${library}${version ? `@${version}` : " (unversioned)"}: ${error instanceof Error ? error.message : String(error)}`;
67+
logger.error(`Error executing ${this.name}: ${errorMessage}`);
68+
// Re-throw the error for the McpServer to handle and format
69+
throw new ToolError(errorMessage, this.name);
70+
}
71+
}
72+
}

src/tools/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ export * from "./ScrapeTool";
55
export * from "./ListJobsTool";
66
export * from "./GetJobInfoTool";
77
export * from "./CancelJobTool";
8+
export * from "./RemoveTool";
89
export * from "./errors";

0 commit comments

Comments
 (0)