feat: add base tool input schema
This commit is contained in:
+4
-3
@@ -7,11 +7,11 @@ ChatGPT MCP Server
|
||||
## Status
|
||||
|
||||
Planning complete.
|
||||
Phase 0 complete. Phase 1 complete. Phase 2 complete.
|
||||
Phase 0 complete. Phase 1 complete. Phase 2 complete. Phase 3 in progress.
|
||||
|
||||
## Current Phase
|
||||
|
||||
Phase 3 — Not yet defined
|
||||
Phase 3 in progress.
|
||||
|
||||
## Completed Tasks
|
||||
|
||||
@@ -24,7 +24,8 @@ Phase 3 — Not yet defined
|
||||
- Task 2.1 — OpenAI client wrapper (`src/openai/client.js`) ✅
|
||||
- Task 2.2 — Response builder (`src/openai/responses.js`) ✅
|
||||
- Task 2.3 — Error handling and edge cases for OpenAI integration (tests) ✅
|
||||
- Task 3.1 — Zod input validation schemas (`src/tools/schemas.js`, `test/tools/schemas.test.js`) ✅
|
||||
|
||||
## Next Phase
|
||||
|
||||
Phase 3 — Not yet defined
|
||||
Phase 3 — in progress; next is Task 3.2 (Base prompt template).
|
||||
|
||||
@@ -131,4 +131,47 @@ Status: ✅ Complete
|
||||
|
||||
---
|
||||
|
||||
Phase 2 complete. Phase 3 not yet defined.
|
||||
## Phase 3 — Tool Inputs and Prompts
|
||||
|
||||
### Task 3.1 - Zod input validation schemas
|
||||
|
||||
Create `src/tools/schemas.js`.
|
||||
|
||||
Requirements:
|
||||
- Export `baseInputSchema` — a single `z.object({...}).strict()` for the common tool input shape (ARCHITECTURE.md §7).
|
||||
- Export `validateToolInput(raw)` — returns `{ ok: true, data }` or `{ ok: false, errors }`. Never throws.
|
||||
- Export `assertValidToolInput(raw)` — returns validated data or throws a `ValidationError`.
|
||||
- No per-tool schemas. No toolName parameters. Unknown keys must fail via `.strict()`.
|
||||
|
||||
Create `test/tools/schemas.test.js`.
|
||||
|
||||
Requirements:
|
||||
- ~26 tests covering schema, validate, and assert paths.
|
||||
- Test all fields: question (required, min(1)), context, constraints, expectedOutput, projectSummary, taskSummary, relevantFiles (nested object), logs.
|
||||
- Test strict mode rejects unknown keys.
|
||||
- Test type rejections for each field.
|
||||
- Test assert throws `ValidationError` with `.kind` and message content.
|
||||
- No MCP code. No OpenAI calls. Pure Zod validation only.
|
||||
|
||||
Status: ✅ Complete
|
||||
|
||||
### Task 3.2 - Base prompt template (NEXT)
|
||||
|
||||
Create `src/prompts/base.js`.
|
||||
|
||||
Requirements:
|
||||
- Export a `buildBasePrompt()` function that returns the base system prompt (ARCHITECTURE.md §11).
|
||||
- The base prompt must be injected as the first message in all tool payloads.
|
||||
- Must include all rules from ARCHITECTURE.md §10 and §11.
|
||||
- No per-tool variations yet — that belongs to Task 3.3.
|
||||
|
||||
Create `test/prompts/base.test.js`.
|
||||
|
||||
Requirements:
|
||||
- Test that the returned prompt includes all key rules from ARCHITECTURE.md §11.
|
||||
- Test that the prompt is a non-empty string.
|
||||
- Test that the prompt does not include tool-specific content (review/code/debug/architecture).
|
||||
|
||||
---
|
||||
|
||||
Phase 2 complete. Phase 3 in progress.
|
||||
|
||||
+46
-1
@@ -1 +1,46 @@
|
||||
// Zod input validation schemas for all tools.
|
||||
// Zod validation schemas for common tool input.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const baseInputSchema = z.object({
|
||||
question: z.string().min(1),
|
||||
context: z.string().optional(),
|
||||
constraints: z.array(z.string()).optional(),
|
||||
expectedOutput: z.string().optional(),
|
||||
projectSummary: z.string().optional(),
|
||||
taskSummary: z.string().optional(),
|
||||
relevantFiles: z
|
||||
.array(
|
||||
z.object({
|
||||
path: z.string().min(1),
|
||||
content: z.string(),
|
||||
language: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
logs: z.string().optional(),
|
||||
}).strict();
|
||||
|
||||
/** @param {unknown} raw */
|
||||
export function validateToolInput(raw) {
|
||||
const result = baseInputSchema.safeParse(raw);
|
||||
if (result.success) {
|
||||
return { ok: true, data: result.data };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
errors: result.error.issues.map((i) => i.path.join(".") + ": " + i.message),
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {unknown} raw */
|
||||
export function assertValidToolInput(raw) {
|
||||
const result = validateToolInput(raw);
|
||||
if (result.ok) return result.data;
|
||||
const err = new Error(
|
||||
"Input validation error:\n" + result.errors.map((e) => " - " + e).join("\n"),
|
||||
);
|
||||
err.kind = "ValidationError";
|
||||
throw err;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { baseInputSchema, validateToolInput, assertValidToolInput } from '../../src/tools/schemas.js';
|
||||
|
||||
const validMinimal = { question: 'hello' };
|
||||
const validFull = {
|
||||
question: 'review this',
|
||||
context: 'some context',
|
||||
constraints: ['no external deps'],
|
||||
expectedOutput: 'bullet points',
|
||||
projectSummary: 'MVP phase 1',
|
||||
taskSummary: 'implement schema validation',
|
||||
relevantFiles: [
|
||||
{ path: 'src/tools/schemas.js', content: 'import ...' },
|
||||
{ path: 'test/schemas.test.js', content: 'import ...', language: 'javascript' },
|
||||
],
|
||||
logs: 'error: something broke',
|
||||
};
|
||||
|
||||
describe('baseInputSchema', () => {
|
||||
it('validates a minimal input successfully', () => {
|
||||
const result = baseInputSchema.safeParse(validMinimal);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.question).toBe('hello');
|
||||
});
|
||||
|
||||
it('validates a full input with all fields', () => {
|
||||
const result = baseInputSchema.safeParse(validFull);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts question with only whitespace (min(1) not trim+min(1))', () => {
|
||||
const result = baseInputSchema.safeParse({ question: ' ' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty string question', () => {
|
||||
const result = baseInputSchema.safeParse({ question: '' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects question with null', () => {
|
||||
const result = baseInputSchema.safeParse({ question: null });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects question with undefined', () => {
|
||||
const result = baseInputSchema.safeParse({ question: undefined });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects question with a number', () => {
|
||||
const result = baseInputSchema.safeParse({ question: 123 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing question field entirely', () => {
|
||||
const result = baseInputSchema.safeParse({ context: 'only context' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts constraints as valid string[]', () => {
|
||||
const input = { question: 'q', constraints: ['a', 'b'] };
|
||||
const result = baseInputSchema.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects constraints with non-string elements', () => {
|
||||
// @ts-ignore - testing runtime invalid input
|
||||
const input = { question: 'q', constraints: ['a', 123] };
|
||||
const result = baseInputSchema.safeParse(input);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts relevantFiles with valid file objects', () => {
|
||||
const input = { question: 'q', relevantFiles: [{ path: 'a.js', content: 'code' }] };
|
||||
const result = baseInputSchema.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts empty relevantFiles array', () => {
|
||||
const input = { question: 'q', relevantFiles: [] };
|
||||
const result = baseInputSchema.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects relevantFiles with missing path', () => {
|
||||
// @ts-ignore - testing runtime invalid input
|
||||
const input = { question: 'q', relevantFiles: [{ content: 'code' }] };
|
||||
const result = baseInputSchema.safeParse(input);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects relevantFiles with non-string content', () => {
|
||||
// @ts-ignore - testing runtime invalid input
|
||||
const input = { question: 'q', relevantFiles: [{ path: 'a.js', content: 123 }] };
|
||||
const result = baseInputSchema.safeParse(input);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects input with unknown keys (strict mode)', () => {
|
||||
// @ts-ignore - testing runtime invalid input
|
||||
const input = { question: 'q', unknownField: true };
|
||||
const result = baseInputSchema.safeParse(input);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects null as raw input', () => {
|
||||
const result = baseInputSchema.safeParse(null);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a string as raw input', () => {
|
||||
const result = baseInputSchema.safeParse('just a string');
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToolInput', () => {
|
||||
it('returns ok:true with data for valid input', () => {
|
||||
const result = validateToolInput(validMinimal);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.data.question).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns ok:false with errors array for invalid input', () => {
|
||||
const result = validateToolInput({ question: '' });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(Array.isArray(result.errors)).toBe(true);
|
||||
});
|
||||
|
||||
it('error messages include dot-notation path segments', () => {
|
||||
const result = validateToolInput({ question: 'q', relevantFiles: [{ content: 1 }] });
|
||||
expect(!result.ok && result.errors.some((e) => e.includes('.'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns ok:false for null input', () => {
|
||||
const result = validateToolInput(null);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('returns ok:false for unknown keys', () => {
|
||||
// @ts-ignore - testing runtime invalid input
|
||||
const result = validateToolInput({ question: 'q', extraKey: true });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertValidToolInput', () => {
|
||||
it('returns validated data for valid input', () => {
|
||||
const result = assertValidToolInput(validMinimal);
|
||||
expect(typeof result).toBe('object');
|
||||
expect(result.question).toBe('hello');
|
||||
});
|
||||
|
||||
it('throws Error with kind "ValidationError" on invalid input', () => {
|
||||
expect(() => assertValidToolInput({ question: '' })).toThrow('Input validation error');
|
||||
});
|
||||
|
||||
it('thrown error has .kind === "ValidationError"', () => {
|
||||
let caught = null;
|
||||
try {
|
||||
assertValidToolInput(null);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).not.toBeNull();
|
||||
expect(/** @type{Error} */ (caught).kind).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('thrown error message includes validation details', () => {
|
||||
let caughtMsg = '';
|
||||
try {
|
||||
// @ts-ignore - testing runtime invalid input
|
||||
assertValidToolInput({ question: 'q', badField: true });
|
||||
} catch (e) {
|
||||
caughtMsg = /** @type{Error} */ (e).message;
|
||||
}
|
||||
expect(caughtMsg).toContain('badField');
|
||||
expect(caughtMsg).toContain('Input validation error');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user