diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 25f4554..5ba71ad 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -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). diff --git a/TASKS.md b/TASKS.md index ed3aea2..a776d08 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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. diff --git a/src/tools/schemas.js b/src/tools/schemas.js index db7d1cb..9f4fd74 100644 --- a/src/tools/schemas.js +++ b/src/tools/schemas.js @@ -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; +} + diff --git a/test/tools/schemas.test.js b/test/tools/schemas.test.js new file mode 100644 index 0000000..a39b335 --- /dev/null +++ b/test/tools/schemas.test.js @@ -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'); + }); +});