feat: implement Obsidian MCP Bundle MVP (Phase 1-3)

- Complete project setup with TypeScript, Jest, MCPB manifest
- Implement foundational infrastructure (CLI executor, logger, error handler)
- Add 9 file operation tools for User Story 1
- Full MCP protocol compliance with stdio transport
- Input validation and sanitization for security
- Comprehensive error handling with actionable messages
- Constitutional compliance: all 6 principles satisfied

MVP includes:
- obsidian_create_note, read, append, prepend, delete, move, rename, open, file_info
- Zod validation schemas for all parameters
- 30s timeout configuration with per-command overrides
- Stderr-only logging with sanitized output
- Graceful shutdown handling

Build:  0 errors, 0 vulnerabilities
Tasks: 48/167 complete (MVP milestone)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-03-22 11:21:38 -05:00
parent e9e0112240
commit 622b28e42c
35 changed files with 5139 additions and 35 deletions

164
src/validation/schemas.ts Normal file
View File

@@ -0,0 +1,164 @@
/**
* Zod Validation Schemas
* Constitutional Principle III: Local Execution Security
* Constitutional Principle IV: Defensive Programming
*/
import { z } from 'zod';
/**
* Common validation patterns
*/
// Vault name validation (alphanumeric, spaces, hyphens, underscores)
export const vaultNameSchema = z
.string()
.min(1, 'Vault name cannot be empty')
.max(255, 'Vault name too long')
.regex(/^[a-zA-Z0-9\s_-]+$/, 'Vault name contains invalid characters');
// Note name validation (allow most characters, but not path separators)
export const noteNameSchema = z
.string()
.min(1, 'Note name cannot be empty')
.max(255, 'Note name too long')
.regex(/^[^/\\]+$/, 'Note name cannot contain path separators');
// File path validation (allow subdirectories)
export const filePathSchema = z
.string()
.min(1, 'File path cannot be empty')
.max(1024, 'File path too long')
.regex(/^[^<>:"|?*]+$/, 'File path contains invalid characters');
// Content validation (allow any string, but limit size)
export const contentSchema = z
.string()
.max(1048576, 'Content too large (max 1MB)'); // 1MB limit
// Tag validation (must start with #)
export const tagSchema = z
.string()
.min(2, 'Tag too short')
.regex(/^#[a-zA-Z0-9_/-]+$/, 'Invalid tag format (must start with # and contain only alphanumeric, _, /, -)');
// Property key validation
export const propertyKeySchema = z
.string()
.min(1, 'Property key cannot be empty')
.max(100, 'Property key too long')
.regex(/^[a-zA-Z0-9_-]+$/, 'Property key contains invalid characters');
// Boolean flag validation
export const booleanFlagSchema = z
.union([z.boolean(), z.string()])
.transform((val) => {
if (typeof val === 'boolean') return val;
return val.toLowerCase() === 'true' || val === '1';
});
// Optional string that can be undefined or empty
export const optionalStringSchema = z.string().optional();
// Date string validation (ISO 8601)
export const dateSchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?)?$/, 'Invalid date format (use ISO 8601)');
/**
* Common parameter schemas
*/
// File identifier (either file name or full path)
export const fileIdentifierSchema = z.object({
file: z.string().optional(),
path: z.string().optional(),
}).refine(
(data) => data.file || data.path,
{ message: 'Either file or path must be provided' }
);
// Pagination parameters
export const paginationSchema = z.object({
limit: z.number().int().positive().max(1000).optional(),
offset: z.number().int().nonnegative().optional(),
});
// Format options
export const formatSchema = z.object({
format: z.enum(['json', 'tsv', 'csv', 'text']).optional().default('text'),
});
/**
* Tool-specific schemas
*/
// Create note parameters
export const createNoteSchema = z.object({
name: noteNameSchema,
path: filePathSchema.optional(),
content: contentSchema.optional(),
template: z.string().optional(),
overwrite: booleanFlagSchema.optional(),
open: booleanFlagSchema.optional(),
});
// Read note parameters
export const readNoteSchema = z.union([
z.object({ file: noteNameSchema }),
z.object({ path: filePathSchema }),
]).refine(
(data) => ('file' in data && data.file) || ('path' in data && data.path),
{ message: 'Either file or path must be provided' }
);
// Append/Prepend parameters
export const appendPrependSchema = z.intersection(
fileIdentifierSchema,
z.object({
content: contentSchema,
inline: booleanFlagSchema.optional(),
})
);
// Delete note parameters
export const deleteNoteSchema = z.intersection(
fileIdentifierSchema,
z.object({
permanent: booleanFlagSchema.optional(),
})
);
// Move/Rename parameters
export const moveRenameSchema = z.intersection(
fileIdentifierSchema,
z.object({
newPath: filePathSchema.optional(),
newName: noteNameSchema.optional(),
})
).refine(
(data) => data.newPath || data.newName,
{ message: 'Either newPath or newName must be provided' }
);
// Search parameters
export const searchSchema = z.object({
query: z.string().min(1, 'Search query cannot be empty'),
...formatSchema.shape,
...paginationSchema.shape,
});
// Tag search parameters
export const tagSearchSchema = z.object({
tag: tagSchema,
...formatSchema.shape,
});
// Property parameters
export const propertySchema = z.intersection(
fileIdentifierSchema,
z.object({
key: propertyKeySchema,
value: z.unknown().optional(),
})
);