Files
obsidian-mcp/src/validation/schemas.ts
Peter.Morton a0801a82fd fix: chunk large note reads to prevent output-too-large errors (fixes #5)
Add offset and max_chars parameters to obsidian_read_note:
- max_chars (default 50000, max 500000): caps characters returned per call
- offset (default 0): start position for reading, enabling pagination

When content is truncated a trailer message is appended telling the
caller the total size and the exact offset to pass on the next call.

This prevents the 26MB+ responses that caused Claude to reject output
when reading large PDFs stored in an Obsidian vault.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 17:36:33 -05:00

170 lines
4.7 KiB
TypeScript

/**
* 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.object({
file: noteNameSchema.optional(),
path: filePathSchema.optional(),
offset: z.number().int().nonnegative().optional().default(0),
max_chars: z.number().int().positive().max(500000).optional().default(50000),
}).refine(
(data) => data.file || 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'),
path: optionalStringSchema, // Folder path to limit search
limit: z.number().int().positive().max(1000).optional(),
total: booleanFlagSchema.optional(), // Return match count instead of files
case: booleanFlagSchema.optional(), // Case sensitive search
format: z.enum(['text', 'json']).optional().default('text'),
});
// 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(),
})
);