| name | mcp-schema-designer |
| version | 1.0.0 |
| category | validation |
| complexity | simple |
| status | active |
| created | Thu Dec 18 2025 00:00:00 GMT+0000 (Coordinated Universal Time) |
| author | braiins-pool-mcp-server |
| description | Designs comprehensive Zod schemas for MCP tool inputs and API responses, ensuring type safety, clear validation error messages, and security through input sanitization patterns. |
| triggers | design tool schema, create input validation, Zod schema for, validate tool parameters, create response schema, input schema |
| dependencies |
MCP Schema Designer Skill
Description
Design and implement Zod validation schemas for MCP tool inputs and Braiins API responses. This skill ensures type safety, prevents injection attacks through input validation, and provides clear error messages for invalid parameters.
When to Use This Skill
- When defining input parameters for a new MCP tool
- When creating response validation for API endpoints
- When adding new parameters to existing tools
- When reviewing schemas for security vulnerabilities
- When standardizing validation patterns across tools
When NOT to Use This Skill
- When implementing the full tool handler (use mcp-tool-builder)
- When designing caching strategy (use braiins-cache-strategist)
- When working on API client code (use braiins-api-mapper)
Prerequisites
- Zod is installed:
npm install zod - Understanding of the parameter requirements from API.md
- Knowledge of TypeScript types
Schema Design Patterns
Pattern 1: Required String ID
Use for identifiers like worker IDs, pool IDs:
import { z } from 'zod';
const WorkerIdSchema = z.object({
workerId: z.string()
.min(1, 'Worker ID is required')
.max(100, 'Worker ID must be 100 characters or less')
.regex(
/^[a-zA-Z0-9\-_]+$/,
'Worker ID can only contain letters, numbers, hyphens, and underscores'
),
});
// Usage in tool:
// workerId: "farm1-s19-01" -> Valid
// workerId: "" -> Error: "Worker ID is required"
// workerId: "abc@123" -> Error: "Worker ID can only contain..."
Security Notes:
- Regex prevents injection attacks
- Max length prevents memory exhaustion
- Min length ensures meaningful input
Pattern 2: Pagination Parameters
Use for paginated list endpoints:
const PaginationSchema = z.object({
page: z.number()
.int('Page must be a whole number')
.min(1, 'Page must be at least 1')
.default(1),
pageSize: z.number()
.int('Page size must be a whole number')
.min(1, 'Page size must be at least 1')
.max(200, 'Page size cannot exceed 200')
.default(50),
});
// Extend for tool-specific pagination:
const ListWorkersInputSchema = PaginationSchema.extend({
status: z.enum(['active', 'inactive', 'all']).default('all'),
});
Default Values:
page: 1- Start at first pagepageSize: 50- Reasonable default batch sizestatus: 'all'- Include everything by default
Pattern 3: Time Range Parameters
Use for historical data queries:
const TimeRangeSchema = z.object({
from: z.string()
.datetime({ message: 'From must be a valid ISO 8601 datetime' })
.optional(),
to: z.string()
.datetime({ message: 'To must be a valid ISO 8601 datetime' })
.optional(),
granularity: z.enum(['minute', 'hour', 'day'], {
errorMap: () => ({ message: 'Granularity must be: minute, hour, or day' }),
}).default('hour'),
}).refine(
(data) => {
if (data.from && data.to) {
return new Date(data.from) <= new Date(data.to);
}
return true;
},
{ message: 'From date must be before or equal to To date' }
);
// Usage:
// { from: "2025-01-01T00:00:00Z", to: "2025-01-10T00:00:00Z" } -> Valid
// { from: "2025-01-10T00:00:00Z", to: "2025-01-01T00:00:00Z" } -> Error: "From date must be..."
Refinement:
- Custom validation ensures logical date ranges
.refine()enables cross-field validation
Pattern 4: Filter Parameters with Allow-List
Use for search and filter endpoints:
const WorkerFilterSchema = z.object({
status: z.enum(['active', 'inactive', 'disabled', 'all'])
.default('all')
.describe('Filter workers by operational status'),
search: z.string()
.max(100, 'Search query too long')
.regex(/^[a-zA-Z0-9\-_ ]*$/, 'Search contains invalid characters')
.optional()
.describe('Partial match on worker name'),
sortBy: z.enum([
'hashrate_desc',
'hashrate_asc',
'name_asc',
'name_desc',
'last_share_desc',
'last_share_asc',
])
.optional()
.describe('Sort order for results'),
tags: z.array(z.string().max(50))
.max(10, 'Too many tags')
.optional()
.describe('Filter by worker tags'),
});
Allow-List Pattern:
- Enums prevent arbitrary sort/filter injection
- Only predefined values are accepted
- Clear error messages for invalid choices
Pattern 5: API Response Validation
Use for validating Braiins API responses:
// Base response fields
const TimestampedResponseSchema = z.object({
updated_at: z.string().datetime(),
});
// Hashrate object (reusable)
const HashrateSchema = z.object({
current: z.number().nonnegative(),
avg_1h: z.number().nonnegative(),
avg_24h: z.number().nonnegative(),
});
// Full response schema
const UserOverviewResponseSchema = TimestampedResponseSchema.extend({
username: z.string(),
currency: z.literal('BTC'),
hashrate: HashrateSchema,
rewards: z.object({
confirmed: z.string().regex(/^\d+\.\d{8}$/, 'Invalid BTC amount format'),
unconfirmed: z.string().regex(/^\d+\.\d{8}$/, 'Invalid BTC amount format'),
last_payout: z.string(),
last_payout_at: z.string().datetime(),
}),
workers: z.object({
active: z.number().int().nonnegative(),
inactive: z.number().int().nonnegative(),
total: z.number().int().nonnegative(),
}),
});
// Type inference
type UserOverviewResponse = z.infer<typeof UserOverviewResponseSchema>;
Response Validation Benefits:
- Catches API changes early (schema mismatch)
- Ensures type safety in handler code
- Documents expected API structure
Pattern 6: Union Types for Polymorphic Data
Use when response varies by type:
const WorkerStatusSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('active'),
hashrate: HashrateSchema,
last_share_at: z.string().datetime(),
uptime_hours: z.number(),
}),
z.object({
status: z.literal('inactive'),
last_seen_at: z.string().datetime(),
inactive_reason: z.enum(['no_shares', 'disconnected', 'maintenance']),
}),
z.object({
status: z.literal('disabled'),
disabled_at: z.string().datetime(),
disabled_by: z.string(),
}),
]);
// Type-safe access:
// if (worker.status === 'active') {
// console.log(worker.hashrate); // TypeScript knows hashrate exists
// }
Workflow
Step 1: Gather Requirements
From API.md, extract:
- All parameters (name, type, required/optional)
- Valid value ranges and formats
- Default values
- Relationships between parameters
Step 2: Choose Base Patterns
Select from patterns above based on parameter type:
- ID fields -> Pattern 1
- Pagination -> Pattern 2
- Date ranges -> Pattern 3
- Filters -> Pattern 4
- API responses -> Pattern 5
Step 3: Implement Schema
// src/schemas/{toolName}Input.ts
import { z } from 'zod';
/**
* Input schema for {toolName} MCP tool
*
* Parameters:
* - param1: Description (required)
* - param2: Description (optional, default: X)
*
* @example
* {
* param1: "value1",
* param2: 10
* }
*/
export const {ToolName}InputSchema = z.object({
// Define all parameters with validation
});
export type {ToolName}Input = z.infer<typeof {ToolName}InputSchema>;
Step 4: Write Tests
// tests/unit/schemas/{toolName}Input.test.ts
import { describe, it, expect } from 'vitest';
import { {ToolName}InputSchema } from '../../../src/schemas/{toolName}Input';
describe('{ToolName}InputSchema', () => {
it('should accept valid input', () => {
const result = {ToolName}InputSchema.safeParse({
param1: 'valid-value',
});
expect(result.success).toBe(true);
});
it('should reject missing required field', () => {
const result = {ToolName}InputSchema.safeParse({});
expect(result.success).toBe(false);
expect(result.error?.issues[0].message).toContain('required');
});
it('should apply default values', () => {
const result = {ToolName}InputSchema.parse({
param1: 'value',
});
expect(result.param2).toBe(50); // Default value
});
it('should reject invalid format', () => {
const result = {ToolName}InputSchema.safeParse({
param1: 'invalid@format!',
});
expect(result.success).toBe(false);
});
});
Quality Checklist
Every schema must pass these checks:
- All required fields have validation messages
- All strings have
.max()to prevent abuse - ID fields have regex patterns preventing injection
- Enums used for fixed-choice parameters
- Defaults provided where sensible
- JSDoc comment with example
- Type exported for use in handler
- Unit tests for valid/invalid cases
- Error messages are user-friendly (not technical)
Examples
Example 1: getWorkerDetails Input Schema
Requirements from API.md Section 6.2:
- workerId: Required, string identifier
// src/schemas/getWorkerDetailsInput.ts
import { z } from 'zod';
/**
* Input schema for getWorkerDetails MCP tool
*
* @param workerId - Unique identifier for the worker device
*
* @example
* { workerId: "farm1-s19-01" }
*/
export const GetWorkerDetailsInputSchema = z.object({
workerId: z.string()
.min(1, 'Worker ID is required')
.max(100, 'Worker ID must be 100 characters or less')
.regex(
/^[a-zA-Z0-9\-_]+$/,
'Worker ID can only contain letters, numbers, hyphens, and underscores'
),
});
export type GetWorkerDetailsInput = z.infer<typeof GetWorkerDetailsInputSchema>;
Example 2: listWorkers Input Schema
Requirements from API.md Section 6.1:
- page, pageSize: Pagination
- status, search, sortBy: Filters
// src/schemas/listWorkersInput.ts
import { z } from 'zod';
/**
* Input schema for listWorkers MCP tool
*
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 50, max: 200)
* @param status - Filter by worker status
* @param search - Partial name match
* @param sortBy - Sort order
*
* @example
* {
* page: 1,
* pageSize: 25,
* status: "active",
* sortBy: "hashrate_desc"
* }
*/
export const ListWorkersInputSchema = z.object({
// Pagination
page: z.number()
.int('Page must be a whole number')
.min(1, 'Page must be at least 1')
.default(1),
pageSize: z.number()
.int('Page size must be a whole number')
.min(1, 'Page size must be at least 1')
.max(200, 'Page size cannot exceed 200')
.default(50),
// Filters
status: z.enum(['active', 'inactive', 'all'], {
errorMap: () => ({ message: 'Status must be: active, inactive, or all' }),
}).default('all'),
search: z.string()
.max(100, 'Search query too long')
.regex(/^[a-zA-Z0-9\-_ ]*$/, 'Search contains invalid characters')
.optional(),
sortBy: z.enum([
'hashrate_desc',
'hashrate_asc',
'name_asc',
'name_desc',
'last_share',
], {
errorMap: () => ({
message: 'Invalid sort option. Valid options: hashrate_desc, hashrate_asc, name_asc, name_desc, last_share',
}),
}).optional(),
});
export type ListWorkersInput = z.infer<typeof ListWorkersInputSchema>;
Example 3: getWorkerHashrateTimeseries Input Schema
Requirements from API.md Section 6.3:
- workerId: Required identifier
- from, to: Optional time range
- granularity: Aggregation level
// src/schemas/getWorkerHashrateTimeseriesInput.ts
import { z } from 'zod';
/**
* Input schema for getWorkerHashrateTimeseries MCP tool
*
* @param workerId - Worker identifier
* @param from - Start timestamp (ISO 8601)
* @param to - End timestamp (ISO 8601)
* @param granularity - Data point aggregation level
*
* @example
* {
* workerId: "farm1-s19-01",
* from: "2025-01-01T00:00:00Z",
* to: "2025-01-07T00:00:00Z",
* granularity: "hour"
* }
*/
export const GetWorkerHashrateTimeseriesInputSchema = z.object({
workerId: z.string()
.min(1, 'Worker ID is required')
.max(100, 'Worker ID too long')
.regex(/^[a-zA-Z0-9\-_]+$/, 'Invalid worker ID format'),
from: z.string()
.datetime({ message: 'From must be a valid ISO 8601 datetime' })
.optional(),
to: z.string()
.datetime({ message: 'To must be a valid ISO 8601 datetime' })
.optional(),
granularity: z.enum(['minute', 'hour', 'day'], {
errorMap: () => ({ message: 'Granularity must be: minute, hour, or day' }),
}).default('hour'),
}).refine(
(data) => {
if (data.from && data.to) {
return new Date(data.from) <= new Date(data.to);
}
return true;
},
{ message: 'From date must be before or equal to To date' }
);
export type GetWorkerHashrateTimeseriesInput = z.infer<
typeof GetWorkerHashrateTimeseriesInputSchema
>;
Common Pitfalls
Pitfall 1: Missing max length on strings
// BAD: No length limit
workerId: z.string()
// GOOD: Prevent memory exhaustion
workerId: z.string().max(100)
Pitfall 2: Technical error messages
// BAD: Zod default message
.min(1) // Error: "String must contain at least 1 character(s)"
// GOOD: User-friendly message
.min(1, 'Worker ID is required')
Pitfall 3: No regex on IDs
// BAD: Accepts any string (injection risk)
workerId: z.string().min(1).max(100)
// GOOD: Only safe characters
workerId: z.string().min(1).max(100).regex(/^[a-zA-Z0-9\-_]+$/)
Pitfall 4: Hardcoded strings instead of enums
// BAD: Any string accepted
status: z.string()
// GOOD: Only valid options
status: z.enum(['active', 'inactive', 'all'])
Version History
- 1.0.0 (2025-12-18): Initial skill definition
References
- Zod Documentation
- API.md - Braiins API specification
- ARCHITECTURE.md - Validation layer design