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:
164
src/validation/schemas.ts
Normal file
164
src/validation/schemas.ts
Normal 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(),
|
||||
})
|
||||
);
|
||||
Reference in New Issue
Block a user