Organize Attachments
organize_attachmentsScans a markdown file for linked attachments, moves them into a folder named after the document title, and updates the links automatically.
Instructions
Scans a specified markdown file for linked images (or other attachments), moves them to a dedicated folder named after the document's title, and updates the links within the markdown file automatically.
Use Cases:
When a post is finalized and you want to clean up all associated images into a neat folder.
To automatically organize attachments for better vault management.
Example Workflow:
Specify 'my-awesome-post.md' as the fileName.
The tool finds the 'title' property in the frontmatter (e.g., "My Awesome Post").
It finds all image links like ![[my-image.png]].
It creates a folder at '{vault}/images/My Awesome Post/'.
It moves 'my-image.png' into that new folder.
It updates the link in the markdown file to ![[images/My Awesome Post/my-image.png]].
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| keyword | Yes | A keyword to search for the markdown file within the vault. | |
| destination | No | The base folder to move attachments into. Defaults to "images". | images |
| useTitleAsFolderName | No | If true, creates a subfolder named after the document title. Defaults to true. | |
| quiet | No | If true, returns a minimal success message. |
Implementation Reference
- Main handler (execute) for the organize_attachments tool. It initializes the vault manager, searches documents by keyword, generates organization tasks, executes them, and returns results.
export const execute = async ( params: OrganizeAttachmentsParams, ): Promise<CallToolResult> => { const vaultDirPath = state.vaultPath; if (!vaultDirPath) { return createToolError( "VAULT_DIR_PATH is not set", "Set the VAULT_DIR_PATH environment variable to your Obsidian vault path.", ); } let vaultManager = null; try { vaultManager = getGlobalVaultManager(); } catch (e) { return createToolError((e as Error).message); } try { await vaultManager.initialize(); const documents = await vaultManager.searchDocuments(params.keyword); if (documents.length === 0) { return createToolError( `No document found for keyword: ${params.keyword}`, "Try a different keyword or use the vault tool with 'list_all' action to see available documents.", ); } const organizationTasks = genreateOrganizationTasks( documents, vaultDirPath, ); const results = await Promise.all(organizationTasks.map((task) => task())); return { isError: false, content: [ { type: "text", text: JSON.stringify( { summary: `Processed ${documents.length} document(s).`, details: results, }, null, 2, ), }, ], }; } catch (error) { return createToolError((error as Error).message); } }; - Helper function genreateOrganizationTasks that generates per-document async tasks: moves image attachments to a title-based folder and updates markdown links.
export function genreateOrganizationTasks( documents: DocumentIndex[], vaultDirPath: string, ) { return documents.map((doc) => async () => { const docTitle = doc.frontmatter?.title || basename(doc.filePath, ".md"); const sanitizedTitle = docTitle.replace(/[\\?%*:|"<>]/g, "-"); const imageLinks = doc.imageLinks || []; if (imageLinks.length === 0) { return { document: doc.filePath, status: "skipped", message: "No image links found.", movedFiles: [], }; } const destinationFolder = "images"; const targetDir = join(destinationFolder, sanitizedTitle); const targetDirFullPath = join(vaultDirPath, targetDir); await mkdir(targetDirFullPath, { recursive: true }); const docDir = dirname(doc.filePath); const movePromises = imageLinks.map( async (imageName): Promise<MoveResult> => { const originalImagePath = join(vaultDirPath, imageName); const originalImagePathInDocDir = join(docDir, imageName); let sourcePath: string; if (existsSync(originalImagePath)) { sourcePath = originalImagePath; } else if (existsSync(originalImagePathInDocDir)) { sourcePath = originalImagePathInDocDir; } else { return { imageName, status: "error", reason: "File not found" }; } const newImageName = basename(imageName); const newImagePath = join(targetDirFullPath, newImageName); const newLinkPath = join(targetDir, newImageName).replace(/\\/g, "/"); try { await rename(sourcePath, newImagePath); return { originalLink: `![[${imageName}]]`, newLink: `![[${newLinkPath}]]`, status: "moved", }; } catch (e) { return { imageName, status: "error", reason: (e as Error).message }; } }, ); const moveResults = await Promise.all(movePromises); let content = await readFile(doc.filePath, "utf-8"); const successfullyMoved = moveResults.filter( (result): result is MoveSuccess => result.status === "moved", ); for (const result of successfullyMoved) { content = content.replace(result.originalLink, result.newLink); } await writeFile(doc.filePath, content, "utf-8"); return { document: doc.filePath, status: "success", targetDirectory: targetDir, movedFiles: successfullyMoved.length, errors: moveResults.filter((r) => r.status === "error"), }; }); } - Zod schema for input parameters: keyword (required), destination (default 'images'), useTitleAsFolderName (default true), quiet (default false).
export const organizeAttachmentsParamsSchema = z .object({ keyword: z .string() .describe("A keyword to search for the markdown file within the vault."), destination: z .string() .optional() .default("images") .describe( 'The base folder to move attachments into. Defaults to "images".', ), useTitleAsFolderName: z .boolean() .optional() .default(true) .describe( "If true, creates a subfolder named after the document title. Defaults to true.", ), quiet: z .boolean() .optional() .default(false) .describe("If true, returns a minimal success message."), }) .describe("Parameters for organizing attachments of a markdown file"); - Zod schemas for output: OrganizeAttachmentsDetailSchema and OrganizeAttachmentsResultSchema defining the shape of the result object.
export const OrganizeAttachmentsDetailSchema = z .object({ document: z .string() .describe("The path of the processed markdown document."), status: z .enum(["skipped", "completed", "success"]) .describe("The status of the operation."), message: z .string() .optional() .describe( "A message providing additional information, especially if skipped.", ), movedFiles: z .number() .optional() .describe( 'The number of files successfully moved. Present if status is "completed".', ), targetDirectory: z .string() .optional() .describe( 'The directory where attachments were moved. Present if status is "completed".', ), errors: z .array( z.object({ imageName: z .string() .describe("The name of the image that failed to move."), reason: z.string().describe("The reason for the failure."), }), ) .optional() .describe( "List of errors encountered during the move operation. Present if any errors occurred.", ), }) .describe("Result of organizing attachments for a single document"); export const OrganizeAttachmentsResultSchema = z.object({ summary: z.string().describe("A summary message of the overall operation."), details: z .array(OrganizeAttachmentsDetailSchema) .describe("Array of results for each processed document."), }); - src/tools/organize_attachments/index.ts:40-51 (registration)Registration function that registers the tool with the MCP server, providing name, description, input schema, annotations, and the execute handler.
export const register = (mcpServer: McpServer) => { mcpServer.registerTool( name, { title: annotations.title || name, description: description, inputSchema: organizeAttachmentsParamsSchema.shape, annotations: annotations, }, execute, ); };