obsidian-mcp
- obsidian-mcp
- docs
# Creating New Tools Guide
This guide explains how to create new tools that integrate seamlessly with the existing codebase while following established patterns and best practices.
## Tool Structure Overview
Every tool follows a consistent structure:
1. Input validation using Zod schemas
2. Core functionality implementation
3. Tool factory function that creates the tool interface
4. Standardized error handling and responses
## Step-by-Step Implementation Guide
### 1. Create the Tool Directory
Create a new directory under `src/tools/` with your tool name:
```bash
src/tools/your-tool-name/
└── index.ts
```
### 2. Define the Input Schema
Start by defining a Zod schema for input validation. Always include descriptions for better documentation:
```typescript
const schema = z.object({
param1: z.string()
.min(1, "Parameter cannot be empty")
.describe("Description of what this parameter does"),
param2: z.number()
.min(0)
.describe("Description of numeric constraints"),
optionalParam: z.string()
.optional()
.describe("Optional parameters should have clear descriptions too")
}).strict();
const schemaHandler = createSchemaHandler(schema);
```
### 3. Implement Core Functionality
Create a private async function that implements the tool's core logic:
```typescript
async function performOperation(
vaultPath: string,
param1: string,
param2: number,
optionalParam?: string
): Promise<OperationResult> {
try {
// Implement core functionality
// Use utility functions for common operations
// Handle errors appropriately
return {
success: true,
message: "Operation completed successfully",
// Include relevant details
};
} catch (error) {
if (error instanceof McpError) {
throw error;
}
throw handleFsError(error, 'operation name');
}
}
```
### 4. Create the Tool Factory
Export a factory function that creates the tool interface:
```typescript
export function createYourTool(vaultPath: string): Tool {
if (!vaultPath) {
throw new Error("Vault path is required");
}
return {
name: "your-tool-name",
description: `Clear description of what the tool does.
Examples:
- Basic usage: { "param1": "value", "param2": 42 }
- With options: { "param1": "value", "param2": 42, "optionalParam": "extra" }`,
inputSchema: schemaHandler,
handler: async (args) => {
try {
const validated = schemaHandler.parse(args);
const result = await performOperation(
vaultPath,
validated.param1,
validated.param2,
validated.optionalParam
);
return createToolResponse(formatOperationResult(result));
} catch (error) {
if (error instanceof z.ZodError) {
throw new McpError(
ErrorCode.InvalidRequest,
`Invalid arguments: ${error.errors.map(e => e.message).join(", ")}`
);
}
throw error;
}
}
};
}
```
## Best Practices
### Input Validation
✅ DO:
- Use strict schemas with `.strict()`
- Provide clear error messages for validation
- Include descriptions for all parameters
- Validate paths are within vault when relevant
- Use discriminated unions for operations with different requirements
- Keep validation logic JSON Schema-friendly
#### Handling Conditional Validation
When dealing with operations that have different validation requirements, prefer using discriminated unions over complex refinements:
```typescript
// ✅ DO: Use discriminated unions for different operation types
const deleteSchema = z.object({
operation: z.literal('delete'),
target: z.string(),
content: z.undefined()
}).strict();
const editSchema = z.object({
operation: z.enum(['update', 'append']),
target: z.string(),
content: z.string().min(1)
}).strict();
const schema = z.discriminatedUnion('operation', [
deleteSchema,
editSchema
]);
// ❌ DON'T: Use complex refinements that don't translate well to JSON Schema
const schema = z.object({
operation: z.enum(['delete', 'update', 'append']),
target: z.string(),
content: z.string().optional()
}).superRefine((data, ctx) => {
if (data.operation === 'delete') {
if (data.content !== undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Content not allowed for delete"
});
}
} else if (!data.content) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Content required for non-delete"
});
}
});
```
#### Schema Design Patterns
When designing schemas:
✅ DO:
- Break down complex schemas into smaller, focused schemas
- Use discriminated unions for operations with different requirements
- Keep validation logic simple and explicit
- Consider how schemas will translate to JSON Schema
- Use literal types for precise operation matching
❌ DON'T:
```typescript
// Don't use complex refinements that access parent data
schema.superRefine((val, ctx) => {
const parent = ctx.parent; // Unreliable
});
// Don't mix validation concerns
const schema = z.object({
operation: z.enum(['delete', 'update']),
content: z.string().superRefine((val, ctx) => {
// Don't put operation-specific logic here
})
});
// Don't skip schema validation
const schema = z.object({
path: z.string() // Missing validation and description
});
// Don't allow unsafe paths
const schema = z.object({
path: z.string().describe("File path") // Missing path validation
});
```
### Error Handling
✅ DO:
- Use utility functions for common errors
- Convert filesystem errors to McpErrors
- Provide specific error messages
❌ DON'T:
```typescript
// Don't throw raw errors
catch (error) {
throw error;
}
// Don't ignore validation errors
handler: async (args) => {
const result = await performOperation(args.param); // Missing validation
}
```
### Response Formatting
✅ DO:
- Use response utility functions
- Return standardized result objects
- Include relevant operation details
❌ DON'T:
```typescript
// Don't return raw strings
return createToolResponse("Done"); // Too vague
// Don't skip using proper response types
return {
message: "Success" // Missing proper response structure
};
```
### Code Organization
✅ DO:
- Split complex logic into smaller functions
- Use utility functions for common operations
- Keep the tool factory function clean
❌ DON'T:
```typescript
// Don't mix concerns in the handler
handler: async (args) => {
// Don't put core logic here
const files = await fs.readdir(path);
// ... more direct implementation
}
// Don't duplicate utility functions
function isValidPath(path: string) {
// Don't reimplement existing utilities
}
```
## Schema Conversion Considerations
When creating schemas, remember they need to be converted to JSON Schema for the MCP interface:
### JSON Schema Compatibility
✅ DO:
- Test your schemas with the `createSchemaHandler` utility
- Use standard Zod types that have clear JSON Schema equivalents
- Structure complex validation using composition of simple schemas
- Verify generated JSON Schema matches expected validation rules
❌ DON'T:
- Rely heavily on refinements that don't translate to JSON Schema
- Use complex validation logic that can't be represented in JSON Schema
- Access parent context in nested validations
- Assume all Zod features will work in JSON Schema
### Schema Handler Usage
```typescript
// ✅ DO: Test schema conversion
const schema = z.discriminatedUnion('operation', [
z.object({
operation: z.literal('read'),
path: z.string()
}),
z.object({
operation: z.literal('write'),
path: z.string(),
content: z.string()
})
]);
// Verify schema handler creation succeeds
const schemaHandler = createSchemaHandler(schema);
// ❌ DON'T: Use features that don't convert well
const schema = z.object({
data: z.any().superRefine((val, ctx) => {
// Complex custom validation that won't translate
})
});
```
## Common Utilities
Make use of existing utilities:
- `createSchemaHandler`: For input validation
- `handleFsError`: For filesystem error handling
- `createToolResponse`: For formatting responses
- `validateVaultPath`: For path validation
- `ensureDirectory`: For directory operations
- `formatOperationResult`: For standardized results
## Testing Your Tool
1. Ensure your tool handles edge cases:
- Invalid inputs
- File/directory permissions
- Non-existent paths
- Concurrent operations
2. Verify error messages are helpful:
- Validation errors should guide the user
- Operation errors should be specific
- Path-related errors should be clear
3. Check response formatting:
- Success messages should be informative
- Error messages should be actionable
- Operation details should be complete
## Integration
After implementing your tool:
1. Export it from `src/tools/index.ts`
2. Register it in `src/server.ts`
3. Update any relevant documentation
4. Add appropriate error handling utilities if needed
Remember: Tools should be focused, well-documented, and follow the established patterns in the codebase. When in doubt, look at existing tools like `create-note` or `edit-note` as references.