diff --git a/apps/mcp/README.md b/apps/mcp/README.md index fc40803f..dfaff370 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -52,6 +52,7 @@ The client discovers the OAuth authorization server through | `get_profile` | Get stable and recent profile context for a space | | `list_documents` | List document metadata and summaries in a space | | `get_document` | Read one document's available content by ID | +| `delete_document` | Permanently delete a stored document by ID | | `list_memories` | List extracted memory entries and their source document IDs | | `list_spaces` | List spaces visible to the authenticated account | | `who_am_i` | Return identity, access, and active-space context | diff --git a/apps/mcp/e2e/discovery.test.ts b/apps/mcp/e2e/discovery.test.ts index 66c503f2..33f4df1d 100644 --- a/apps/mcp/e2e/discovery.test.ts +++ b/apps/mcp/e2e/discovery.test.ts @@ -9,6 +9,7 @@ import { const EXPECTED_TOOLS = [ "add_memory", + "delete_document", "fetch-graph-data", "get_document", "get_profile", diff --git a/apps/mcp/src/server/analytics.ts b/apps/mcp/src/server/analytics.ts index e92cd82a..3adf9001 100644 --- a/apps/mcp/src/server/analytics.ts +++ b/apps/mcp/src/server/analytics.ts @@ -37,6 +37,7 @@ const TOOL_SURFACES: Record = { get_profile: "model_tool", list_documents: "model_tool", get_document: "model_tool", + delete_document: "model_tool", list_memories: "model_tool", list_spaces: "model_tool", who_am_i: "model_tool", diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index a2ccc8e7..edb407ef 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -144,11 +144,12 @@ export class SupermemoryClient { containerTag?: string, apiUrl = "https://api.supermemory.ai", ) { + const normalizedApiUrl = apiUrl.trim().replace(/\/+$/, "") this.bearerToken = bearerToken - this.apiUrl = apiUrl + this.apiUrl = normalizedApiUrl this.client = new Supermemory({ apiKey: bearerToken, - baseURL: apiUrl, + baseURL: normalizedApiUrl, timeout: FETCH_TIMEOUT_MS, defaultHeaders: { "x-sm-source": MCP_SOURCE }, }) @@ -236,6 +237,21 @@ export class SupermemoryClient { } } + async deleteDocument( + documentId: string, + ): Promise<{ success: boolean; message: string; documentId: string }> { + try { + await this.client.documents.delete(documentId) + return { + success: true, + message: `Document ${documentId} deleted successfully.`, + documentId, + } + } catch (error) { + this.handleOperationError("Delete document request", error) + } + } + async search( query: string, limit = 10, diff --git a/apps/mcp/src/server/tools/delete-document.test.ts b/apps/mcp/src/server/tools/delete-document.test.ts new file mode 100644 index 00000000..984c3a59 --- /dev/null +++ b/apps/mcp/src/server/tools/delete-document.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest" +import { register } from "./delete-document" +import { errorResult, type ToolDeps } from "./types" + +function setupHarness() { + let handler: ((args: { documentId: string }) => Promise) | undefined + let toolConfig: Record | undefined + + const registerTool = vi.fn( + (_name: string, config: Record, fn: typeof handler) => { + toolConfig = config + handler = fn + return {} + }, + ) + + const deleteDocument = vi.fn() + const mockClient = { + deleteDocument, + } + + const deps: Partial = { + server: { registerTool } as unknown as ToolDeps["server"], + getClient: vi.fn().mockReturnValue(mockClient), + errorResult, + } + + register(deps as ToolDeps) + + if (!handler || !toolConfig) { + throw new Error("delete_document tool failed to register") + } + + return { + handler, + toolConfig, + deleteDocument, + } +} + +describe("delete_document tool", () => { + it("registers with destructive memory annotations", () => { + const harness = setupHarness() + expect(harness.toolConfig.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }) + }) + + it("calls client.deleteDocument and returns structured result", async () => { + const harness = setupHarness() + harness.deleteDocument.mockResolvedValueOnce({ + success: true, + message: "Document doc-123 deleted successfully.", + documentId: "doc-123", + }) + + const result = (await harness.handler({ documentId: "doc-123" })) as { + content: Array<{ type: string; text: string }> + structuredContent: { + success: boolean + documentId: string + message: string + } + } + + expect(harness.deleteDocument).toHaveBeenCalledWith("doc-123") + expect(result.structuredContent).toEqual({ + success: true, + documentId: "doc-123", + message: "Document doc-123 deleted successfully.", + }) + expect(result.content[0]?.text).toContain( + "Document doc-123 deleted successfully.", + ) + }) + + it("handles client errors gracefully", async () => { + const harness = setupHarness() + harness.deleteDocument.mockRejectedValueOnce( + new Error("Document not found"), + ) + + const result = (await harness.handler({ + documentId: "doc-missing", + })) as { + content: Array<{ type: string; text: string }> + isError: boolean + } + + expect(result.isError).toBe(true) + expect(result.content[0]?.text).toContain("Document not found") + }) +}) diff --git a/apps/mcp/src/server/tools/delete-document.ts b/apps/mcp/src/server/tools/delete-document.ts new file mode 100644 index 00000000..861e6cb9 --- /dev/null +++ b/apps/mcp/src/server/tools/delete-document.ts @@ -0,0 +1,49 @@ +import { z } from "zod" +import { MEMORY_TOOL_ANNOTATIONS } from "./annotations" +import { + deleteDocumentOutputSchema, + type DeleteDocumentOutput, +} from "./output-schemas" +import { textContent, type ToolDeps } from "./types" + +export function register(deps: ToolDeps) { + const inputSchema = z.object({ + documentId: z + .string() + .min(1, "Document ID is required") + .max(255, "Document ID exceeds maximum length") + .describe( + "Document ID to delete, as returned by list_documents or a memory result", + ), + }) + + deps.server.registerTool( + "delete_document", + { + title: "Delete Document", + description: + "Permanently delete a stored document and its associated memories by document ID. Use list_documents to find the document ID.", + inputSchema, + outputSchema: deleteDocumentOutputSchema, + annotations: MEMORY_TOOL_ANNOTATIONS, + }, + async (args) => { + try { + const client = deps.getClient() + const result = await client.deleteDocument(args.documentId) + const structuredContent: DeleteDocumentOutput = { + success: true, + documentId: args.documentId, + message: result.message, + } + + return { + content: [textContent(result.message)], + structuredContent, + } + } catch (error) { + return deps.errorResult(error) + } + }, + ) +} diff --git a/apps/mcp/src/server/tools/index.ts b/apps/mcp/src/server/tools/index.ts index 232c3932..ed7b2deb 100644 --- a/apps/mcp/src/server/tools/index.ts +++ b/apps/mcp/src/server/tools/index.ts @@ -1,4 +1,5 @@ import * as addMemory from "./add-memory" +import * as deleteDocument from "./delete-document" import * as fetchGraphData from "./fetch-graph-data" import * as getDocument from "./get-document" import * as getProfile from "./get-profile" @@ -21,6 +22,7 @@ export function registerAllTools(deps: ToolDeps) { getProfile.register(deps) listDocuments.register(deps) getDocument.register(deps) + deleteDocument.register(deps) listMemories.register(deps) listContainerTags.register(deps) whoAmI.register(deps) diff --git a/apps/mcp/src/server/tools/output-schemas.ts b/apps/mcp/src/server/tools/output-schemas.ts index 8f10d65a..a364aa85 100644 --- a/apps/mcp/src/server/tools/output-schemas.ts +++ b/apps/mcp/src/server/tools/output-schemas.ts @@ -71,6 +71,14 @@ export const getDocumentOutputSchema = z.object({ export type GetDocumentOutput = z.infer +export const deleteDocumentOutputSchema = z.object({ + success: z.boolean(), + documentId: z.string(), + message: z.string(), +}) + +export type DeleteDocumentOutput = z.infer + export const listDocumentsOutputSchema = z.object({ documents: z.array(documentSummarySchema), pagination: paginationSchema,