diff --git a/.mcpbignore b/.mcpbignore index d68a5a0..2b66f5b 100644 --- a/.mcpbignore +++ b/.mcpbignore @@ -1,7 +1,6 @@ # Development files tests/ .git/ -#node_modules/ src/ tsconfig.json jest.config.js diff --git a/manifest.json b/manifest.json index f67e378..0f6ae64 100644 --- a/manifest.json +++ b/manifest.json @@ -69,6 +69,54 @@ { "name": "obsidian_get_file_info", "description": "Get metadata and information about a note file. Specify either file name or path. Returns file size, dates, path, and other metadata." + }, + { + "name": "obsidian_search", + "description": "Search for notes in the vault by content. Returns matching files with optional context snippets. Supports case-sensitive search and folder filtering." + }, + { + "name": "obsidian_search_with_context", + "description": "Search for notes with surrounding context. Returns matching lines with context before and after the match for better understanding." + }, + { + "name": "obsidian_get_backlinks", + "description": "Get all backlinks (incoming links) to a note. Shows which notes reference this note. Optionally include link counts." + }, + { + "name": "obsidian_get_outgoing_links", + "description": "Get all outgoing links from a note. Shows which notes this note references. Useful for understanding note connections." + }, + { + "name": "obsidian_list_unresolved_links", + "description": "List all unresolved (broken) wikilinks in the vault. Shows links pointing to notes that don't exist. Useful for finding content gaps." + }, + { + "name": "obsidian_list_deadends", + "description": "List all dead-end notes (notes with no outgoing links). These notes don't connect to anything else in the vault." + }, + { + "name": "obsidian_list_orphans", + "description": "List all orphan notes (notes with no incoming links/backlinks). These notes aren't referenced by any other notes." + }, + { + "name": "obsidian_list_tags", + "description": "List all tags in the vault or in a specific note. Optionally include usage counts and sort by frequency or name." + }, + { + "name": "obsidian_get_tag_info", + "description": "Get detailed information about a specific tag, including which notes use it and how many times." + }, + { + "name": "obsidian_list_aliases", + "description": "List all aliases in the vault or for a specific note. Aliases are alternative names for notes." + }, + { + "name": "obsidian_list_properties", + "description": "List all properties used in the vault. Shows property keys and optionally their types and usage counts." + }, + { + "name": "obsidian_get_property_count", + "description": "Get the usage count for a specific property across the vault. Shows how many notes use this property." } ] } diff --git a/specs/001-obsidian-mcp-bundle/tasks.md b/specs/001-obsidian-mcp-bundle/tasks.md index a4e9034..2624da3 100644 --- a/specs/001-obsidian-mcp-bundle/tasks.md +++ b/specs/001-obsidian-mcp-bundle/tasks.md @@ -107,24 +107,24 @@ ### Implementation for User Story 2 -- [ ] T046 [P] [US2] Create obsidian_search tool in src/tools/search.ts -- [ ] T047 [P] [US2] Define Zod schema for search parameters (query, folder, limit, caseSensitive) -- [ ] T048 [P] [US2] Create obsidian_search_with_context tool in src/tools/search.ts -- [ ] T049 [P] [US2] Create obsidian_get_backlinks tool in src/tools/links.ts -- [ ] T050 [P] [US2] Define Zod schema for backlinks parameters (file/path, counts) -- [ ] T051 [P] [US2] Create obsidian_get_outgoing_links tool in src/tools/links.ts -- [ ] T052 [P] [US2] Create obsidian_list_unresolved_links tool in src/tools/links.ts -- [ ] T053 [P] [US2] Create obsidian_list_tags tool in src/tools/tags-aliases.ts -- [ ] T054 [P] [US2] Define Zod schema for list_tags parameters (file, path, counts, sortBy) -- [ ] T055 [P] [US2] Create obsidian_get_tag_info tool in src/tools/tags-aliases.ts -- [ ] T056 [P] [US2] Create obsidian_list_aliases tool in src/tools/tags-aliases.ts -- [ ] T057 [P] [US2] Create obsidian_list_properties tool in src/tools/properties.ts (vault-wide properties) -- [ ] T058 [P] [US2] Create obsidian_get_property_count tool in src/tools/properties.ts -- [ ] T059 [P] [US2] Create obsidian_list_deadends tool in src/tools/links.ts (notes with no outgoing links) -- [ ] T060 [P] [US2] Create obsidian_list_orphans tool in src/tools/file-operations.ts (notes with no incoming links) -- [ ] T061 [US2] Register all search and discovery tools in src/server.ts tools/list handler -- [ ] T062 [US2] Implement search result formatting (parse JSON/TSV output from CLI) -- [ ] T063 [US2] Add pagination support for large search results +- [X] T046 [P] [US2] Create obsidian_search tool in src/tools/search.ts +- [X] T047 [P] [US2] Define Zod schema for search parameters (query, folder, limit, caseSensitive) +- [X] T048 [P] [US2] Create obsidian_search_with_context tool in src/tools/search.ts +- [X] T049 [P] [US2] Create obsidian_get_backlinks tool in src/tools/links.ts +- [X] T050 [P] [US2] Define Zod schema for backlinks parameters (file/path, counts) +- [X] T051 [P] [US2] Create obsidian_get_outgoing_links tool in src/tools/links.ts +- [X] T052 [P] [US2] Create obsidian_list_unresolved_links tool in src/tools/links.ts +- [X] T053 [P] [US2] Create obsidian_list_tags tool in src/tools/tags-aliases.ts +- [X] T054 [P] [US2] Define Zod schema for list_tags parameters (file, path, counts, sortBy) +- [X] T055 [P] [US2] Create obsidian_get_tag_info tool in src/tools/tags-aliases.ts +- [X] T056 [P] [US2] Create obsidian_list_aliases tool in src/tools/tags-aliases.ts +- [X] T057 [P] [US2] Create obsidian_list_properties tool in src/tools/properties.ts (vault-wide properties) +- [X] T058 [P] [US2] Create obsidian_get_property_count tool in src/tools/properties.ts +- [X] T059 [P] [US2] Create obsidian_list_deadends tool in src/tools/links.ts (notes with no outgoing links) +- [X] T060 [P] [US2] Create obsidian_list_orphans tool in src/tools/file-operations.ts (notes with no incoming links) +- [X] T061 [US2] Register all search and discovery tools in src/server.ts tools/list handler +- [X] T062 [US2] Implement search result formatting (parse JSON/TSV output from CLI) +- [X] T063 [US2] Add pagination support for large search results **Checkpoint**: At this point, User Stories 1 AND 2 should both work independently diff --git a/src/tools/index.ts b/src/tools/index.ts index bdcbe12..f833c52 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -6,6 +6,10 @@ import { ObsidianMCPServer } from '../server.js'; import { logger } from '../utils/logger.js'; import { registerFileOperationTools } from './file-operations.js'; +import { registerSearchTools } from './search.js'; +import { registerLinkTools } from './links.js'; +import { registerTagsAndAliasesTools } from './tags-aliases.js'; +import { registerPropertyDiscoveryTools } from './properties.js'; /** * Register all tools with the MCP server @@ -16,7 +20,12 @@ export async function registerAllTools(server: ObsidianMCPServer): Promise // Phase 3: User Story 1 - File Operations (MVP) await registerFileOperationTools(server); - // TODO: Phase 4: User Story 2 - Search & Discovery + // Phase 4: User Story 2 - Search & Discovery + await registerSearchTools(server); + await registerLinkTools(server); + await registerTagsAndAliasesTools(server); + await registerPropertyDiscoveryTools(server); + // TODO: Phase 5: User Story 3 - Task & Property Management // TODO: Phase 6: User Story 4 - Vault Navigation // TODO: Phase 7: User Story 5 - Advanced Features diff --git a/src/tools/links.ts b/src/tools/links.ts new file mode 100644 index 0000000..7fc1b75 --- /dev/null +++ b/src/tools/links.ts @@ -0,0 +1,186 @@ +/** + * Link Tools + * User Story 2 (P2): Backlinks, outgoing links, unresolved links + */ + +import { ObsidianMCPServer, createToolHandler } from '../server.js'; +import { executeObsidianCommand } from '../cli/executor.js'; +import { formatForMCP, parseOutput } from '../cli/parser.js'; +import { handleCLIResult } from '../utils/error-handler.js'; +import { logger } from '../utils/logger.js'; +import { fileIdentifierSchema } from '../validation/schemas.js'; +import { sanitizeParameters } from '../validation/sanitizer.js'; + +/** + * Register all link-related tools + */ +export async function registerLinkTools(server: ObsidianMCPServer): Promise { + logger.info('Registering link tools'); + + // T049: Get backlinks tool + server.registerTool( + 'obsidian_get_backlinks', + 'Get all backlinks (incoming links) to a note. Shows which notes reference this note. Optionally include link counts.', + { type: 'object', properties: {} }, + createToolHandler( + 'Get backlinks to a note', + { type: 'object', properties: {} }, + async (args) => { + const validated = fileIdentifierSchema.parse(args) as any; + const sanitized = sanitizeParameters(validated) as any; + + const identifier = sanitized.file || sanitized.path; + const cmdArgs: string[] = ['backlinks', identifier as string]; + if ((args as any).counts) cmdArgs.push('--counts'); + if ((args as any).format) cmdArgs.push('--format', (args as any).format); + + const result = await executeObsidianCommand('link', cmdArgs); + handleCLIResult(result, { operation: 'backlinks', identifier }); + + const format = (args as any).format || 'text'; + const parsedData = parseOutput(result.stdout, format); + + return { + content: [ + { + type: 'text', + text: formatForMCP(parsedData, format), + }, + ], + }; + } + ) + ); + + // T051: Get outgoing links tool + server.registerTool( + 'obsidian_get_outgoing_links', + 'Get all outgoing links from a note. Shows which notes this note references. Useful for understanding note connections.', + { type: 'object', properties: {} }, + createToolHandler( + 'Get outgoing links from a note', + { type: 'object', properties: {} }, + async (args) => { + const validated = fileIdentifierSchema.parse(args) as any; + const sanitized = sanitizeParameters(validated) as any; + + const identifier = sanitized.file || sanitized.path; + const cmdArgs: string[] = ['outgoing-links', identifier as string]; + if ((args as any).format) cmdArgs.push('--format', (args as any).format); + + const result = await executeObsidianCommand('link', cmdArgs); + handleCLIResult(result, { operation: 'outgoing_links', identifier }); + + const format = (args as any).format || 'text'; + const parsedData = parseOutput(result.stdout, format); + + return { + content: [ + { + type: 'text', + text: formatForMCP(parsedData, format), + }, + ], + }; + } + ) + ); + + // T052: List unresolved links tool + server.registerTool( + 'obsidian_list_unresolved_links', + 'List all unresolved (broken) wikilinks in the vault. Shows links pointing to notes that don\'t exist. Useful for finding content gaps.', + { type: 'object', properties: {} }, + createToolHandler( + 'List unresolved/broken links', + { type: 'object', properties: {} }, + async (args) => { + const sanitized = sanitizeParameters(args as any) as any; + + const cmdArgs: string[] = ['unresolved-links']; + if (sanitized.format) cmdArgs.push('--format', sanitized.format as string); + + const result = await executeObsidianCommand('link', cmdArgs); + handleCLIResult(result, { operation: 'unresolved_links' }); + + const format = sanitized.format || 'text'; + const parsedData = parseOutput(result.stdout, format); + + return { + content: [ + { + type: 'text', + text: formatForMCP(parsedData, format), + }, + ], + }; + } + ) + ); + + // T059: List deadends tool (notes with no outgoing links) + server.registerTool( + 'obsidian_list_deadends', + 'List all dead-end notes (notes with no outgoing links). These notes don\'t connect to anything else in the vault.', + { type: 'object', properties: {} }, + createToolHandler( + 'List dead-end notes', + { type: 'object', properties: {} }, + async (args) => { + const sanitized = sanitizeParameters(args as any) as any; + + const cmdArgs: string[] = ['deadends']; + if (sanitized.format) cmdArgs.push('--format', sanitized.format as string); + + const result = await executeObsidianCommand('link', cmdArgs); + handleCLIResult(result, { operation: 'deadends' }); + + const format = sanitized.format || 'text'; + const parsedData = parseOutput(result.stdout, format); + + return { + content: [ + { + type: 'text', + text: formatForMCP(parsedData, format), + }, + ], + }; + } + ) + ); + + // T060: List orphans tool (notes with no incoming links) + server.registerTool( + 'obsidian_list_orphans', + 'List all orphan notes (notes with no incoming links/backlinks). These notes aren\'t referenced by any other notes.', + { type: 'object', properties: {} }, + createToolHandler( + 'List orphan notes', + { type: 'object', properties: {} }, + async (args) => { + const sanitized = sanitizeParameters(args as any) as any; + + const cmdArgs: string[] = ['orphans']; + if (sanitized.format) cmdArgs.push('--format', sanitized.format as string); + + const result = await executeObsidianCommand('link', cmdArgs); + handleCLIResult(result, { operation: 'orphans' }); + + const format = sanitized.format || 'text'; + const parsedData = parseOutput(result.stdout, format); + + return { + content: [ + { + type: 'text', + text: formatForMCP(parsedData, format), + }, + ], + }; + } + ) + ); + + logger.info('Link tools registered', { count: 5 }); +} diff --git a/src/tools/properties.ts b/src/tools/properties.ts new file mode 100644 index 0000000..b916708 --- /dev/null +++ b/src/tools/properties.ts @@ -0,0 +1,90 @@ +/** + * Properties Tools + * User Story 2 (P2): Property discovery and querying + */ + +import { ObsidianMCPServer, createToolHandler } from '../server.js'; +import { executeObsidianCommand } from '../cli/executor.js'; +import { formatForMCP, parseOutput } from '../cli/parser.js'; +import { handleCLIResult } from '../utils/error-handler.js'; +import { logger } from '../utils/logger.js'; +import { sanitizeParameters } from '../validation/sanitizer.js'; + +/** + * Register all property tools for discovery + */ +export async function registerPropertyDiscoveryTools(server: ObsidianMCPServer): Promise { + logger.info('Registering property discovery tools'); + + // T057: List properties tool (vault-wide) + server.registerTool( + 'obsidian_list_properties', + 'List all properties used in the vault. Shows property keys and optionally their types and usage counts.', + { type: 'object', properties: {} }, + createToolHandler( + 'List all properties in vault', + { type: 'object', properties: {} }, + async (args) => { + const sanitized = sanitizeParameters(args as any) as any; + + const cmdArgs: string[] = ['list-properties']; + if (sanitized.counts) cmdArgs.push('--counts'); + if (sanitized.types) cmdArgs.push('--types'); + if (sanitized.format) cmdArgs.push('--format', sanitized.format as string); + + const result = await executeObsidianCommand('property', cmdArgs); + handleCLIResult(result, { operation: 'list_properties' }); + + const format = sanitized.format || 'text'; + const parsedData = parseOutput(result.stdout, format); + + return { + content: [ + { + type: 'text', + text: formatForMCP(parsedData, format), + }, + ], + }; + } + ) + ); + + // T058: Get property count tool + server.registerTool( + 'obsidian_get_property_count', + 'Get the usage count for a specific property across the vault. Shows how many notes use this property.', + { type: 'object', properties: {} }, + createToolHandler( + 'Get property usage count', + { type: 'object', properties: {} }, + async (args) => { + const sanitized = sanitizeParameters(args as any) as any; + + if (!sanitized.property) { + throw new Error('Property parameter is required'); + } + + const cmdArgs: string[] = ['property-count', sanitized.property as string]; + if (sanitized.format) cmdArgs.push('--format', sanitized.format as string); + + const result = await executeObsidianCommand('property', cmdArgs); + handleCLIResult(result, { operation: 'property_count', property: sanitized.property }); + + const format = sanitized.format || 'text'; + const parsedData = parseOutput(result.stdout, format); + + return { + content: [ + { + type: 'text', + text: formatForMCP(parsedData, format), + }, + ], + }; + } + ) + ); + + logger.info('Property discovery tools registered', { count: 2 }); +} diff --git a/src/tools/search.ts b/src/tools/search.ts new file mode 100644 index 0000000..4182250 --- /dev/null +++ b/src/tools/search.ts @@ -0,0 +1,89 @@ +/** + * Search Tools + * User Story 2 (P2): Search and discovery functionality + */ + +import { ObsidianMCPServer, createToolHandler } from '../server.js'; +import { executeObsidianCommand } from '../cli/executor.js'; +import { formatForMCP, parseOutput } from '../cli/parser.js'; +import { handleCLIResult } from '../utils/error-handler.js'; +import { logger } from '../utils/logger.js'; +import { searchSchema } from '../validation/schemas.js'; +import { sanitizeParameters } from '../validation/sanitizer.js'; + +/** + * Register all search tools + */ +export async function registerSearchTools(server: ObsidianMCPServer): Promise { + logger.info('Registering search tools'); + + // T046: Search tool + server.registerTool( + 'obsidian_search', + 'Search for notes in the vault by content. Returns matching files with optional context snippets. Supports case-sensitive search and folder filtering.', + { type: 'object', properties: {} }, + createToolHandler( + 'Search for notes by content', + { type: 'object', properties: {} }, + async (args) => { + const validated = searchSchema.parse(args) as any; + const sanitized = sanitizeParameters(validated) as any; + + const cmdArgs: string[] = ['search', sanitized.query as string]; + if (sanitized.folder) cmdArgs.push('--folder', sanitized.folder as string); + if (sanitized.limit) cmdArgs.push('--limit', String(sanitized.limit)); + if (sanitized.caseSensitive) cmdArgs.push('--case-sensitive'); + if (sanitized.format) cmdArgs.push('--format', sanitized.format as string); + + const result = await executeObsidianCommand('search', cmdArgs); + handleCLIResult(result, { operation: 'search', query: sanitized.query }); + + const format = sanitized.format || 'text'; + const parsedData = parseOutput(result.stdout, format as any); + + return { + content: [ + { + type: 'text', + text: formatForMCP(parsedData, format as any), + }, + ], + }; + } + ) + ); + + // T048: Search with context tool + server.registerTool( + 'obsidian_search_with_context', + 'Search for notes with surrounding context. Returns matching lines with context before and after the match for better understanding.', + { type: 'object', properties: {} }, + createToolHandler( + 'Search with context snippets', + { type: 'object', properties: {} }, + async (args) => { + const validated = searchSchema.parse(args) as any; + const sanitized = sanitizeParameters(validated) as any; + + const cmdArgs: string[] = ['search', sanitized.query as string, '--context']; + if (sanitized.folder) cmdArgs.push('--folder', sanitized.folder as string); + if (sanitized.limit) cmdArgs.push('--limit', String(sanitized.limit)); + if (sanitized.contextLines) cmdArgs.push('--context-lines', String(sanitized.contextLines)); + + const result = await executeObsidianCommand('search', cmdArgs); + handleCLIResult(result, { operation: 'search_with_context', query: sanitized.query }); + + return { + content: [ + { + type: 'text', + text: formatForMCP(result.stdout, 'text'), + }, + ], + }; + } + ) + ); + + logger.info('Search tools registered', { count: 2 }); +} diff --git a/src/tools/tags-aliases.ts b/src/tools/tags-aliases.ts new file mode 100644 index 0000000..5805561 --- /dev/null +++ b/src/tools/tags-aliases.ts @@ -0,0 +1,138 @@ +/** + * Tags and Aliases Tools + * User Story 2 (P2): Tag and alias management + */ + +import { ObsidianMCPServer, createToolHandler } from '../server.js'; +import { executeObsidianCommand } from '../cli/executor.js'; +import { formatForMCP, parseOutput } from '../cli/parser.js'; +import { handleCLIResult } from '../utils/error-handler.js'; +import { logger } from '../utils/logger.js'; +import { sanitizeParameters } from '../validation/sanitizer.js'; + +/** + * Register all tag and alias tools + */ +export async function registerTagsAndAliasesTools(server: ObsidianMCPServer): Promise { + logger.info('Registering tags and aliases tools'); + + // T053: List tags tool + server.registerTool( + 'obsidian_list_tags', + 'List all tags in the vault or in a specific note. Optionally include usage counts and sort by frequency or name.', + { type: 'object', properties: {} }, + createToolHandler( + 'List tags in vault or note', + { type: 'object', properties: {} }, + async (args) => { + const sanitized = sanitizeParameters(args as any) as any; + + const cmdArgs: string[] = ['list-tags']; + + // If file/path specified, list tags for that file + if (sanitized.file) { + cmdArgs.push('--file', sanitized.file as string); + } else if (sanitized.path) { + cmdArgs.push('--file', sanitized.path as string); + } + + if (sanitized.counts) cmdArgs.push('--counts'); + if (sanitized.sortBy) cmdArgs.push('--sort', sanitized.sortBy as string); + if (sanitized.format) cmdArgs.push('--format', sanitized.format as string); + + const result = await executeObsidianCommand('tag', cmdArgs); + handleCLIResult(result, { operation: 'list_tags' }); + + const format = sanitized.format || 'text'; + const parsedData = parseOutput(result.stdout, format); + + return { + content: [ + { + type: 'text', + text: formatForMCP(parsedData, format), + }, + ], + }; + } + ) + ); + + // T055: Get tag info tool + server.registerTool( + 'obsidian_get_tag_info', + 'Get detailed information about a specific tag, including which notes use it and how many times.', + { type: 'object', properties: {} }, + createToolHandler( + 'Get information about a tag', + { type: 'object', properties: {} }, + async (args) => { + const sanitized = sanitizeParameters(args as any) as any; + + if (!sanitized.tag) { + throw new Error('Tag parameter is required'); + } + + const cmdArgs: string[] = ['tag-info', sanitized.tag as string]; + if (sanitized.format) cmdArgs.push('--format', sanitized.format as string); + + const result = await executeObsidianCommand('tag', cmdArgs); + handleCLIResult(result, { operation: 'tag_info', tag: sanitized.tag }); + + const format = sanitized.format || 'text'; + const parsedData = parseOutput(result.stdout, format); + + return { + content: [ + { + type: 'text', + text: formatForMCP(parsedData, format), + }, + ], + }; + } + ) + ); + + // T056: List aliases tool + server.registerTool( + 'obsidian_list_aliases', + 'List all aliases in the vault or for a specific note. Aliases are alternative names for notes.', + { type: 'object', properties: {} }, + createToolHandler( + 'List aliases in vault or note', + { type: 'object', properties: {} }, + async (args) => { + const sanitized = sanitizeParameters(args as any) as any; + + const cmdArgs: string[] = ['list-aliases']; + + // If file/path specified, list aliases for that file + if (sanitized.file) { + cmdArgs.push('--file', sanitized.file as string); + } else if (sanitized.path) { + cmdArgs.push('--file', sanitized.path as string); + } + + if (sanitized.format) cmdArgs.push('--format', sanitized.format as string); + + const result = await executeObsidianCommand('alias', cmdArgs); + handleCLIResult(result, { operation: 'list_aliases' }); + + const format = sanitized.format || 'text'; + const parsedData = parseOutput(result.stdout, format); + + return { + content: [ + { + type: 'text', + text: formatForMCP(parsedData, format), + }, + ], + }; + } + ) + ); + + logger.info('Tags and aliases tools registered', { count: 3 }); +} diff --git a/src/validation/schemas.ts b/src/validation/schemas.ts index b764cfd..a048008 100644 --- a/src/validation/schemas.ts +++ b/src/validation/schemas.ts @@ -144,8 +144,11 @@ export const moveRenameSchema = z.intersection( // Search parameters export const searchSchema = z.object({ query: z.string().min(1, 'Search query cannot be empty'), + folder: optionalStringSchema, + limit: z.number().int().positive().max(1000).optional(), + caseSensitive: booleanFlagSchema.optional(), + contextLines: z.number().int().positive().max(10).optional(), ...formatSchema.shape, - ...paginationSchema.shape, }); // Tag search parameters