feat: add base tool input schema

This commit is contained in:
2026-06-11 14:03:14 +01:00
parent 311f49435e
commit 58fe45b1b3
4 changed files with 275 additions and 5 deletions
+4 -3
View File
@@ -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).
+44 -1
View File
@@ -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
View File
@@ -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;
}
+181
View File
@@ -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');
});
});