From fe9f93c8518d5243ad2d691a4a2dfe1f7e35a932 Mon Sep 17 00:00:00 2001 From: Rafal Krysiak Date: Tue, 1 Sep 2026 22:21:01 +0200 Subject: [PATCH 1/6] validate inbound input with Ajv Adds schema validation on the MCP transports (stdio + HTTP), the tools config, and the GUI server routes, as evidence of input validation for the SOC2 audit. Ajv because the MCP tool schemas are already JSON Schema and are advertised to clients verbatim, so there is no second definition to drift. `env` is no longer required on tools that authenticate: resolveAuth also accepts explicit credentials, MPKIT_* env vars, or the default .pos entry. --- CLAUDE.md | 58 ++++- lib/proxy.js | 6 +- lib/server.js | 61 ++++- lib/validation/index.js | 106 +++++++++ lib/validation/schemas/gui.js | 78 +++++++ mcp-min/__tests__/constants.test.js | 12 +- mcp-min/__tests__/data.import.test.js | 4 +- .../__tests__/tools-config-validation.test.js | 99 ++++++++ .../__tests__/transport-validation.test.js | 148 ++++++++++++ mcp-min/__tests__/uploads.push.test.js | 3 +- mcp-min/__tests__/validate-params.test.js | 97 ++++++++ mcp-min/constants/list.js | 5 +- mcp-min/constants/set.js | 4 +- mcp-min/constants/unset.js | 4 +- mcp-min/data/import-status.js | 4 +- mcp-min/data/import.js | 3 +- mcp-min/data/validate-tool.js | 2 +- mcp-min/http-server.js | 23 ++ mcp-min/schemas/auth.js | 19 ++ mcp-min/stdio-server.js | 22 ++ mcp-min/tests/run.js | 4 +- mcp-min/tools.js | 28 ++- mcp-min/uploads/push.js | 4 +- mcp-min/validate-params.js | 34 +++ package-lock.json | 2 + package.json | 2 + test/unit/server.validation.test.js | 215 ++++++++++++++++++ test/unit/validation.test.js | 94 ++++++++ 28 files changed, 1117 insertions(+), 24 deletions(-) create mode 100644 lib/validation/index.js create mode 100644 lib/validation/schemas/gui.js create mode 100644 mcp-min/__tests__/tools-config-validation.test.js create mode 100644 mcp-min/__tests__/transport-validation.test.js create mode 100644 mcp-min/__tests__/validate-params.test.js create mode 100644 mcp-min/schemas/auth.js create mode 100644 mcp-min/validate-params.js create mode 100644 test/unit/server.validation.test.js create mode 100644 test/unit/validation.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 39f94220..6401fd45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,8 @@ pos-cli/ │ ├── data/ # Import/export/clean │ ├── assets/ # Asset deployment │ ├── logsv2/ # OpenObserve logs integration -│ └── validators/ # Input validation +│ ├── validation/ # Ajv schema validation (shared by GUI + MCP) +│ └── validators/ # CLI argument validators (url, email, paths) ├── mcp-min/ # MCP server implementation │ ├── index.js # Starts stdio + HTTP/SSE transports │ ├── stdio-server.js # MCP over stdio (for editor integrations) @@ -397,6 +398,61 @@ Can run with sync: `pos-cli gui serve staging --sync --open` - **ora** - Loading spinners - **yeoman-generator** - Code generators +## Input Validation (Ajv) + +**Key files**: `lib/validation/index.js`, `lib/validation/schemas/gui.js`, `mcp-min/validate-params.js`, `mcp-min/schemas/auth.js` + +Untrusted input is validated against JSON Schema with **Ajv** (draft-07) before it reaches +any handler. Ajv is used rather than a code-first library because the MCP protocol requires +JSON Schema on the wire: each tool's `inputSchema` is advertised verbatim in `tools/list`, +so the schema we publish and the schema we enforce are the same object and cannot drift. + +`lib/validation/index.js` exposes one function: + +```javascript +import { validate } from '#lib/validation/index.js'; + +const result = validate(schema, data); // { valid, errors, message, schemaError } +const coerced = validate(schema, req.query, { mode: 'coercing' }); +``` + +- **`strict` mode (default)** — for JSON bodies. Leaves the caller's data untouched. +- **`coercing` mode** — for query strings, where every value arrives as a string. Ajv + applies coercion and defaults **by mutating the object in place**. +- **`result.schemaError`** — the schema itself would not compile. That is our defect, not + the caller's, so report it as 500 / `-32603` — but still reject, because nothing was + actually checked. + +Ajv runs in `strict: true` mode so a malformed schema fails loudly at compile time. +`allowUnionTypes` is the one rule relaxed, for fields that genuinely accept two types. + +### Enforcement points + +| Where | What is validated | +|---|---| +| `mcp-min/http-server.js` — `POST /call`, `/call-stream` | tool params vs `inputSchema` → 400 | +| `mcp-min/http-server.js` — JSON-RPC `tools/call` | same → `-32602` | +| `mcp-min/stdio-server.js` — `tools/call` + legacy direct invocation | same → `-32602` | +| `mcp-min/tools.js` | `tools.config.json` vs `tools.config.schema.json` | +| `lib/server.js` | GUI requests for graph / liquid / logs / logsv2 / sync | + +Adding a tool to `mcp-min/` needs no wiring: both transports validate against whatever +`inputSchema` the tool declares. A tool with no schema accepts any object. + +### Two rules to preserve + +**`env` must stay optional on tools that authenticate.** `resolveAuth` (`mcp-min/auth.js`) +resolves credentials from explicit `url`+`email`+`token` params, then `MPKIT_*` env vars, +then the named `.pos` environment, then the first `.pos` entry. Marking `env` as `required` +would reject three of those four supported call styles. Tools closing their schema with +`additionalProperties: false` must also spread in `authProperties` from +`mcp-min/schemas/auth.js`, or the explicit-credentials path becomes unreachable. + +**The tools config fails closed.** An unparseable or missing config falls back to defaults, +but one that parses and fails schema validation throws at import. That file decides which +tools are exposed, so ignoring a broken one would silently re-enable every tool the author +meant to switch off. + ### Testing Philosophy Integration tests against real platformOS instances for reliability. Tests cover: - Deploy (various strategies, error handling) diff --git a/lib/proxy.js b/lib/proxy.js index 1f5002d8..9f3311c2 100644 --- a/lib/proxy.js +++ b/lib/proxy.js @@ -93,7 +93,11 @@ class Gateway { } logs(json, { signal } = {}) { - return apiRequest({ uri: `${this.api_url}/logs?last_id=${json.lastId}`, json: true, forever: true, headers: this.defaultHeaders, signal }); + // Encoded rather than interpolated raw: the cursor reaches this method from a GUI + // query string as well as from internal pollers, and an unencoded value could append + // its own parameters to the request. + const lastId = encodeURIComponent(json.lastId); + return apiRequest({ uri: `${this.api_url}/logs?last_id=${lastId}`, json: true, forever: true, headers: this.defaultHeaders, signal }); } logsv2(params) { diff --git a/lib/server.js b/lib/server.js index 755f25fa..e5c2c14b 100644 --- a/lib/server.js +++ b/lib/server.js @@ -15,6 +15,14 @@ const upload = multer(); import Gateway from '../lib/proxy.js'; import logger from '../lib/logger.js'; +import { validate } from '../lib/validation/index.js'; +import { + graphqlRequestSchema, + liquidRequestSchema, + logsRequestSchema, + logsSearchSchema, + syncRequestSchema +} from '../lib/validation/schemas/gui.js'; const start = (env, client) => { const port = env.PORT || 3333; @@ -39,16 +47,38 @@ const start = (env, client) => { return res.status(status).json({ error: (typeof body === 'string' && body) || error?.message || 'Request failed' }); }; + // Rejects the request when `payload` does not match `schema`, and reports which field + // was at fault so the GUI shows something more useful than an upstream 500. + const rejectInvalid = (res, schema, payload, { mode = 'strict' } = {}) => { + const result = validate(schema, payload, { mode }); + if (result.valid) return false; + + // A schema that will not compile is our defect, not the caller's — report it as a + // server error, but still reject, since nothing was actually checked. + const status = result.schemaError ? 500 : 400; + + logger.Debug(`Rejected invalid request: ${result.message}`); + res.status(status).json({ error: `Invalid request: ${result.message}`, details: result.errors }); + return true; + }; + const graphqlRouting = (req, res) => { + if (rejectInvalid(res, graphqlRequestSchema, req.body)) return; + gateway .graph(req.body) .then(body => res.send(body)) .catch(error => sendError(res, error)); }; + // POST carries the template as a JSON body; the form in gui/liquid falls back to a GET + // with `?content=...`, so the payload has to be read from whichever the request used. const liquidRouting = (req, res) => { + const payload = req.method === 'GET' ? req.query : req.body; + if (rejectInvalid(res, liquidRequestSchema, payload)) return; + gateway - .liquid(req.body) + .liquid(payload) .then(body => res.send(body)) .catch(error => sendError(res, error)); }; @@ -85,15 +115,23 @@ const start = (env, client) => { app.get('/api/liquid', liquidRouting); app.get('/api/logs', (req, res) => { + const params = { ...req.query }; + if (rejectInvalid(res, logsRequestSchema, params, { mode: 'coercing' })) return; + gateway - .logs({ lastId: req.query.lastId }) + .logs({ lastId: params.lastId }) .then(body => res.send(body)) .catch(error => sendError(res, error)); }); + // Coercing mode on both verbs: the GET route can only deliver strings, and a POST body + // that spells a numeric field as a string is normalised rather than rejected. app.get('/api/logsv2', (req, res) => { + const params = { ...req.query }; + if (rejectInvalid(res, logsSearchSchema, params, { mode: 'coercing' })) return; + gateway - .logsv2({ ...req.query }) + .logsv2(params) .then(body => { res.send(body); }) @@ -103,6 +141,8 @@ const start = (env, client) => { }); app.post('/api/logsv2', (req, res) => { + if (rejectInvalid(res, logsSearchSchema, req.body, { mode: 'coercing' })) return; + gateway .logsv2(req.body) .then(body => { @@ -121,9 +161,19 @@ const start = (env, client) => { '/api/app_builder/marketplace_releases/sync', upload.fields([{ name: 'path' }, { name: 'marketplace_builder_file_body' }]), (req, res) => { + if (rejectInvalid(res, syncRequestSchema, req.body)) return; + + // The upload arrives through multer rather than the JSON body, so it is guarded + // here instead of in the schema. Without this the missing-field case dereferenced + // undefined and surfaced as an unhandled 500. + const uploaded = req.files?.marketplace_builder_file_body?.[0]; + if (!uploaded) { + return res.status(400).json({ error: 'Invalid request: missing file field marketplace_builder_file_body' }); + } + const formData = { path: req.body.path, - marketplace_builder_file_body: req.files.marketplace_builder_file_body[0].buffer + marketplace_builder_file_body: uploaded.buffer }; gateway @@ -133,7 +183,8 @@ const start = (env, client) => { } ); - app + // Returned so callers (and tests) can shut the listener down. + return app .listen(port, host, function() { logger.Debug(`Server is listening on ${port}`); logger.Success(`Connected to ${env.MARKETPLACE_URL}`); diff --git a/lib/validation/index.js b/lib/validation/index.js new file mode 100644 index 00000000..1d683d47 --- /dev/null +++ b/lib/validation/index.js @@ -0,0 +1,106 @@ +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; + +/** + * Runtime input validation, backed by Ajv (JSON Schema draft-07). + * + * Every MCP tool in mcp-min/ already declares its `inputSchema` as JSON Schema, and + * that same object is advertised to clients verbatim in `tools/list`. Validating + * against it means the contract we publish and the contract we enforce are one + * object — there is no second definition that can drift out of sync. + */ + +// Two Ajv instances, because the two input shapes need different treatment: +// +// strict - JSON request bodies. Values arrive with real types, so nothing is +// rewritten and the caller's data is left untouched. +// coercing - query strings. Every value arrives as a string, so `?size=100` has to +// become a number before `{ type: 'integer' }` can accept it. Ajv applies +// coercion and defaults by mutating the validated object in place, which +// is what callers reading `req.query` afterwards want. +// strict mode stays on so a malformed schema fails loudly at compile time rather than +// silently validating nothing. `allowUnionTypes` is the one rule relaxed: fields that +// legitimately accept more than one type exist (a search payload that arrives as an +// object over POST and as a string over GET), and Ajv otherwise rejects the schema. +const buildInstance = options => { + const ajv = new Ajv({ allErrors: true, strict: true, allowUnionTypes: true, ...options }); + addFormats(ajv); + return { ajv, compiled: new WeakMap() }; +}; + +const instances = { + strict: buildInstance({}), + coercing: buildInstance({ coerceTypes: true, useDefaults: true }) +}; + +// Schemas are stable module-level objects, so a WeakMap keyed on the schema keeps one +// compiled validator per schema for the process lifetime without pinning it in memory. +const validatorFor = (schema, mode) => { + const instance = instances[mode]; + if (!instance) throw new Error(`Unknown validation mode: ${mode}`); + + let validator = instance.compiled.get(schema); + if (!validator) { + validator = instance.ajv.compile(schema); + instance.compiled.set(schema, validator); + } + return validator; +}; + +// Ajv's raw messages drop the detail that matters most for the two failures callers hit +// constantly: which property is missing, and which unknown one was rejected. +const describeError = error => { + const at = error.instancePath || '(root)'; + + switch (error.keyword) { + case 'required': + return `${at} is missing required property '${error.params.missingProperty}'`; + case 'additionalProperties': + return `${at} has unknown property '${error.params.additionalProperty}'`; + default: + return `${at} ${error.message}`; + } +}; + +const MESSAGE_ERROR_LIMIT = 5; + +const summarize = errors => { + const shown = errors.slice(0, MESSAGE_ERROR_LIMIT).map(error => error.message); + const hidden = errors.length - shown.length; + return hidden > 0 ? `${shown.join('; ')} (+${hidden} more)` : shown.join('; '); +}; + +/** + * Validate `data` against `schema`. + * + * @param {object} schema - JSON Schema (draft-07) + * @param {*} data - value to validate; with mode 'coercing' it is mutated in place + * @param {object} [options] + * @param {'strict'|'coercing'} [options.mode] - see the instances above + * @returns {{valid: boolean, data: *, errors?: Array<{path: string, message: string}>, + * message?: string, schemaError?: boolean}} + * `schemaError` marks a schema that failed to compile. That is a defect in our own + * schema rather than bad input, so callers should report it as a server-side error — + * but still reject the call, since an uncompilable schema means nothing was checked. + */ +const validate = (schema, data, { mode = 'strict' } = {}) => { + let validator; + try { + validator = validatorFor(schema, mode); + } catch (err) { + const message = `Schema failed to compile: ${err.message}`; + return { valid: false, data, schemaError: true, message, errors: [{ path: '(schema)', message }] }; + } + + if (validator(data)) return { valid: true, data }; + + const errors = (validator.errors || []).map(error => ({ + path: error.instancePath || '(root)', + message: describeError(error) + })); + + return { valid: false, data, errors, message: summarize(errors) }; +}; + +export { validate, describeError }; +export default validate; diff --git a/lib/validation/schemas/gui.js b/lib/validation/schemas/gui.js new file mode 100644 index 00000000..88924207 --- /dev/null +++ b/lib/validation/schemas/gui.js @@ -0,0 +1,78 @@ +/** + * Request schemas for the local GUI server (lib/server.js). + * + * These endpoints proxy straight through to the connected platformOS instance, and + * lib/server.js sets `Access-Control-Allow-Origin: *`, so any page open in the + * developer's browser can reach them while the GUI is running. Validating here rejects + * malformed input before it is forwarded with the user's API token attached. + * + * The schemas stay open (`additionalProperties: true`) on purpose. The pages that call + * these endpoints are pre-built bundles (GraphiQL in particular sends whatever its + * fetcher assembles), so pinning an exact property set would risk rejecting a legitimate + * field we cannot see from here. What is checked is what the handlers actually depend + * on: that the payload is an object and that its known fields have workable types. + */ + +const graphqlRequestSchema = { + type: 'object', + additionalProperties: true, + required: ['query'], + properties: { + query: { type: 'string', minLength: 1 }, + variables: { type: ['object', 'null'] }, + operationName: { type: ['string', 'null'] } + } +}; + +const liquidRequestSchema = { + type: 'object', + additionalProperties: true, + required: ['content'], + properties: { + content: { type: 'string' } + } +}; + +// Mirrors what Gateway.logsv2 branches on (`query`, then `key`, then a plain SQL search) +// and what swagger-client's buildQuery/searchAround read off the params. +// +// `query` accepts a string as well as an object because that branch forwards the whole +// params object as the request body, and the GET route can only ever deliver a string. +const logsSearchSchema = { + type: 'object', + additionalProperties: true, + properties: { + query: { type: ['object', 'string'] }, + key: { type: 'string' }, + stream_name: { type: 'string' }, + sql: { type: 'string' }, + size: { type: 'integer', minimum: 0 }, + from: { type: 'integer', minimum: 0 }, + start_time: { type: 'integer' }, + end_time: { type: 'integer' } + } +}; + +// `lastId` is a log row id (`row.id`, seeded from 0 — see lib/test-runner/logStream.js) +// and Gateway.logs interpolates it straight into the request URL, so constraining it to an +// integer is what stops a caller from appending their own query parameters. It stays +// optional because the GUI's first poll sends no cursor at all. +const logsRequestSchema = { + type: 'object', + additionalProperties: true, + properties: { + lastId: { type: 'integer', minimum: 0 } + } +}; + +// The file itself arrives via multer, not JSON, so only `path` is schema-checkable here. +const syncRequestSchema = { + type: 'object', + additionalProperties: true, + required: ['path'], + properties: { + path: { type: 'string', minLength: 1 } + } +}; + +export { graphqlRequestSchema, liquidRequestSchema, logsRequestSchema, logsSearchSchema, syncRequestSchema }; diff --git a/mcp-min/__tests__/constants.test.js b/mcp-min/__tests__/constants.test.js index 129ea549..bb9d46df 100644 --- a/mcp-min/__tests__/constants.test.js +++ b/mcp-min/__tests__/constants.test.js @@ -88,8 +88,12 @@ describe('constants-list', () => { expect(res.error.message).toBe('Unauthorized'); }); + // `env` is optional across these tools: resolveAuth also accepts explicit + // url/email/token, MPKIT_* env vars, or the first .pos entry. test('has correct schema', () => { - expect(constantsListTool.inputSchema.required).toContain('env'); + expect(constantsListTool.inputSchema.required).toBeUndefined(); + expect(constantsListTool.inputSchema.properties).toHaveProperty('env'); + expect(constantsListTool.inputSchema.properties).toHaveProperty('token'); }); }); @@ -183,9 +187,10 @@ describe('constants-set', () => { }); test('has correct schema', () => { - expect(constantsSetTool.inputSchema.required).toContain('env'); + expect(constantsSetTool.inputSchema.required).not.toContain('env'); expect(constantsSetTool.inputSchema.required).toContain('name'); expect(constantsSetTool.inputSchema.required).toContain('value'); + expect(constantsSetTool.inputSchema.properties).toHaveProperty('env'); }); }); @@ -259,7 +264,8 @@ describe('constants-unset', () => { }); test('has correct schema', () => { - expect(constantsUnsetTool.inputSchema.required).toContain('env'); + expect(constantsUnsetTool.inputSchema.required).not.toContain('env'); expect(constantsUnsetTool.inputSchema.required).toContain('name'); + expect(constantsUnsetTool.inputSchema.properties).toHaveProperty('env'); }); }); diff --git a/mcp-min/__tests__/data.import.test.js b/mcp-min/__tests__/data.import.test.js index 0c867bc3..247a52a0 100644 --- a/mcp-min/__tests__/data.import.test.js +++ b/mcp-min/__tests__/data.import.test.js @@ -23,7 +23,9 @@ describe('data-import tool', () => { expect(dataImportTool.inputSchema.properties).toHaveProperty('filePath'); expect(dataImportTool.inputSchema.properties).toHaveProperty('jsonData'); expect(dataImportTool.inputSchema.properties).toHaveProperty('zipFileUrl'); - expect(dataImportTool.inputSchema.required).toContain('env'); + // `env` is optional: resolveAuth also accepts url/email/token or MPKIT_* env vars. + expect(dataImportTool.inputSchema.required).toBeUndefined(); + expect(dataImportTool.inputSchema.properties).toHaveProperty('token'); }); test('returns error when env not found', async () => { diff --git a/mcp-min/__tests__/tools-config-validation.test.js b/mcp-min/__tests__/tools-config-validation.test.js new file mode 100644 index 00000000..735cee3f --- /dev/null +++ b/mcp-min/__tests__/tools-config-validation.test.js @@ -0,0 +1,99 @@ +/** + * tools.config.json decides which tools the MCP server exposes, so a config that does not + * match tools.config.schema.json must stop the server rather than be ignored — ignoring it + * would silently re-enable every tool the author meant to switch off. + * + * Each case runs in its own process because tools.js reads the config once, at import. + */ +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, test, expect, beforeAll, afterAll } from 'vitest'; + +let tmpDir; + +const write = (name, contents) => { + const file = path.join(tmpDir, name); + fs.writeFileSync(file, contents); + return file; +}; + +// Reports what `import('./mcp-min/tools.js')` did under the given config. +const loadWithConfig = configPath => { + const script = + "import('./mcp-min/tools.js')" + + ".then(m => console.log('OK:' + Object.keys(m.default).join(',')))" + + ".catch(e => { console.log('REJECTED:' + e.message); process.exitCode = 1; })"; + + const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { + cwd: process.cwd(), + env: { ...process.env, MCP_TOOLS_CONFIG: configPath }, + encoding: 'utf8' + }); + + return { stdout: result.stdout || '', status: result.status }; +}; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pos-cli-tools-config-')); +}); + +afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('tools config validation', () => { + test('refuses to start when a config field has the wrong type', () => { + const configPath = write('wrong-type.json', JSON.stringify({ tools: { 'envs-list': { enabled: 'yes' } } })); + const { stdout, status } = loadWithConfig(configPath); + + expect(stdout).toContain('REJECTED:'); + expect(stdout).toContain('/tools/envs-list/enabled must be boolean'); + expect(status).not.toBe(0); + }); + + test('refuses to start when the config is not an object', () => { + const configPath = write('array.json', JSON.stringify(['envs-list'])); + const { stdout } = loadWithConfig(configPath); + + expect(stdout).toContain('REJECTED:'); + }); + + // These parse as valid JSON but are not configs. Testing the parsed value for + // truthiness would skip validation and fall through to defaults with everything on. + test.each(['null', 'false', '0', '""'])('refuses to start on a bare %s config', literal => { + const configPath = write(`falsy-${literal.replace(/\W/g, '_')}.json`, literal); + const { stdout, status } = loadWithConfig(configPath); + + expect(stdout).toContain('REJECTED:'); + expect(status).not.toBe(0); + }); + + test('applies a valid config and disables the named tool', () => { + const configPath = write('valid.json', JSON.stringify({ tools: { 'envs-list': { enabled: false } } })); + const { stdout, status } = loadWithConfig(configPath); + + expect(status).toBe(0); + expect(stdout).toContain('OK:'); + expect(stdout).not.toContain('envs-list'); + }); + + test('falls back to defaults when the config file does not exist', () => { + const { stdout, status } = loadWithConfig(path.join(tmpDir, 'absent.json')); + + expect(status).toBe(0); + expect(stdout).toContain('envs-list'); + }); + + test('the config shipped in the package satisfies its own schema', () => { + const here = path.dirname(new URL(import.meta.url).pathname); + const schema = JSON.parse(fs.readFileSync(path.join(here, '..', 'tools.config.schema.json'), 'utf8')); + const config = JSON.parse(fs.readFileSync(path.join(here, '..', 'tools.config.json'), 'utf8')); + + // Imported here rather than at the top so this file stays runnable in isolation. + return import('../../lib/validation/index.js').then(({ validate }) => { + expect(validate(schema, config).errors ?? []).toEqual([]); + }); + }); +}); diff --git a/mcp-min/__tests__/transport-validation.test.js b/mcp-min/__tests__/transport-validation.test.js new file mode 100644 index 00000000..6668dbe6 --- /dev/null +++ b/mcp-min/__tests__/transport-validation.test.js @@ -0,0 +1,148 @@ +/** + * Both MCP transports must reject params that do not match the schema they advertise in + * tools/list, before the params ever reach a handler. + */ +import http from 'http'; +import { spawn } from 'child_process'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { describe, test, expect, beforeAll, afterAll } from 'vitest'; +import startHttp from '../http-server.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const stdioScript = resolve(__dirname, '..', 'stdio-server.js'); + +const PORT = 5931; +let server; + +const post = (path, body) => + new Promise((resolvePromise, reject) => { + const req = http.request( + { hostname: '127.0.0.1', port: PORT, path, method: 'POST', headers: { 'Content-Type': 'application/json' } }, + res => { + let data = ''; + res.on('data', chunk => (data += chunk)); + res.on('end', () => resolvePromise({ status: res.statusCode, body: data ? JSON.parse(data) : null })); + } + ); + req.on('error', reject); + req.write(JSON.stringify(body)); + req.end(); + }); + +// No .pos fixture on purpose: every assertion here is about params being rejected before +// a handler runs, and writing .pos into the working directory races other suites. +beforeAll(async () => { + server = await startHttp({ port: PORT }); +}); + +afterAll(() => { + if (server) server.close(); +}); + +describe('HTTP POST /call', () => { + test('rejects a missing required param with 400', async () => { + const res = await post('/call', { tool: 'constants-set', params: { env: 'staging' } }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("missing required property 'name'"); + expect(res.body.details).toBeInstanceOf(Array); + }); + + test('rejects an unknown param with 400', async () => { + const res = await post('/call', { tool: 'envs-list', params: {} }); + expect(res.status).toBe(200); + + const rejected = await post('/call', { tool: 'constants-list', params: { env: 'staging', wat: 1 } }); + expect(rejected.status).toBe(400); + expect(rejected.body.error).toContain("unknown property 'wat'"); + }); + + test('rejects a param of the wrong type with 400', async () => { + const res = await post('/call', { tool: 'logs-fetch', params: { limit: 'all' } }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('/limit'); + }); + + test('still accepts params that match the schema', async () => { + const res = await post('/call', { tool: 'envs-list', params: {} }); + + expect(res.status).toBe(200); + expect(res.body.result.ok).toBe(true); + }); +}); + +describe('HTTP JSON-RPC tools/call', () => { + test('rejects invalid params with -32602', async () => { + const res = await post('/call-stream', { + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { name: 'constants-set', arguments: { env: 'staging' } } + }); + + expect(res.status).toBe(200); + expect(res.body.error.code).toBe(-32602); + expect(res.body.error.message).toContain("missing required property 'name'"); + }); + + test('accepts valid params', async () => { + const res = await post('/call-stream', { + jsonrpc: '2.0', + id: 8, + method: 'tools/call', + params: { name: 'envs-list', arguments: {} } + }); + + expect(res.body.error).toBeUndefined(); + expect(res.body.result.content).toBeDefined(); + }); +}); + +describe('stdio tools/call', () => { + test('rejects invalid params with -32602', () => new Promise((done, reject) => { + const child = spawn(process.execPath, [stdioScript], { stdio: ['pipe', 'pipe', 'pipe'] }); + let buffered = ''; + let initialized = false; + + const fail = err => { child.kill(); reject(err); }; + + child.stdout.on('data', chunk => { + buffered += chunk.toString(); + + if (!initialized && buffered.includes('protocolVersion')) { + initialized = true; + child.stdin.write(JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'constants-set', arguments: { env: 'staging' } } + }) + '\n'); + return; + } + + const line = buffered.split('\n').find(l => l.includes('"id":2')); + if (!line) return; + + try { + const response = JSON.parse(line); + expect(response.error.code).toBe(-32602); + expect(response.error.message).toContain("missing required property 'name'"); + child.kill(); + done(); + } catch (err) { + fail(err); + } + }); + + child.on('error', fail); + + child.stdin.write(JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {} } + }) + '\n'); + }), 15000); +}); diff --git a/mcp-min/__tests__/uploads.push.test.js b/mcp-min/__tests__/uploads.push.test.js index 66f19cf0..cc8d8abe 100644 --- a/mcp-min/__tests__/uploads.push.test.js +++ b/mcp-min/__tests__/uploads.push.test.js @@ -93,7 +93,8 @@ describe('uploads-push', () => { test('has correct description and schema with required fields', () => { expect(uploadsTool.description).toContain('ZIP'); - expect(uploadsTool.inputSchema.required).toContain('env'); + // `env` is optional: resolveAuth also accepts url/email/token or MPKIT_* env vars. + expect(uploadsTool.inputSchema.required).not.toContain('env'); expect(uploadsTool.inputSchema.required).toContain('filePath'); expect(uploadsTool.inputSchema.properties.env).toBeDefined(); expect(uploadsTool.inputSchema.properties.filePath).toBeDefined(); diff --git a/mcp-min/__tests__/validate-params.test.js b/mcp-min/__tests__/validate-params.test.js new file mode 100644 index 00000000..e2135d06 --- /dev/null +++ b/mcp-min/__tests__/validate-params.test.js @@ -0,0 +1,97 @@ +import { describe, test, expect } from 'vitest'; +import tools from '../tools.js'; +import { validateToolParams } from '../validate-params.js'; + +const check = (name, params) => validateToolParams(name, tools[name], params); + +describe('tool input schemas', () => { + test('every registered tool has a schema Ajv can compile', () => { + const uncompilable = Object.entries(tools) + .filter(([name, tool]) => validateToolParams(name, tool, {}).schemaError) + .map(([name]) => name); + + expect(uncompilable).toEqual([]); + }); + + test('every registered tool declares an object schema', () => { + for (const [name, tool] of Object.entries(tools)) { + expect(tool.inputSchema?.type, `${name} inputSchema.type`).toBe('object'); + } + }); +}); + +describe('validateToolParams', () => { + test('accepts params that match the schema', () => { + expect(check('constants-set', { env: 'staging', name: 'API_KEY', value: 'x' }).valid).toBe(true); + }); + + test('rejects missing required params', () => { + const result = check('constants-set', { env: 'staging' }); + expect(result.valid).toBe(false); + expect(result.message).toContain("missing required property 'name'"); + }); + + test('rejects unknown params on a closed schema', () => { + const result = check('constants-list', { env: 'staging', dropTable: true }); + expect(result.valid).toBe(false); + expect(result.message).toContain("unknown property 'dropTable'"); + }); + + test('rejects a param of the wrong type', () => { + const result = check('logs-fetch', { limit: 'all' }); + expect(result.valid).toBe(false); + expect(result.errors[0].path).toBe('/limit'); + }); + + test('rejects a param outside its declared range', () => { + expect(check('logs-fetch', { limit: 999999 }).valid).toBe(false); + }); + + test('rejects params that are not an object at all', () => { + expect(check('constants-list', 'staging').valid).toBe(false); + expect(check('constants-list', ['staging']).valid).toBe(false); + }); + + test('treats absent params as an empty object', () => { + expect(check('envs-list', undefined).valid).toBe(true); + expect(check('constants-set', undefined).valid).toBe(false); + }); +}); + +// resolveAuth resolves credentials from params, then MPKIT_* env vars, then .pos — so a +// schema that made `env` mandatory would reject two of its three supported call styles. +describe('authentication params stay accepted', () => { + const authenticating = [ + 'constants-list', + 'constants-set', + 'constants-unset', + 'data-import', + 'data-import-status', + 'uploads-push', + 'unit-tests-run' + ]; + + const requiredExtras = { + 'constants-set': { name: 'A', value: '1' }, + 'constants-unset': { name: 'A' }, + 'data-import-status': { jobId: '1' }, + 'uploads-push': { filePath: 'uploads.zip' }, + 'unit-tests-run': { name: 'example_test' } + }; + + test.each(authenticating)('%s accepts explicit url/email/token without env', name => { + const params = { url: 'https://example.com', email: 'a@b.c', token: 'tok', ...requiredExtras[name] }; + const result = check(name, params); + + expect(result.errors ?? []).toEqual([]); + expect(result.valid).toBe(true); + }); + + test.each(authenticating)('%s accepts env alone', name => { + expect(check(name, { env: 'staging', ...requiredExtras[name] }).valid).toBe(true); + }); + + test.each(authenticating)('%s accepts no auth params (MPKIT_* / default .pos entry)', name => { + expect(check(name, { ...requiredExtras[name] }).valid).toBe(true); + }); +}); diff --git a/mcp-min/constants/list.js b/mcp-min/constants/list.js index a561a88b..a146af84 100644 --- a/mcp-min/constants/list.js +++ b/mcp-min/constants/list.js @@ -3,15 +3,16 @@ import { resolveAuth } from '../auth.js'; import Gateway from '../../lib/proxy.js'; import { getConstants } from '../../lib/graph/queries.js'; import { graphQLErrorMessage } from '../../lib/graph/response.js'; +import { authProperties } from '../schemas/auth.js'; const constantsListTool = { description: 'List all constants configured on a platformOS instance.', inputSchema: { type: 'object', additionalProperties: false, - required: ['env'], properties: { - env: { type: 'string', description: 'Environment name from .pos config' } + env: { type: 'string', description: 'Environment name from .pos config' }, + ...authProperties } }, handler: async (params, ctx = {}) => { diff --git a/mcp-min/constants/set.js b/mcp-min/constants/set.js index 874d13fa..9b36db6d 100644 --- a/mcp-min/constants/set.js +++ b/mcp-min/constants/set.js @@ -3,15 +3,17 @@ import { resolveAuth } from '../auth.js'; import Gateway from '../../lib/proxy.js'; import { setConstant } from '../../lib/graph/queries.js'; import { graphQLErrorMessage } from '../../lib/graph/response.js'; +import { authProperties } from '../schemas/auth.js'; const constantsSetTool = { description: 'Set a constant on a platformOS instance. Creates or updates the constant.', inputSchema: { type: 'object', additionalProperties: false, - required: ['env', 'name', 'value'], + required: ['name', 'value'], properties: { env: { type: 'string', description: 'Environment name from .pos config' }, + ...authProperties, name: { type: 'string', description: 'Name of the constant (e.g., API_KEY)' }, value: { type: 'string', description: 'Value of the constant' } } diff --git a/mcp-min/constants/unset.js b/mcp-min/constants/unset.js index 5e9963bb..4221b5d0 100644 --- a/mcp-min/constants/unset.js +++ b/mcp-min/constants/unset.js @@ -3,15 +3,17 @@ import { resolveAuth } from '../auth.js'; import Gateway from '../../lib/proxy.js'; import { unsetConstant } from '../../lib/graph/queries.js'; import { graphQLErrorMessage } from '../../lib/graph/response.js'; +import { authProperties } from '../schemas/auth.js'; const constantsUnsetTool = { description: 'Delete a constant from a platformOS instance.', inputSchema: { type: 'object', additionalProperties: false, - required: ['env', 'name'], + required: ['name'], properties: { env: { type: 'string', description: 'Environment name from .pos config' }, + ...authProperties, name: { type: 'string', description: 'Name of the constant to delete' } } }, diff --git a/mcp-min/data/import-status.js b/mcp-min/data/import-status.js index 9c64e920..e8b024c1 100644 --- a/mcp-min/data/import-status.js +++ b/mcp-min/data/import-status.js @@ -2,15 +2,17 @@ import log from '../log.js'; import { resolveAuth } from '../auth.js'; import Gateway from '../../lib/proxy.js'; +import { authProperties } from '../schemas/auth.js'; const dataImportStatusTool = { description: 'Check the status of a data import job. Poll until status is "done" or "failed".', inputSchema: { type: 'object', additionalProperties: false, - required: ['env', 'jobId'], + required: ['jobId'], properties: { env: { type: 'string', description: 'Environment name from .pos config' }, + ...authProperties, jobId: { type: 'string', description: 'Import job ID returned from data-import' } } }, diff --git a/mcp-min/data/import.js b/mcp-min/data/import.js index d9ae1df9..e171fe6d 100644 --- a/mcp-min/data/import.js +++ b/mcp-min/data/import.js @@ -6,6 +6,7 @@ import path from 'path'; import os from 'os'; import { jsonToZipBuffer } from './json-to-csv.js'; import { validateRecords, validateJsonStructure } from './validate.js'; +import { authProperties } from '../schemas/auth.js'; import log from '../log.js'; import { resolveAuth, runWithAuth } from '../auth.js'; import Gateway from '../../lib/proxy.js'; @@ -37,9 +38,9 @@ const dataImportTool = { inputSchema: { type: 'object', additionalProperties: false, - required: ['env'], properties: { env: { type: 'string', description: 'Environment name from .pos config' }, + ...authProperties, filePath: { type: 'string', description: 'Path to JSON or ZIP file to import' }, jsonData: { type: 'object', description: 'JSON data object to import (records, users)' }, zipFileUrl: { type: 'string', description: 'Remote URL of ZIP archive to import' }, diff --git a/mcp-min/data/validate-tool.js b/mcp-min/data/validate-tool.js index 69d34408..1a9f6afe 100644 --- a/mcp-min/data/validate-tool.js +++ b/mcp-min/data/validate-tool.js @@ -9,7 +9,7 @@ const dataValidateTool = { inputSchema: { type: 'object', additionalProperties: false, - required: ['env'], + // Nothing is required: validation runs entirely locally, and `env` is context only. properties: { env: { type: 'string', diff --git a/mcp-min/http-server.js b/mcp-min/http-server.js index 86e430b2..9cbc48ac 100644 --- a/mcp-min/http-server.js +++ b/mcp-min/http-server.js @@ -2,6 +2,7 @@ import express from 'express'; import bodyParser from 'body-parser'; import { randomUUID } from 'crypto'; import tools from './tools.js'; +import { validateToolParams } from './validate-params.js'; import { sseHandler, writeSSE } from './sse.js'; import { DEBUG } from './config.js'; import log from './log.js'; @@ -92,6 +93,14 @@ export default async function startHttp({ port = 5910 } = {}) { const entry = tools[tool]; if (!entry) return res.status(404).json({ error: `tool not found: ${tool}` }); + const validation = validateToolParams(tool, entry, params); + if (!validation.valid) { + // A schema that will not compile is our defect, not the caller's — but the call is + // still rejected, because an uncompilable schema means nothing was checked. + const status = validation.schemaError ? 500 : 400; + return res.status(status).json({ error: `invalid params: ${validation.message}`, details: validation.errors }); + } + try { log.debug('HTTP /call', { tool, params, rawBodyKeys: Object.keys(body) }); const result = await entry.handler(params || {}, { transport: 'http', debug: DEBUG }); @@ -182,6 +191,12 @@ export default async function startHttp({ port = 5910 } = {}) { respond({ error: { code: -32601, message: `Tool not found: ${name}` } }); return; } + const validation = validateToolParams(name, entry, args); + if (!validation.valid) { + const code = validation.schemaError ? -32603 : -32602; + respond({ error: { code, message: `Invalid params: ${validation.message}`, data: { errors: validation.errors } } }); + return; + } const result = await entry.handler(args, { transport: 'jsonrpc', debug: DEBUG }); // Wrap result as text content for broad client compatibility const text = (() => { try { return JSON.stringify(result); } catch { return String(result); } })(); @@ -211,6 +226,14 @@ export default async function startHttp({ port = 5910 } = {}) { const entry = tools[tool]; if (!entry) return res.status(404).json({ error: `tool not found: ${tool}` }); + // Validate before the SSE handshake: once the stream is open the status code is + // already sent, so a rejection could only be reported as an in-band error event. + const streamValidation = validateToolParams(tool, entry, params); + if (!streamValidation.valid) { + const status = streamValidation.schemaError ? 500 : 400; + return res.status(status).json({ error: `invalid params: ${streamValidation.message}`, details: streamValidation.errors }); + } + // Prepare SSE response sseHandler(req, res); diff --git a/mcp-min/schemas/auth.js b/mcp-min/schemas/auth.js new file mode 100644 index 00000000..3502d11c --- /dev/null +++ b/mcp-min/schemas/auth.js @@ -0,0 +1,19 @@ +/** + * Shared input-schema fragment for the explicit-credentials path. + * + * `resolveAuth` (mcp-min/auth.js) accepts `url` + `email` + `token` on the params of + * every tool that authenticates, ahead of the `.pos` environment lookup. Tools that + * close their schema with `additionalProperties: false` must therefore declare these + * three, or validation would reject the very callers that path exists to serve. + * + * `env` is deliberately not included: it is required on some tools and optional on + * others, and its description varies, so it stays declared per tool. + */ +const authProperties = { + url: { type: 'string', description: 'Instance URL (with email and token, bypasses .pos)' }, + email: { type: 'string', description: 'Account email (with url and token, bypasses .pos)' }, + token: { type: 'string', description: 'API token (with url and email, bypasses .pos)' } +}; + +export { authProperties }; +export default authProperties; diff --git a/mcp-min/stdio-server.js b/mcp-min/stdio-server.js index 5951fec4..c97c58b8 100644 --- a/mcp-min/stdio-server.js +++ b/mcp-min/stdio-server.js @@ -2,6 +2,7 @@ import { createInterface } from 'readline'; import { fileURLToPath } from 'url'; import path from 'path'; import tools from './tools.js'; +import { validateToolParams } from './validate-params.js'; import { DEBUG } from './config.js'; import log from './log.js'; @@ -84,6 +85,15 @@ const mcpHandlers = { return; } + const validation = validateToolParams(name, tool, args); + if (!validation.valid) { + // -32603 (internal error) when our own schema failed to compile; -32602 (invalid + // params) when the caller is genuinely at fault. + const code = validation.schemaError ? -32603 : -32602; + sendError(id, code, `Invalid params: ${validation.message}`, { errors: validation.errors }); + return; + } + // Send progress notification (keeps connection alive, prevents client timeout) let progressCounter = 0; function sendProgress(current, total, message) { @@ -160,6 +170,18 @@ export default function startStdio() { // Fallback: direct tool invocation (legacy/custom protocol) const tool = tools[method]; if (tool) { + const validation = validateToolParams(method, tool, params); + if (!validation.valid) { + const message = `Invalid params: ${validation.message}`; + if (jsonrpc === '2.0') { + sendError(id, validation.schemaError ? -32603 : -32602, message, { errors: validation.errors }); + } else { + send({ id, error: message }); + } + log.debug('STDIO invalid params', { id, method, message }); + return; + } + try { const result = await tool.handler(params || {}, { transport: 'stdio', debug: DEBUG, log: log.info.bind(log) }); if (jsonrpc === '2.0') { diff --git a/mcp-min/tests/run.js b/mcp-min/tests/run.js index 39bf54a3..f0a88a8c 100644 --- a/mcp-min/tests/run.js +++ b/mcp-min/tests/run.js @@ -257,7 +257,9 @@ const testsRunTool = { path: { type: 'string', description: 'Optional test path filter (e.g., "tests/users")' }, name: { type: 'string', description: 'Test name filter (e.g., "create_user_test"). Required to avoid running all tests which causes timeouts.' } }, - required: ['env', 'name'] + // `env` is not required: resolveAuth also accepts url+email+token, MPKIT_* env + // vars, or falls back to the first .pos environment. + required: ['name'] }, handler: async (params, ctx = {}) => { const startedAt = new Date().toISOString(); diff --git a/mcp-min/tools.js b/mcp-min/tools.js index 0e1f8bb3..a6f11ffc 100644 --- a/mcp-min/tools.js +++ b/mcp-min/tools.js @@ -4,17 +4,39 @@ import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import files from '../lib/files.js'; +import { validate } from '../lib/validation/index.js'; // Load tool configuration (descriptions and enabled/disabled state) // MCP_TOOLS_CONFIG env var overrides the bundled config const __dirname = dirname(fileURLToPath(import.meta.url)); let toolsConfig = { tools: {} }; const configPath = process.env.MCP_TOOLS_CONFIG || join(__dirname, 'tools.config.json'); +const configSchema = JSON.parse(readFileSync(join(__dirname, 'tools.config.schema.json'), 'utf-8')); + +// Tracked separately from the parsed value: `null`, `false`, `0` and `""` are all valid +// JSON, so testing the value for truthiness would skip validation on exactly the configs +// that need rejecting and fall through to defaults with every tool enabled. +let rawConfig; +let configParsed = false; try { - toolsConfig = JSON.parse(readFileSync(configPath, 'utf-8')); - log.debug('tools config loaded', { path: configPath, tools: Object.keys(toolsConfig.tools || {}).length }); + rawConfig = JSON.parse(readFileSync(configPath, 'utf-8')); + configParsed = true; } catch (err) { - log.debug('tools config not found or invalid, using defaults', { path: configPath, error: String(err) }); + // A missing file is the normal case for a custom path that was never created, and a + // malformed one cannot be interpreted at all. Either way there is nothing to apply. + log.debug('tools config not found or unparseable, using defaults', { path: configPath, error: String(err) }); +} + +if (configParsed) { + const result = validate(configSchema, rawConfig); + if (!result.valid) { + // Fail closed. This config decides which tools are exposed, so ignoring a broken one + // would silently re-enable every tool the author meant to switch off. + log.error(`invalid tools config at ${configPath}: ${result.message}`); + throw new Error(`Invalid tools config at ${configPath}: ${result.message}`); + } + toolsConfig = rawConfig; + log.debug('tools config loaded', { path: configPath, tools: Object.keys(toolsConfig.tools || {}).length }); } // Keep tools.js lean by extracting complex tools into modules diff --git a/mcp-min/uploads/push.js b/mcp-min/uploads/push.js index 77662a7f..f778f978 100644 --- a/mcp-min/uploads/push.js +++ b/mcp-min/uploads/push.js @@ -7,15 +7,17 @@ import Gateway from '../../lib/proxy.js'; import { presignUrl } from '../../lib/presignUrl.js'; import { uploadFile } from '../../lib/s3UploadFile.js'; import { resolveAuth, runWithAuth } from '../auth.js'; +import { authProperties } from '../schemas/auth.js'; const uploadsPushTool = { description: 'Upload a ZIP file containing property uploads to platformOS instance. The ZIP should contain files referenced by upload-type properties.', inputSchema: { type: 'object', additionalProperties: false, - required: ['env', 'filePath'], + required: ['filePath'], properties: { env: { type: 'string', description: 'Environment name' }, + ...authProperties, filePath: { type: 'string', description: 'Path to ZIP file with uploads' } } }, diff --git a/mcp-min/validate-params.js b/mcp-min/validate-params.js new file mode 100644 index 00000000..61ecf368 --- /dev/null +++ b/mcp-min/validate-params.js @@ -0,0 +1,34 @@ +import { validate } from '../lib/validation/index.js'; +import log from './log.js'; + +// Tools that declare no schema accept any object — which is exactly what the transports +// already advertise on their behalf in tools/list. +const OPEN_SCHEMA = { type: 'object' }; + +/** + * Validate tool params against the tool's advertised `inputSchema`. + * + * Both transports call this before handing params to a handler, so the schema shown to + * clients in tools/list is the schema those params are actually checked against. + * + * @param {string} name - tool name, for logging + * @param {object} tool - tool entry from tools.js + * @param {*} params - untrusted params from the client + * @returns {{valid: boolean, errors?: Array, message?: string, schemaError?: boolean}} + */ +const validateToolParams = (name, tool, params) => { + const result = validate(tool.inputSchema || OPEN_SCHEMA, params ?? {}); + + if (!result.valid) { + log.debug('tool params rejected', { + tool: name, + message: result.message, + schemaError: Boolean(result.schemaError) + }); + } + + return result; +}; + +export { validateToolParams }; +export default validateToolParams; diff --git a/package-lock.json b/package-lock.json index d59b6876..b42d38d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,8 @@ "@platformos/platformos-common": "^0.1.0", "@platformos/platformos-language-server-node": "^0.1.0", "@platformos/platformos-mcp-supervisor": "^0.1.0", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "async": "^3.2.6", "body-parser": "^2.2.2", "chalk": "^6.0.0", diff --git a/package.json b/package.json index 037d948b..9e21d90a 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,8 @@ "@platformos/platformos-common": "^0.1.0", "@platformos/platformos-language-server-node": "^0.1.0", "@platformos/platformos-mcp-supervisor": "^0.1.0", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "async": "^3.2.6", "body-parser": "^2.2.2", "chalk": "^6.0.0", diff --git a/test/unit/server.validation.test.js b/test/unit/server.validation.test.js new file mode 100644 index 00000000..19510145 --- /dev/null +++ b/test/unit/server.validation.test.js @@ -0,0 +1,215 @@ +/** + * Request validation on the local GUI server. + * + * These routes proxy to the connected instance with the user's API token attached, and + * lib/server.js allows any origin, so malformed input must be rejected before it is + * forwarded upstream. + */ +import { describe, test, expect, vi, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; + +const forwarded = { graph: [], liquid: [], logs: [], logsv2: [], sync: [] }; + +vi.mock('#lib/proxy.js', () => ({ + default: class Gateway { + graph(body) { + forwarded.graph.push(body); + return Promise.resolve({ data: {} }); + } + liquid(body) { + forwarded.liquid.push(body); + return Promise.resolve({ result: 'ok' }); + } + logs(params) { + forwarded.logs.push(params); + return Promise.resolve({ logs: [] }); + } + logsv2(params) { + forwarded.logsv2.push(params); + return Promise.resolve({ logs: [] }); + } + sync(formData) { + forwarded.sync.push(formData); + return Promise.resolve({ status: 'ok' }); + } + } +})); + +vi.mock('#lib/logger.js', () => ({ + default: { Debug: vi.fn(), Success: vi.fn(), Error: vi.fn(), Print: vi.fn(), Warn: vi.fn() } +})); + +let server; +let agent; + +beforeAll(async () => { + const { start } = await import('#lib/server.js'); + // Port 0 lets the OS pick a free port, so the suite never collides with a real GUI. + server = start({ PORT: 0, HOST: '127.0.0.1', MARKETPLACE_URL: 'https://example.com' }); + agent = request(server); +}); + +afterAll(() => { + if (server) server.close(); +}); + +describe('POST /api/graph', () => { + test('forwards a well-formed query', async () => { + const res = await agent.post('/api/graph').send({ query: '{ records { id } }' }); + + expect(res.status).toBe(200); + expect(forwarded.graph.at(-1).query).toBe('{ records { id } }'); + }); + + test('rejects a missing query without calling upstream', async () => { + const before = forwarded.graph.length; + const res = await agent.post('/api/graph').send({ variables: {} }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("missing required property 'query'"); + expect(forwarded.graph).toHaveLength(before); + }); + + test('rejects an empty query', async () => { + const res = await agent.post('/api/graph').send({ query: '' }); + expect(res.status).toBe(400); + }); + + test('rejects a query of the wrong type', async () => { + const res = await agent.post('/api/graph').send({ query: { evil: true } }); + expect(res.status).toBe(400); + }); + + test('rejects a non-object body', async () => { + const res = await agent.post('/api/graph').set('Content-Type', 'application/json').send('"just a string"'); + expect(res.status).toBe(400); + }); + + test('keeps accepting extra fields the pre-built GraphiQL bundle may send', async () => { + const res = await agent + .post('/api/graph') + .send({ query: '{ a }', operationName: 'A', variables: { x: 1 }, extensions: {} }); + + expect(res.status).toBe(200); + }); +}); + +describe('/api/liquid', () => { + test('forwards a well-formed POST body', async () => { + const res = await agent.post('/api/liquid').send({ content: '{{ 1 | plus: 1 }}' }); + + expect(res.status).toBe(200); + expect(forwarded.liquid.at(-1)).toEqual({ content: '{{ 1 | plus: 1 }}' }); + }); + + test('rejects a POST with no content', async () => { + const res = await agent.post('/api/liquid').send({}); + expect(res.status).toBe(400); + expect(res.body.error).toContain("missing required property 'content'"); + }); + + test('reads content from the query string on GET, matching the GUI form', async () => { + const res = await agent.get('/api/liquid').query({ content: '{{ 2 }}' }); + + expect(res.status).toBe(200); + expect(forwarded.liquid.at(-1).content).toBe('{{ 2 }}'); + }); + + test('rejects a GET with no content', async () => { + const res = await agent.get('/api/liquid'); + expect(res.status).toBe(400); + }); +}); + +describe('GET /api/logs', () => { + test('coerces the cursor to a number', async () => { + const res = await agent.get('/api/logs').query({ lastId: '42' }); + + expect(res.status).toBe(200); + expect(forwarded.logs.at(-1).lastId).toBe(42); + }); + + test('allows a first poll with no cursor', async () => { + const res = await agent.get('/api/logs'); + + expect(res.status).toBe(200); + expect(forwarded.logs.at(-1).lastId).toBeUndefined(); + }); + + // Gateway.logs interpolates the cursor into the request URL, so a value carrying its + // own query parameters must not reach it. + test('rejects a cursor that smuggles extra query parameters', async () => { + const before = forwarded.logs.length; + const res = await agent.get('/api/logs').query({ lastId: '1&admin=true' }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('/lastId'); + expect(forwarded.logs).toHaveLength(before); + }); + + test('rejects a non-numeric cursor', async () => { + expect((await agent.get('/api/logs').query({ lastId: 'newest' })).status).toBe(400); + }); + + test('rejects a negative cursor', async () => { + expect((await agent.get('/api/logs').query({ lastId: '-1' })).status).toBe(400); + }); +}); + +describe('/api/logsv2', () => { + test('coerces numeric query-string params on GET', async () => { + const res = await agent.get('/api/logsv2').query({ sql: 'select 1', size: '25', from: '0' }); + + expect(res.status).toBe(200); + expect(forwarded.logsv2.at(-1).size).toBe(25); + expect(forwarded.logsv2.at(-1).from).toBe(0); + }); + + test('rejects a numeric param that is not a number', async () => { + const res = await agent.get('/api/logsv2').query({ sql: 'select 1', size: 'lots' }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('/size'); + }); + + test('rejects a negative size', async () => { + const res = await agent.post('/api/logsv2').send({ sql: 'select 1', size: -5 }); + expect(res.status).toBe(400); + }); + + test('accepts a POST search body', async () => { + const res = await agent.post('/api/logsv2').send({ sql: 'select 1', size: 10 }); + expect(res.status).toBe(200); + }); +}); + +describe('PUT /api/app_builder/marketplace_releases/sync', () => { + const url = '/api/app_builder/marketplace_releases/sync'; + + test('forwards a complete upload', async () => { + const res = await agent + .put(url) + .field('path', 'views/pages/index.liquid') + .attach('marketplace_builder_file_body', Buffer.from('hello'), 'index.liquid'); + + expect(res.status).toBe(200); + expect(forwarded.sync.at(-1).path).toBe('views/pages/index.liquid'); + }); + + test('rejects a missing path', async () => { + const res = await agent + .put(url) + .attach('marketplace_builder_file_body', Buffer.from('hello'), 'index.liquid'); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("missing required property 'path'"); + }); + + // Previously this dereferenced undefined and surfaced as an unhandled 500. + test('rejects a missing file with 400 rather than crashing', async () => { + const res = await agent.put(url).field('path', 'views/pages/index.liquid'); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('marketplace_builder_file_body'); + }); +}); diff --git a/test/unit/validation.test.js b/test/unit/validation.test.js new file mode 100644 index 00000000..2606d8f9 --- /dev/null +++ b/test/unit/validation.test.js @@ -0,0 +1,94 @@ +import { describe, test, expect } from 'vitest'; +import { validate } from '#lib/validation/index.js'; + +const schema = { + type: 'object', + additionalProperties: false, + required: ['name'], + properties: { + name: { type: 'string', minLength: 1 }, + count: { type: 'integer', minimum: 0 }, + enabled: { type: 'boolean' } + } +}; + +describe('validate', () => { + test('accepts a conforming object', () => { + const result = validate(schema, { name: 'core', count: 2 }); + expect(result.valid).toBe(true); + expect(result.errors).toBeUndefined(); + }); + + test('reports a missing required property by name', () => { + const result = validate(schema, { count: 1 }); + expect(result.valid).toBe(false); + expect(result.message).toContain("missing required property 'name'"); + }); + + test('reports an unknown property by name', () => { + const result = validate(schema, { name: 'core', bogus: true }); + expect(result.valid).toBe(false); + expect(result.message).toContain("unknown property 'bogus'"); + }); + + test('rejects a wrong type and points at the property', () => { + const result = validate(schema, { name: 'core', count: 'many' }); + expect(result.valid).toBe(false); + expect(result.errors[0].path).toBe('/count'); + }); + + test('rejects a non-object payload', () => { + expect(validate(schema, 'core').valid).toBe(false); + expect(validate(schema, ['core']).valid).toBe(false); + expect(validate(schema, null).valid).toBe(false); + }); + + test('collects every error, not just the first', () => { + const result = validate(schema, { count: -1, enabled: 'yes' }); + expect(result.errors.length).toBeGreaterThanOrEqual(3); + }); + + test('strict mode leaves the caller data untouched', () => { + const data = { name: 'core', count: 2 }; + validate(schema, data); + expect(data).toEqual({ name: 'core', count: 2 }); + }); + + test('coercing mode converts query-string values in place', () => { + const data = { name: 'core', count: '42', enabled: 'true' }; + const result = validate(schema, data, { mode: 'coercing' }); + + expect(result.valid).toBe(true); + expect(data.count).toBe(42); + expect(data.enabled).toBe(true); + }); + + test('coercing mode still rejects values that cannot be coerced', () => { + const result = validate(schema, { name: 'core', count: 'many' }, { mode: 'coercing' }); + expect(result.valid).toBe(false); + }); + + test('flags an uncompilable schema as our defect rather than bad input', () => { + const result = validate({ type: 'not-a-real-type' }, {}); + expect(result.valid).toBe(false); + expect(result.schemaError).toBe(true); + }); + + test('caps the summary message but keeps every error', () => { + const wide = { + type: 'object', + properties: Object.fromEntries('abcdefgh'.split('').map(k => [k, { type: 'string' }])) + }; + const data = Object.fromEntries('abcdefgh'.split('').map(k => [k, 1])); + const result = validate(wide, data); + + expect(result.errors).toHaveLength(8); + expect(result.message).toContain('(+3 more)'); + }); + + test('compiles each schema once and reuses it', () => { + const first = validate(schema, { name: 'a' }); + const second = validate(schema, { name: 'b' }); + expect(first.valid && second.valid).toBe(true); + }); +}); From 8dd89bd7541805cb43e5654a5f20cad2ef036ccb Mon Sep 17 00:00:00 2001 From: Rafal Krysiak Date: Wed, 2 Sep 2026 00:25:27 +0200 Subject: [PATCH 2/6] fix(ci): Windows path resolution in tools config test --- mcp-min/__tests__/tools-config-validation.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mcp-min/__tests__/tools-config-validation.test.js b/mcp-min/__tests__/tools-config-validation.test.js index 735cee3f..6498f6df 100644 --- a/mcp-min/__tests__/tools-config-validation.test.js +++ b/mcp-min/__tests__/tools-config-validation.test.js @@ -9,8 +9,11 @@ import { spawnSync } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; +import { fileURLToPath } from 'url'; import { describe, test, expect, beforeAll, afterAll } from 'vitest'; +const here = path.dirname(fileURLToPath(import.meta.url)); + let tmpDir; const write = (name, contents) => { @@ -87,7 +90,6 @@ describe('tools config validation', () => { }); test('the config shipped in the package satisfies its own schema', () => { - const here = path.dirname(new URL(import.meta.url).pathname); const schema = JSON.parse(fs.readFileSync(path.join(here, '..', 'tools.config.schema.json'), 'utf8')); const config = JSON.parse(fs.readFileSync(path.join(here, '..', 'tools.config.json'), 'utf8')); From 2df5a222c837cabd4da9e657ae0b9a3290d22448 Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Wed, 2 Sep 2026 12:29:33 +0200 Subject: [PATCH 3/6] feat: Add tasks for documentation updates, security improvements, and bug fixes --- ...states-the-wrong-resolveAuth-precedence.md | 42 ++++++ ...-request-header-including-Authorization.md | 51 +++++++ ...ill-describes-Jest-the-repo-runs-vitest.md | 43 ++++++ ...se-the-gaps-in-the-new-validation-layer.md | 140 ++++++++++++++++++ ...urn-an-HTML-500-with-a-full-stack-trace.md | 50 +++++++ ...ot-found-check-on-4-of-5-dispatch-paths.md | 54 +++++++ ...cli-mcp-config-and-the-MCP-server-agree.md | 45 ++++++ ...y-interpolating-the-users-filter-string.md | 56 +++++++ ...-and-microseconds-so-the-range-is-wrong.md | 50 +++++++ ...sion-single-dir-workflow-test-times-out.md | 47 ++++++ 10 files changed, 578 insertions(+) create mode 100644 backlog/tasks/task-10 - mcp-min-auth.js-JSDoc-states-the-wrong-resolveAuth-precedence.md create mode 100644 backlog/tasks/task-11 - MCP-HTTP-request-logging-writes-every-request-header-including-Authorization.md create mode 100644 backlog/tasks/task-12 - CLAUDE.md-testing-section-still-describes-Jest-the-repo-runs-vitest.md create mode 100644 backlog/tasks/task-3 - add-ajv-input-validation-fix-GUI-logs-regression-and-close-the-gaps-in-the-new-validation-layer.md create mode 100644 backlog/tasks/task-4 - GUI-server-multer-failures-return-an-HTML-500-with-a-full-stack-trace.md create mode 100644 backlog/tasks/task-5 - MCP-prototype-chain-tool-names-bypass-the-not-found-check-on-4-of-5-dispatch-paths.md create mode 100644 backlog/tasks/task-6 - Extract-a-shared-tools.config-loader-so-pos-cli-mcp-config-and-the-MCP-server-agree.md create mode 100644 backlog/tasks/task-7 - gui-next-log-search-builds-SQL-by-interpolating-the-users-filter-string.md create mode 100644 backlog/tasks/task-8 - gui-next-log-date-filter-mixes-milliseconds-and-microseconds-so-the-range-is-wrong.md create mode 100644 backlog/tasks/task-9 - modules.test.js-publishVersion-single-dir-workflow-test-times-out.md diff --git a/backlog/tasks/task-10 - mcp-min-auth.js-JSDoc-states-the-wrong-resolveAuth-precedence.md b/backlog/tasks/task-10 - mcp-min-auth.js-JSDoc-states-the-wrong-resolveAuth-precedence.md new file mode 100644 index 00000000..61c2dff5 --- /dev/null +++ b/backlog/tasks/task-10 - mcp-min-auth.js-JSDoc-states-the-wrong-resolveAuth-precedence.md @@ -0,0 +1,42 @@ +--- +id: TASK-10 +title: mcp-min/auth.js JSDoc states the wrong resolveAuth precedence +status: To Do +assignee: [] +created_date: '2026-09-02 10:24' +labels: + - docs + - mcp +dependencies: [] +references: + - mcp-min/auth.js +priority: low +ordinal: 25000 +--- + +## Description + + +Pre-existing on `master`. This is the source of the same error that reached CLAUDE.md via the Ajv validation branch (fixed there under TASK-3), so fixing it here stops it being copied again. + +`mcp-min/auth.js:16-20` documents the fallback order as: + +``` + * 1. Explicit params (url + email + token) + * 2. MPKIT_* environment variables + * 3. Named .pos environment (params.env) + * 4. First environment in .pos config +``` + +The function body does something different, and its own inline comments say so: `params.env` is checked at `auth.js:39` **before** `MPKIT_*` at `auth.js:46`, labelled "Priority 2: Named .pos environment" and "Priority 3: MPKIT_*". The distinction matters — the code deliberately does not fall back to `MPKIT_*` when an env name was given ("the caller is being explicit"), which is the opposite of what the JSDoc implies. + +Correct order: explicit `url`+`email`+`token` params -> named `.pos` environment -> `MPKIT_*` env vars -> first `.pos` entry. + +Fix the JSDoc block to match, and while there, check for other copies of the wrong order in tool descriptions and README before they multiply. + + +## Acceptance Criteria + +- [ ] #1 The JSDoc on resolveAuth lists the precedence the function actually implements +- [ ] #2 The repo is searched for other copies of the wrong order (tool descriptions, README, docs) and any found are corrected + diff --git a/backlog/tasks/task-11 - MCP-HTTP-request-logging-writes-every-request-header-including-Authorization.md b/backlog/tasks/task-11 - MCP-HTTP-request-logging-writes-every-request-header-including-Authorization.md new file mode 100644 index 00000000..fdd82c7c --- /dev/null +++ b/backlog/tasks/task-11 - MCP-HTTP-request-logging-writes-every-request-header-including-Authorization.md @@ -0,0 +1,51 @@ +--- +id: TASK-11 +title: 'MCP HTTP request logging writes every request header, including Authorization' +status: To Do +assignee: [] +created_date: '2026-09-02 10:24' +labels: + - security + - mcp + - logs +dependencies: [] +references: + - mcp-min/http-server.js + - mcp-min/stdio-server.js + - mcp-min/auth.js + - mcp-min/portal/env-add.js + - mcp-min/log.js +priority: low +ordinal: 26000 +--- + +## Description + + +Pre-existing on `master`, found while reviewing the Ajv validation branch. + +`mcp-min/http-server.js:27-38` logs the full header object on every request: + +```js +log.debug('HTTP request', { + method: req.method, + url: req.originalUrl || req.url, + remoteAddress: req.ip || req.connection?.remoteAddress, + headers: req.headers +}); +``` + +Any `Authorization`, `Cookie` or `Mcp-Session-Id` header a client sends is written verbatim wherever `log.debug` goes. This is behind `DEBUG`, so it is not on by default, but `DEBUG=1` is the first thing anyone does when an MCP client misbehaves — which is exactly the situation where credentials are in play. `mcp-min/auth.js` already has a `maskToken` helper for precisely this reason, used when logging resolved auth. + +Also worth checking the `/call` and `tools/call` debug lines in the same file (`http-server.js:100`, `:198`) and their stdio equivalents: they log `params` in full, and `data-import`, `env-add`, `constants-set` and the explicit-credentials path all carry secrets in params. `mcp-min/portal/env-add.js:80` logs the whole `params` object explicitly. + +Redact a denylist of sensitive header names, and mask `token`/`password`/`value` in logged params (reusing `maskToken` where it fits) rather than dropping the logging — the request/param detail is genuinely useful for debugging. + + +## Acceptance Criteria + +- [ ] #1 Authorization, Cookie and equivalent sensitive headers are redacted in the MCP HTTP request log +- [ ] #2 Params logged by tool invocation paths mask token, password and credential values on both transports +- [ ] #3 Non-sensitive request and param detail is still logged, so DEBUG remains useful +- [ ] #4 A test asserts that a request carrying an Authorization header does not write it to the log + diff --git a/backlog/tasks/task-12 - CLAUDE.md-testing-section-still-describes-Jest-the-repo-runs-vitest.md b/backlog/tasks/task-12 - CLAUDE.md-testing-section-still-describes-Jest-the-repo-runs-vitest.md new file mode 100644 index 00000000..e4185cf9 --- /dev/null +++ b/backlog/tasks/task-12 - CLAUDE.md-testing-section-still-describes-Jest-the-repo-runs-vitest.md @@ -0,0 +1,43 @@ +--- +id: TASK-12 +title: CLAUDE.md testing section still describes Jest; the repo runs vitest +status: To Do +assignee: [] +created_date: '2026-09-02 10:24' +labels: + - docs + - tests +dependencies: [] +references: + - CLAUDE.md + - package.json + - vitest.config.js + - test/global-setup.js +priority: low +ordinal: 27000 +--- + +## Description + + +Pre-existing on `master`. Low stakes but actively misleading, since CLAUDE.md is what agents working in this repo read first. + +The "Testing" and "Testing Philosophy" sections describe a Jest setup that no longer exists: + +- `npm run test-watch` — the script is `test:watch` (`package.json`) +- "Tests run with `--runInBand` to prevent race conditions" — vitest is configured with `pool: 'forks'` and `fileParallelism: true` (`vitest.config.js`) +- "`npm test` — Run all tests with Jest" — `npm test` is `vitest run` +- "Fixtures are in `/test/fixtures/`" is still true, but the section omits that `pretest` installs the yeoman fixtures and that skipping it makes `test/unit/generators.test.js` fail with a confusing interactive prompt + +Also unmentioned: the `test:unit` / `test:integration` / `test:mcp-min` split, and the 60% coverage thresholds in `vitest.config.js` (which cover `lib/**` and `bin/**` but not `mcp-min/**`). + +The claim that tests require live credentials is only half true now — `test/unit` and `mcp-min/__tests__` run without them (`test/global-setup.js` skips cleanup when no real credentials are present); only `test/integration` needs `MPKIT_*`. Worth saying, because it tells a new contributor what they can actually run. + + +## Acceptance Criteria + +- [ ] #1 The CLAUDE.md testing sections name vitest and the actual script names +- [ ] #2 The test:unit / test:integration / test:mcp-min split is documented, including which of them need MPKIT_* credentials +- [ ] #3 The pretest yeoman-fixture install is documented, with the failure it causes when skipped +- [ ] #4 No reference to Jest or --runInBand remains in CLAUDE.md + diff --git a/backlog/tasks/task-3 - add-ajv-input-validation-fix-GUI-logs-regression-and-close-the-gaps-in-the-new-validation-layer.md b/backlog/tasks/task-3 - add-ajv-input-validation-fix-GUI-logs-regression-and-close-the-gaps-in-the-new-validation-layer.md new file mode 100644 index 00000000..b1a927b0 --- /dev/null +++ b/backlog/tasks/task-3 - add-ajv-input-validation-fix-GUI-logs-regression-and-close-the-gaps-in-the-new-validation-layer.md @@ -0,0 +1,140 @@ +--- +id: TASK-3 +title: >- + add-ajv-input-validation: fix GUI logs regression and close the gaps in the + new validation layer +status: To Do +assignee: [] +created_date: '2026-09-02 10:21' +labels: + - bug + - validation + - mcp + - gui + - security +dependencies: [] +references: + - lib/validation/index.js + - lib/validation/schemas/gui.js + - lib/server.js + - mcp-min/validate-params.js + - mcp-min/schemas/auth.js + - mcp-min/tools.js + - mcp-min/http-server.js + - mcp-min/stdio-server.js + - gui/next/src/lib/api/logs.js + - gui/next/src/routes/logs/+page.svelte + - CLAUDE.md +priority: high +ordinal: 18000 +--- + +## Description + + +Branch `add-ajv-input-validation` (commits fe9f93c, 8dd89bd) adds Ajv JSON Schema validation on the MCP transports (stdio + HTTP), on `mcp-min/tools.config.json`, and on the GUI server routes, as input-validation evidence for the SOC2 audit. It also relaxes `required: ['env']` on tools that authenticate, because `resolveAuth` supports three other call styles. + +The design is right and should not change: validating each MCP tool's own `inputSchema` means the schema advertised in `tools/list` and the schema enforced are the same object, so there is no second definition to drift. `allowUnionTypes` is genuinely required (verified: `logsSearchSchema.query` is the only schema that needs it). The new tests do bite — six independent mutations of the production code each broke tests as intended. + +Everything below lives in the code and tests this branch introduces. It is one task because shipping any of it would either break a user-visible feature or leave the branch's own stated contract unenforced. + +## 1. Blocker: the admin GUI Logs page returns 400 on every load + +`lib/validation/schemas/gui.js` types the log cursor as `{ type: 'integer', minimum: 0 }`. The client that actually calls the route (`gui/next/src/lib/api/logs.js:18-20`) sends the literal string `null` when there is no cursor: + +```js +const last = args.last ?? null; +return fetch(`${url}?lastId=` + last) // -> ?lastId=null +``` + +`routes/logs/+page.svelte:41` seeds that value from `$state.logs.logs?.at(-1)?.id ?? null`, so the first poll always sends it. Ajv does not coerce `"null"` to an integer. Verified against a running `lib/server.js`: + +``` +GET /api/logs?lastId=null -> 400 {"error":"Invalid request: /lastId must be integer"} +``` + +`logs.get` swallows the rejection into `{ error }`, so the page renders empty with no indication why. `?lastId=` (empty) is rejected the same way. + +The schema comment in `gui.js:56` shows the root cause: the constraint was derived from `lib/test-runner/logStream.js` rather than from the client that calls the route. The preferred fix is client-side (`args.last ?? 0` — `0` is already the "from the beginning" sentinel that `logStream.js` uses) plus a `gui/next` rebuild, since `gui/next/build` is committed and shipped in the npm package. Normalising empty/`null`/`undefined` away inside the route is acceptable only as a compatibility shim for already-installed GUI builds, and must be commented as such. + +The same field is typed `integer` here but `string` in `mcp-min/logs/fetch.js:15` and `mcp-min/logs/stream.js`. Pick one, or document why they differ. + +## 2. The "one schema, no drift" claim is broken by a third default + +Three different objects now describe "this tool declares no schema": `{ type: 'object', properties: {} }` in the stdio tools/list (`stdio-server.js:52`), `{ type: 'object', additionalProperties: true }` in the HTTP tools/list (`http-server.js:175`), and `{ type: 'object' }` in the enforcement path (`validate-params.js:6`). The first two predate the branch; the branch added the third, which is the one actually enforced. Hoist a single exported constant and use it at all three sites. `envs-list` (`mcp-min/tools.js:100`) is the only tool without `additionalProperties: false` — now that schemas are enforced, close it like the others. + +## 3. `validate()` mislabels two non-schema failures as schema-compile failures + +`result.schemaError` maps to HTTP 500 / JSON-RPC `-32603` at four call sites. Two failures are wrongly routed there: + +``` +validate({type:'object'}, {}, {mode:'nope'}) -> schemaError: "Schema failed to compile: Unknown validation mode: nope" +validate(true, {x:1}) -> schemaError: "Schema failed to compile: Invalid value used as weak map key" +``` + +The first is a caller programming error, not a schema defect. The second is a *legal* boolean JSON Schema that dies in the WeakMap cache at `lib/validation/index.js:45` because primitives cannot be WeakMap keys. Neither is reachable from today's schemas; both are traps for the next person. Also, `validate()` returns a `data` field that no caller reads — drop it or use it. + +## 4. The `env`-optional rule is unenforced for 15 of 22 tools + +CLAUDE.md states that any tool closing its schema with `additionalProperties: false` must also declare `url`/`email`/`token`, or the explicit-credentials path in `resolveAuth` becomes unreachable. Every tool satisfies this today (checked all 22 that call `resolveAuth`), but the test that guards it (`mcp-min/__tests__/validate-params.test.js:64-72`) uses a hand-written list of 7 tool names. Proof the guard does not hold: stripping `url`/`email`/`token` from `mcp-min/migrations/list.js` — a tool that calls `resolveAuth` — leaves all 338 mcp-min tests passing. Replace the list with an invariant derived from the tool registry so tools added later are covered automatically. + +Related: `mcp-min/schemas/auth.js` is applied to only 6 tools (`constants/*`, `data/import*`, `uploads/push`); the other 16 duplicate the three properties inline and, unlike the shared fragment, ship no `description` for them, so `tools/list` documents the same parameters inconsistently. Apply the fragment everywhere or delete it. + +## 5. `ajv-formats` is a dead runtime dependency + +`addFormats(ajv)` is called but no schema in the repo uses the `format` keyword (`mcp-min/check/index.js:17` is a property *named* `format`, not the keyword). Either drop the dependency or use it — `format: 'email'` / `'uri'` on the shared auth properties is the obvious missing half, given `lib/validators/email.js` and `lib/validators/url.js` already enforce that for CLI arguments. + +## 6. "The tools config fails closed" is only half true + +A config naming a tool that does not exist is silently ignored, which is exactly the outcome the new throw was added to prevent. Verified: `MCP_TOOLS_CONFIG = {"tools":{"deploy-strt":{"enabled":false}}}` starts the server with `deploy-start` still enabled and all 35 tools exposed. The schema cannot catch this because it cannot enumerate tool names; `applyConfig` (`mcp-min/tools.js:183`) iterates the registry and never inspects config keys. Add a pass over `Object.keys(config.tools)` that rejects (or at minimum loudly warns about) names with no matching tool. + +Separately, the throw itself reaches the user as a raw Node stack trace, doubled with the `log.error` line above it (`[ERROR] invalid tools config at …` followed by `Error: Invalid tools config at …` and 6 module-loader frames). Exit code is 1, which is correct, but the output contradicts CLAUDE.md's own error-handling guidance ("user-friendly error messages… log to logger for consistent formatting"). Catch at the `bin/pos-cli-mcp.js` boundary and report through `logger.Error`. + +## 7. Documented `resolveAuth` precedence is wrong + +The new CLAUDE.md section states the order as "explicit params, then `MPKIT_*` env vars, then the named `.pos` environment, then the first `.pos` entry". `mcp-min/auth.js:33-56` actually resolves: explicit params -> **named `.pos` environment** -> `MPKIT_*` -> first `.pos` entry. The same wrong order appears in the comment at `mcp-min/__tests__/validate-params.test.js:61`. CLAUDE.md is the file future agents follow, so this needs to be right. (The stale JSDoc in `auth.js` it was copied from is tracked separately.) + +Also worth a line in the affected tool descriptions: because `env` is now advertised as optional, an MCP client that omits it lands on the *first* `.pos` entry for mutating tools (`data-import`, `constants-set`, `uploads-push`). Runtime behaviour is unchanged — nothing enforced `required` before — but the advertised contract now invites the omission. + +## 8. Coverage gaps in the new tests + +- The `schemaError` -> 500 / `-32603` branch is untested at all four call sites. +- `test/unit/validation.test.js:89` ("compiles each schema once and reuses it") validates twice and asserts both are valid. It asserts nothing about caching. +- In `mcp-min/__tests__/tools-config-validation.test.js`, the `test.each` case for a bare `null` config passes for the wrong reason: with the throw removed it still fails, because `applyConfig` then dereferences `null`. Only the `false`/`0`/`""` cases exercise the fail-closed check the comment describes. +- No coverage for: stdio legacy direct invocation (`stdio-server.js:171-181`, including its non-JSON-RPC `send({ id, error })` shape), `/call-stream` legacy streaming validation (`http-server.js:229-234`), `logsv2` `query` as an object (the actual `gui/next` payload) or as a string (the union that forced `allowUnionTypes`), and POST-body coercion on `/api/logsv2` despite the comment at `lib/server.js:127-128` claiming it. +- `transport-validation.test.js:15` hardcodes port 5931 while sibling suites use 5920/5930/5940. `startHttp` resolves with the server, so `port: 0` plus `server.address().port` removes the collision class. +- Schema assertions are split between `.toBeUndefined()` and `.not.toContain('env')` for the same intent; the `required` relaxations on `data-validate` and `unit-tests-run` have no assertion at all. + +## 9. Style + +Two lines added to `mcp-min/http-server.js` exceed the 120-char `.editorconfig` limit: the `respond({ error: { code, … } })` call in JSON-RPC `tools/call` (128) and the `/call-stream` rejection (129). + +## Suite status at time of review + +On the lockfile-pinned `ajv@8.20.0` / `ajv-formats@3.0.1`: the five new/changed test files 80/80 pass; `mcp-min/__tests__` 338/338 pass; `test/unit` 1153 pass with 1 failure (`modules.test.js > publishVersion() … single-dir workflow` times out, reproduced identically on a clean `master` worktree — tracked separately). + +Note: `ajv`/`ajv-formats` were absent from the project's `node_modules` during review — `npm install` had never been run after they were added, so everything resolved from an unrelated `~/node_modules/ajv@8.18.0`. Run `npm ci` first. `npm run pretest` also rewrites `test/fixtures/yeoman{,/custom}/package-lock.json`; revert those before committing. + + +## Acceptance Criteria + +- [ ] #1 GET /api/logs accepts the cursor shape gui/next actually sends, and the admin Logs page populates on first load with no 400 response +- [ ] #2 The log cursor is typed consistently across lib/validation/schemas/gui.js, mcp-min/logs/fetch.js and mcp-min/logs/stream.js, or the divergence is documented in the schema +- [ ] #3 A single shared constant describes the no-schema default, used by both tools/list responses and the enforcement path in validate-params.js +- [ ] #4 The envs-list tool schema is closed with additionalProperties: false like every other tool +- [ ] #5 validate() distinguishes an unknown mode and a boolean schema from a schema compile failure, and neither is reported as 500 / -32603 +- [ ] #6 validate() no longer returns a field that no caller reads +- [ ] #7 Removing url, email or token from the schema of any tool that calls resolveAuth fails the test suite (today: stripping them from mcp-min/migrations/list.js leaves all 338 mcp-min tests passing) +- [ ] #8 mcp-min/schemas/auth.js is spread into every tool with a closed schema that authenticates, or removed in favour of the inline declarations +- [ ] #9 ajv-formats is used by at least one schema or removed from package.json dependencies +- [ ] #10 A tools.config.json entry naming a tool that does not exist is rejected or loudly warned about, so the fail-closed guarantee in CLAUDE.md holds +- [ ] #11 An invalid tools config reports through logger with a user-facing message and exits non-zero, with no raw Node stack trace +- [ ] #12 CLAUDE.md and the comment in validate-params.test.js state resolveAuth's actual precedence: explicit params, named .pos environment, MPKIT_* env vars, first .pos entry +- [ ] #13 The schemaError branch is covered by a test at each of the four sites that maps it to 500 / -32603 +- [ ] #14 No vacuous assertions remain in the new suites: the compile-once test either verifies that Ajv compiles a schema once, or is removed +- [ ] #15 The bare-null tools-config case fails because validation rejected the config, not because applyConfig dereferenced null +- [ ] #16 Validation is covered for the stdio legacy direct-invocation path, the /call-stream legacy streaming path, logsv2 query-as-object and query-as-string, and POST-body coercion on /api/logsv2 +- [ ] #17 transport-validation.test.js binds an ephemeral port rather than a hardcoded one +- [ ] #18 Schema assertions use one consistent style, and the required relaxations on data-validate and unit-tests-run are asserted +- [ ] #19 No line added by this branch exceeds the 120-character limit in .editorconfig + diff --git a/backlog/tasks/task-4 - GUI-server-multer-failures-return-an-HTML-500-with-a-full-stack-trace.md b/backlog/tasks/task-4 - GUI-server-multer-failures-return-an-HTML-500-with-a-full-stack-trace.md new file mode 100644 index 00000000..012d52a7 --- /dev/null +++ b/backlog/tasks/task-4 - GUI-server-multer-failures-return-an-HTML-500-with-a-full-stack-trace.md @@ -0,0 +1,50 @@ +--- +id: TASK-4 +title: 'GUI server: multer failures return an HTML 500 with a full stack trace' +status: To Do +assignee: [] +created_date: '2026-09-02 10:22' +labels: + - bug + - security + - gui +dependencies: [] +references: + - lib/server.js + - test/unit/server.validation.test.js +priority: high +ordinal: 19000 +--- + +## Description + + +Pre-existing on `master`, unrelated to the Ajv validation branch, but found while reviewing it. + +`lib/server.js` has no error-handling middleware. The only route with a body parser that can reject before the handler runs is the sync proxy at `PUT /api/app_builder/marketplace_releases/sync`, which is wrapped in `upload.fields([{ name: 'path' }, { name: 'marketplace_builder_file_body' }])`. A multipart request carrying any other file field never reaches the handler — multer calls `next(err)` and express's default error handler answers. Reproduced against a running server: + +``` +PUT /api/app_builder/marketplace_releases/sync (file part named "bogus") +-> 500 Content-Type: text/html +
MulterError: Unexpected field
+ at wrappedFileFilter (/home/…/node_modules/multer/index.js:41:19) + at Multipart.<anonymous> (/home/…/node_modules/multer/lib/make-middleware.js:284:7) + … 8 more frames
+``` + +Two problems. The response leaks absolute filesystem paths and a dependency stack trace, and pos-cli never sets `NODE_ENV=production`, so express always includes the stack. And the GUI server sets `Access-Control-Allow-Origin: *` (`lib/server.js:97-101`), so any page open in the developer's browser can trigger it and read the result. + +It also contradicts the intent already documented in the file. `sendError` (`lib/server.js:38-41`) exists specifically so that "no error text is ever handed back for a browser to parse as HTML" — that guarantee holds for gateway rejections but not for parser rejections. + +Fix by registering a JSON error-handling middleware after the routes (or by wrapping the `upload.fields` call so a `MulterError` becomes a 400 JSON body). A `MulterError` is caller error, so 400 is right; anything else should be a 502/500 with the message only, never the stack. + +Note: an in-flight task (TASK-3) fixes a *different* unhandled-500 in the same handler — the missing-file case. That fix is already on the `add-ajv-input-validation` branch and does not cover this path, so the two do not conflict, but whoever picks this up should rebase on that branch if it has landed. + + +## Acceptance Criteria + +- [ ] #1 A multipart request to the sync proxy carrying an unexpected file field receives a JSON response, not HTML +- [ ] #2 No response from lib/server.js contains a stack trace or an absolute filesystem path, regardless of NODE_ENV +- [ ] #3 A multer parser rejection is reported as 400 (caller error); other unhandled errors keep their existing status semantics +- [ ] #4 A test in test/unit covers the unexpected-file-field case and asserts the response content type is JSON + diff --git a/backlog/tasks/task-5 - MCP-prototype-chain-tool-names-bypass-the-not-found-check-on-4-of-5-dispatch-paths.md b/backlog/tasks/task-5 - MCP-prototype-chain-tool-names-bypass-the-not-found-check-on-4-of-5-dispatch-paths.md new file mode 100644 index 00000000..78a3915d --- /dev/null +++ b/backlog/tasks/task-5 - MCP-prototype-chain-tool-names-bypass-the-not-found-check-on-4-of-5-dispatch-paths.md @@ -0,0 +1,54 @@ +--- +id: TASK-5 +title: >- + MCP: prototype-chain tool names bypass the not-found check on 4 of 5 dispatch + paths +status: To Do +assignee: [] +created_date: '2026-09-02 10:22' +labels: + - bug + - security + - mcp +dependencies: [] +references: + - mcp-min/http-server.js + - mcp-min/stdio-server.js + - mcp-min/tools.js +priority: medium +ordinal: 20000 +--- + +## Description + + +Pre-existing on `master`, unrelated to the Ajv validation branch, but found while reviewing it. + +The tool registry (`mcp-min/tools.js`) is a plain object literal, and four of the five dispatch sites look a tool up with a bare `tools[name]` and test only truthiness. Names inherited from `Object.prototype` therefore resolve to something truthy that is not a tool. Reproduced against a running `startHttp`: + +``` +POST /call {"tool":"constructor"} -> 500 {"error":"TypeError: entry.handler is not a function"} +POST /call {"tool":"toString"} -> 500 {"error":"TypeError: entry.handler is not a function"} +``` + +`constructor`, `toString`, `valueOf`, `hasOwnProperty` and `__proto__` all behave this way. The affected sites: + +- `mcp-min/http-server.js:93` — `POST /call` +- `mcp-min/http-server.js:226` — `POST /call-stream`, legacy branch +- `mcp-min/stdio-server.js:82` — MCP `tools/call` +- `mcp-min/stdio-server.js:171` — legacy direct invocation + +The JSON-RPC branch in the same file already gets it right (`mcp-min/http-server.js:189`: `if (!entry || typeof entry.handler !== 'function')`) and correctly answers `-32601 Tool not found`, which is the behaviour the other four should match. + +There is a worse variant in the stdio protocol dispatcher. `mcp-min/stdio-server.js:164` does `const mcpHandler = mcpHandlers[method]`, and `mcpHandlers` is also a plain object literal. A request with `method: "toString"` resolves to `Object.prototype.toString`, which is invoked, returns a string, and **sends no response at all** — the client waits for its `id` until it times out. + +Impact is bounded (the MCP server is a local developer tool, and no state is reachable this way) but it is a trivially reachable internal error and a hang from untrusted client input, on the surface this codebase is currently hardening for audit. Fix with `Object.hasOwn` or the `typeof handler === 'function'` guard, applied consistently at all five sites, and make the stdio protocol dispatcher fall through to its existing `-32601 Method not found` path. + + +## Acceptance Criteria + +- [ ] #1 A tools/call or /call request naming constructor, toString, valueOf, hasOwnProperty or __proto__ is answered as tool-not-found (404 / -32601), not as a 500 or an internal TypeError +- [ ] #2 A stdio request whose method is an Object.prototype name is answered as method-not-found rather than receiving no response +- [ ] #3 All five dispatch sites use the same lookup guard +- [ ] #4 Tests cover at least one prototype-chain name on each transport, including the stdio protocol dispatcher hang + diff --git a/backlog/tasks/task-6 - Extract-a-shared-tools.config-loader-so-pos-cli-mcp-config-and-the-MCP-server-agree.md b/backlog/tasks/task-6 - Extract-a-shared-tools.config-loader-so-pos-cli-mcp-config-and-the-MCP-server-agree.md new file mode 100644 index 00000000..1f0621c1 --- /dev/null +++ b/backlog/tasks/task-6 - Extract-a-shared-tools.config-loader-so-pos-cli-mcp-config-and-the-MCP-server-agree.md @@ -0,0 +1,45 @@ +--- +id: TASK-6 +title: >- + Extract a shared tools.config loader so pos-cli mcp config and the MCP server + agree +status: To Do +assignee: [] +created_date: '2026-09-02 10:23' +labels: + - refactor + - mcp +dependencies: [] +references: + - mcp-min/tools.js + - bin/pos-cli-mcp-config.js + - mcp-min/tools.config.schema.json +priority: medium +ordinal: 21000 +--- + +## Description + + +Pre-existing duplication on `master`, surfaced while reviewing the Ajv validation branch. + +`mcp-min/tools.config.json` (overridable with `MCP_TOOLS_CONFIG`) is read in two independent places that each reimplement the same logic: + +- `mcp-min/tools.js` — reads and parses it, then `applyConfig` skips tools with `enabled === false` and overrides descriptions +- `bin/pos-cli-mcp-config.js:20` — reads and parses it again, then re-derives the enabled/disabled split with its own `cfg.enabled === false` loop and its own `config.tools || {}` fallback + +They have already diverged. The `add-ajv-input-validation` branch adds schema validation to `mcp-min/tools.js` only, so `pos-cli mcp config` will print a configuration that the MCP server then refuses to start with. Neither reader tells the user that a configured tool name does not match any registered tool. + +Extract one module (e.g. `mcp-min/config-loader.js`) that reads, validates against `mcp-min/tools.config.schema.json`, reports unknown tool names, and returns the resolved config plus its source. Both callers use it. This also removes the second copy of the enabled/disabled rule, so `pos-cli mcp config` can never disagree with what the server actually exposes. + +Depends on TASK-3 only in the sense that the validation and unknown-name checks it adds should live in the shared loader rather than be re-added here — coordinate ordering, or do this after TASK-3 lands and move its logic in. + + +## Acceptance Criteria + +- [ ] #1 One module owns reading, validating and resolving mcp-min/tools.config.json, and both mcp-min/tools.js and bin/pos-cli-mcp-config.js use it +- [ ] #2 pos-cli mcp config rejects a config that the MCP server would reject, with the same message +- [ ] #3 The enabled/disabled rule exists in exactly one place +- [ ] #4 pos-cli mcp config output still distinguishes the bundled default from an MCP_TOOLS_CONFIG override +- [ ] #5 Tests cover both callers against a valid config, an invalid config, and a missing file + diff --git a/backlog/tasks/task-7 - gui-next-log-search-builds-SQL-by-interpolating-the-users-filter-string.md b/backlog/tasks/task-7 - gui-next-log-search-builds-SQL-by-interpolating-the-users-filter-string.md new file mode 100644 index 00000000..54a967ab --- /dev/null +++ b/backlog/tasks/task-7 - gui-next-log-search-builds-SQL-by-interpolating-the-users-filter-string.md @@ -0,0 +1,56 @@ +--- +id: TASK-7 +title: gui/next log search builds SQL by interpolating the user's filter string +status: To Do +assignee: [] +created_date: '2026-09-02 10:23' +labels: + - security + - gui + - logs +dependencies: [] +references: + - gui/next/src/lib/api/logsv2.js + - gui/next/src/lib/api/network.js + - lib/server.js + - lib/validation/schemas/gui.js + - lib/proxy.js +priority: medium +ordinal: 22000 +--- + +## Description + + +Pre-existing on `master`, found while reviewing the Ajv validation branch. + +`gui/next/src/lib/api/logsv2.js:33` builds an OpenObserve SQL statement by string-interpolating the free-text search box straight into three `ILIKE` predicates: + +```js +if(filters.message){ + filters.sql = `SELECT * FROM logs where message ILIKE '%${filters.message}%' OR type ILIKE '%${filters.message}%' OR options_data_url ILIKE '%${filters.message}%'`; +} +``` + +Nothing escapes the value. A single quote in the search term terminates the literal, and everything after it is parsed as SQL. The statement is then POSTed to `/api/logsv2`, which `lib/server.js` forwards to `Gateway.logsv2` -> `this.client.searchSQL(params)`, so the crafted statement reaches OpenObserve with the developer's credentials attached. + +Two things make this worth fixing rather than shrugging at: + +- The GUI server sets `Access-Control-Allow-Origin: *` (`lib/server.js:97-101`), so `POST /api/logsv2` is reachable from any page open in the developer's browser while `pos-cli gui serve` is running, with an arbitrary `query.sql` — the client-side interpolation is the visible symptom, but the route accepts any statement regardless. +- The Ajv validation branch validates only the *top level* of the logsv2 payload (`query` must be an object or string). The real payload nests `sql`, `from`, `size`, `start_time`, `end_time` inside `query`, none of which are described, so the one place in this codebase with an actual injection surface is the one place validation does not reach. + +Decide the model first, because it changes the fix. If arbitrary SQL from the browser is intended (it is a local developer tool pointed at the developer's own instance, and the Liquid evaluator already carries a "code executed here runs on the connected instance" warning), then say so explicitly in a comment on the route and at least escape the quote in the filter so the UI's own search box cannot produce a malformed or surprising query. If it is not intended, the filter has to be bound rather than interpolated, and `logsSearchSchema` needs to describe the nested `query` object so `sql` is constrained. + +The same pattern appears in `gui/next/src/lib/api/network.js` for the network-log view. + +Note `gui/next/build` is committed and shipped in the npm package, so any client-side change needs `npm run build` in `gui/next` and the built assets committed. + + +## Acceptance Criteria + +- [ ] #1 A search term containing a single quote produces a well-formed query and cannot terminate the SQL string literal +- [ ] #2 The intended trust model for /api/logsv2 is recorded in a comment on the route in lib/server.js +- [ ] #3 If arbitrary SQL is not intended, logsSearchSchema describes the nested query object and constrains sql +- [ ] #4 The same treatment is applied to the network-log search in gui/next/src/lib/api/network.js +- [ ] #5 gui/next is rebuilt and the built assets committed if the client changed + diff --git a/backlog/tasks/task-8 - gui-next-log-date-filter-mixes-milliseconds-and-microseconds-so-the-range-is-wrong.md b/backlog/tasks/task-8 - gui-next-log-date-filter-mixes-milliseconds-and-microseconds-so-the-range-is-wrong.md new file mode 100644 index 00000000..e2e9b82a --- /dev/null +++ b/backlog/tasks/task-8 - gui-next-log-date-filter-mixes-milliseconds-and-microseconds-so-the-range-is-wrong.md @@ -0,0 +1,50 @@ +--- +id: TASK-8 +title: >- + gui/next log date filter mixes milliseconds and microseconds, so the range is + wrong +status: To Do +assignee: [] +created_date: '2026-09-02 10:23' +labels: + - bug + - gui + - logs +dependencies: [] +references: + - gui/next/src/lib/api/logsv2.js + - gui/next/src/lib/api/network.js +priority: low +ordinal: 23000 +--- + +## Description + + +Pre-existing on `master`, found while reviewing the Ajv validation branch. + +`gui/next/src/lib/api/logsv2.js:22-31` and the identical block in `gui/next/src/lib/api/network.js:23-30` compute the search window from the date picker: + +```js +let date = new Date(filters.start_time); +date.setHours(23, 59, 59); + +filters.end_time = Math.floor(date.getTime() * 1000); // microseconds +filters.start_time = Math.floor(date.getTime() - 24 * 60 * 60 * 1000 * 3); // milliseconds +``` + +`end_time` is converted to microseconds (which is what OpenObserve expects); `start_time` is left in milliseconds. It is therefore roughly a thousand times too small — around 1970 in microsecond terms — so the intended three-day window silently becomes "everything up to end_time". Picking a date does not narrow the result set the way the UI implies. + +Two smaller things in the same block, worth settling while it is open: `date` is mutated by `setHours` before `start_time` is derived from it, so "3 days back" is measured from end-of-day rather than start-of-day; and the fixed 3-day lookback is not exposed anywhere in the UI, so a user selecting one date gets four days of logs with no indication. + +`gui/next/build` is committed and shipped in the npm package, so this needs `npm run build` in `gui/next` and the built assets committed. + + +## Acceptance Criteria + +- [ ] #1 start_time and end_time are sent in the same unit that the logs backend expects +- [ ] #2 Selecting a date narrows the returned logs to the intended window, verified against a real instance or a recorded response +- [ ] #3 The window the date picker actually applies is either visible in the UI or documented in a comment +- [ ] #4 The identical block in gui/next/src/lib/api/network.js is fixed the same way +- [ ] #5 gui/next is rebuilt and the built assets committed + diff --git a/backlog/tasks/task-9 - modules.test.js-publishVersion-single-dir-workflow-test-times-out.md b/backlog/tasks/task-9 - modules.test.js-publishVersion-single-dir-workflow-test-times-out.md new file mode 100644 index 00000000..25cfbbc6 --- /dev/null +++ b/backlog/tasks/task-9 - modules.test.js-publishVersion-single-dir-workflow-test-times-out.md @@ -0,0 +1,47 @@ +--- +id: TASK-9 +title: 'modules.test.js: publishVersion single-dir workflow test times out' +status: To Do +assignee: [] +created_date: '2026-09-02 10:24' +labels: + - bug + - tests + - modules +dependencies: [] +references: + - test/unit/modules.test.js + - lib/modules.js + - .github/workflows/tests.yaml +priority: medium +ordinal: 24000 +--- + +## Description + + +Pre-existing failure on `master`. Confirmed not caused by the `add-ajv-input-validation` branch: reproduced identically in a clean `master` worktree with the same `node_modules`. + +``` +FAIL test/unit/modules.test.js > publishVersion() — pre-flight validation + > does not error about directory when modules/ does not exist (single-dir workflow) +Error: Test timed out in 10000ms. + at test/unit/modules.test.js:379 +``` + +Every other test in the file passes (27/28). The rest of `test/unit` is green (1153 passing) apart from this one, so it is the only thing standing between the repo and a clean `npm test`. + +The test writes a manifest with `machine_name` and `version` and no `modules/` directory at all, then expects `publishVersion` to get past the pre-flight directory check and reach archiving. The 10s timeout suggests the call is waiting on something rather than throwing — a prompt with no TTY, or a network call to the Partner Portal, are the obvious candidates given the test environment has no real credentials. + +Note this failure is invisible unless you run the suite: CI runs `npm test`, which runs the whole thing, so it should be failing there too — worth checking whether CI is currently red on `master` or whether something about the CI environment makes this test pass. Also note `npm run pretest` must have run (it installs the yeoman fixtures) or `test/unit/generators.test.js` fails separately for an unrelated reason, which is easy to confuse with this. + +Either fix the hang or, if the scenario is genuinely unsupported, change the test to assert what `publishVersion` should do instead. + + +## Acceptance Criteria + +- [ ] #1 npm run test:unit passes with no failures +- [ ] #2 The cause of the hang is identified and stated in the fix (prompt without TTY, unmocked network call, or similar) +- [ ] #3 If publishVersion cannot support the single-dir workflow, the test asserts the intended behaviour rather than being deleted or skipped +- [ ] #4 It is confirmed whether CI on master is currently failing on this test, and recorded in the task notes + From dfdd3aba4a7fbff95ca4b3e79c5ee876a73ca802 Mon Sep 17 00:00:00 2001 From: Rafal Krysiak Date: Wed, 2 Sep 2026 14:48:50 +0200 Subject: [PATCH 4/6] fix GUI logs regression and close gaps in the validation layer The log cursor schema was derived from logStream.js rather than from the client that calls the route, so gui/next's `?lastId=null` first poll got a 400 and the admin Logs page rendered empty. Fixed in the client, with a shim for GUI builds already installed. Also: the auth invariant is now derived from the tool registry rather than a hand-written list, which it had silently stopped guarding; the tools config rejects entries naming a tool that does not exist, and reports through the logger instead of a stack trace; and validate() no longer reports an unknown mode or a boolean schema as a compile failure. --- CLAUDE.md | 60 +++-- bin/pos-cli-mcp.js | 16 +- gui/next/src/lib/api/logs.js | 4 +- lib/proxy.js | 8 +- lib/server.js | 8 + lib/validation/index.js | 29 ++- lib/validation/schemas/gui.js | 13 +- mcp-min/__tests__/constants.test.js | 7 +- mcp-min/__tests__/data.import.test.js | 2 +- .../__tests__/tools-config-validation.test.js | 60 +++++ .../__tests__/transport-validation.test.js | 227 ++++++++++++++---- mcp-min/__tests__/uploads.push.test.js | 3 +- mcp-min/__tests__/validate-params.test.js | 104 ++++++-- mcp-min/data/clean-status.js | 5 +- mcp-min/data/clean.js | 5 +- mcp-min/data/export-status.js | 5 +- mcp-min/data/export.js | 5 +- mcp-min/deploy/start.js | 5 +- mcp-min/deploy/status.js | 5 +- mcp-min/deploy/wait.js | 5 +- mcp-min/graphql/exec.js | 5 +- mcp-min/http-server.js | 38 +-- mcp-min/liquid/exec.js | 5 +- mcp-min/logs/fetch.js | 7 +- mcp-min/logs/stream.js | 7 +- mcp-min/migrations/generate.js | 5 +- mcp-min/migrations/list.js | 5 +- mcp-min/migrations/run.js | 5 +- mcp-min/schemas/auth.js | 22 +- mcp-min/schemas/default.js | 18 ++ mcp-min/stdio-server.js | 22 +- mcp-min/sync/single-file.js | 5 +- mcp-min/tests/run-async-result.js | 5 +- mcp-min/tests/run-async.js | 5 +- mcp-min/tests/run.js | 5 +- mcp-min/tools-config-error.js | 16 ++ mcp-min/tools.js | 75 +++--- mcp-min/validate-params.js | 37 ++- test/unit/server.validation.test.js | 96 +++++++- test/unit/validation.test.js | 39 ++- 40 files changed, 776 insertions(+), 222 deletions(-) create mode 100644 mcp-min/schemas/default.js create mode 100644 mcp-min/tools-config-error.js diff --git a/CLAUDE.md b/CLAUDE.md index 6401fd45..4b190fce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -400,7 +400,8 @@ Can run with sync: `pos-cli gui serve staging --sync --open` ## Input Validation (Ajv) -**Key files**: `lib/validation/index.js`, `lib/validation/schemas/gui.js`, `mcp-min/validate-params.js`, `mcp-min/schemas/auth.js` +**Key files**: `lib/validation/index.js`, `lib/validation/schemas/gui.js`, +`mcp-min/validate-params.js`, `mcp-min/schemas/auth.js`, `mcp-min/schemas/default.js` Untrusted input is validated against JSON Schema with **Ajv** (draft-07) before it reaches any handler. Ajv is used rather than a code-first library because the MCP protocol requires @@ -421,10 +422,18 @@ const coerced = validate(schema, req.query, { mode: 'coercing' }); applies coercion and defaults **by mutating the object in place**. - **`result.schemaError`** — the schema itself would not compile. That is our defect, not the caller's, so report it as 500 / `-32603` — but still reject, because nothing was - actually checked. + actually checked. Reserved for genuine compile failures: an unknown `mode` throws a + `RangeError` (a caller bug), and boolean schemas — legal JSON Schema that cannot key the + compile cache — validate normally rather than surfacing as a phantom compile failure. + +`validate()` returns only `{ valid, errors, message, schemaError }`. It does not return the +data; in `coercing` mode the caller's own object is what gets mutated. Ajv runs in `strict: true` mode so a malformed schema fails loudly at compile time. -`allowUnionTypes` is the one rule relaxed, for fields that genuinely accept two types. +`allowUnionTypes` is the one rule relaxed, for fields that genuinely accept two types — +`logsSearchSchema.query`, which arrives as an object over POST and a string over GET, is +the only one. `ajv-formats` is loaded, and `format: 'uri'` / `format: 'email'` on the shared +auth properties are what use it. ### Enforcement points @@ -433,25 +442,48 @@ Ajv runs in `strict: true` mode so a malformed schema fails loudly at compile ti | `mcp-min/http-server.js` — `POST /call`, `/call-stream` | tool params vs `inputSchema` → 400 | | `mcp-min/http-server.js` — JSON-RPC `tools/call` | same → `-32602` | | `mcp-min/stdio-server.js` — `tools/call` + legacy direct invocation | same → `-32602` | -| `mcp-min/tools.js` | `tools.config.json` vs `tools.config.schema.json` | +| `mcp-min/tools.js` | `tools.config.json` vs `tools.config.schema.json`, plus tool names | | `lib/server.js` | GUI requests for graph / liquid / logs / logsv2 / sync | +All five sites route through `rejectionFor` in `mcp-min/validate-params.js`, so the mapping +from a rejection to a status code (400/500, `-32602`/`-32603`) is made in exactly one place. +A tool that declares no schema falls back to `OPEN_OBJECT_SCHEMA` in +`mcp-min/schemas/default.js` — the same constant both `tools/list` responses advertise, so +what is published and what is enforced cannot disagree. + Adding a tool to `mcp-min/` needs no wiring: both transports validate against whatever `inputSchema` the tool declares. A tool with no schema accepts any object. ### Two rules to preserve **`env` must stay optional on tools that authenticate.** `resolveAuth` (`mcp-min/auth.js`) -resolves credentials from explicit `url`+`email`+`token` params, then `MPKIT_*` env vars, -then the named `.pos` environment, then the first `.pos` entry. Marking `env` as `required` -would reject three of those four supported call styles. Tools closing their schema with -`additionalProperties: false` must also spread in `authProperties` from -`mcp-min/schemas/auth.js`, or the explicit-credentials path becomes unreachable. - -**The tools config fails closed.** An unparseable or missing config falls back to defaults, -but one that parses and fails schema validation throws at import. That file decides which -tools are exposed, so ignoring a broken one would silently re-enable every tool the author -meant to switch off. +resolves credentials in this order: + +1. explicit `url` + `email` + `token` params +2. the named `.pos` environment (`params.env`) +3. `MPKIT_URL` / `MPKIT_EMAIL` / `MPKIT_TOKEN` env vars +4. the first entry in `.pos` + +Marking `env` as `required` would reject three of those four supported call styles. Tools +closing their schema with `additionalProperties: false` must also spread in +`authProperties` from `mcp-min/schemas/auth.js`, or the explicit-credentials path becomes +unreachable. That rule is enforced by `mcp-min/__tests__/validate-params.test.js`, which +derives the tool list by scanning for `resolveAuth` rather than hard-coding names — a +hand-written list silently stops guarding tools added later. + +Because `env` is advertised as optional, an MCP client that omits it lands on step 4 — the +*first* `.pos` entry — including for mutating tools (`data-import`, `constants-set`, +`uploads-push`). Runtime behaviour is unchanged, since nothing enforced `required` before, +but the advertised contract now invites the omission. + +**The tools config fails closed.** A missing or unparseable config falls back to defaults; +one that parses but is invalid throws `ToolsConfigError`. That file decides which tools are +exposed, so ignoring a broken one would silently re-enable every tool the author meant to +switch off. Two checks, because the schema alone is not enough: it validates the shape, and +`loadToolsConfig` separately rejects entries naming a tool that does not exist — a typo +like `deploy-strt` matches nothing in `applyConfig` and would otherwise leave `deploy-start` +enabled while the config looks like it took effect. `bin/pos-cli-mcp.js` catches the error +and reports it through `logger`, so a config mistake never surfaces as a Node stack trace. ### Testing Philosophy Integration tests against real platformOS instances for reliability. Tests cover: diff --git a/bin/pos-cli-mcp.js b/bin/pos-cli-mcp.js index 2cba2566..457ffbe0 100755 --- a/bin/pos-cli-mcp.js +++ b/bin/pos-cli-mcp.js @@ -1,3 +1,17 @@ #!/usr/bin/env node -import '../mcp-min/index.js'; +import logger from '../lib/logger.js'; + +// The server is loaded dynamically so a configuration problem raised while its module +// graph evaluates can be reported as a message rather than escaping as a raw Node stack +// trace, per the error-handling guidance in CLAUDE.md. A static import would evaluate +// before any statement here could guard it. +try { + await import('../mcp-min/index.js'); +} catch (error) { + if (error?.name === 'ToolsConfigError') { + await logger.Error(error.message, { exit: false, hideTimestamp: true }); + process.exit(1); + } + throw error; +} diff --git a/gui/next/src/lib/api/logs.js b/gui/next/src/lib/api/logs.js index d80ff267..f36af560 100644 --- a/gui/next/src/lib/api/logs.js +++ b/gui/next/src/lib/api/logs.js @@ -15,7 +15,9 @@ const logs = { // the URL to use to connect to the API, in development or preview mode we are using the default pos-cli gui serve port const url = (typeof window !== 'undefined' && window.location.port !== '4173' && window.location.port !== '5173') ? `http://localhost:${parseInt(window.location.port)}/api/logs` : 'http://localhost:3333/api/logs'; - const last = args.last ?? null; + // 0 is the "from the beginning" sentinel the API expects. Sending `null` here put the + // literal string "null" in the query, which the server rejects as a non-integer. + const last = args.last ?? 0; return fetch(`${url}?lastId=` + last) .then(response => { diff --git a/lib/proxy.js b/lib/proxy.js index 9f3311c2..187ba584 100644 --- a/lib/proxy.js +++ b/lib/proxy.js @@ -97,7 +97,13 @@ class Gateway { // query string as well as from internal pollers, and an unencoded value could append // its own parameters to the request. const lastId = encodeURIComponent(json.lastId); - return apiRequest({ uri: `${this.api_url}/logs?last_id=${lastId}`, json: true, forever: true, headers: this.defaultHeaders, signal }); + return apiRequest({ + uri: `${this.api_url}/logs?last_id=${lastId}`, + json: true, + forever: true, + headers: this.defaultHeaders, + signal + }); } logsv2(params) { diff --git a/lib/server.js b/lib/server.js index e5c2c14b..e0f911c7 100644 --- a/lib/server.js +++ b/lib/server.js @@ -116,6 +116,14 @@ const start = (env, client) => { app.get('/api/logs', (req, res) => { const params = { ...req.query }; + + // Compatibility shim for GUI builds already installed in the wild. Versions of + // gui/next up to this change send `?lastId=null` on the first poll (it built the + // query with `args.last ?? null`), and Ajv will not coerce "null" to an integer. + // The source has been fixed to send 0, so this can go once shipping a rebuilt + // gui/next/build is acceptable — it exists only so an older GUI does not 400. + if (['null', 'undefined', ''].includes(params.lastId)) delete params.lastId; + if (rejectInvalid(res, logsRequestSchema, params, { mode: 'coercing' })) return; gateway diff --git a/lib/validation/index.js b/lib/validation/index.js index 1d683d47..188fbc2c 100644 --- a/lib/validation/index.js +++ b/lib/validation/index.js @@ -35,9 +35,12 @@ const instances = { // Schemas are stable module-level objects, so a WeakMap keyed on the schema keeps one // compiled validator per schema for the process lifetime without pinning it in memory. -const validatorFor = (schema, mode) => { - const instance = instances[mode]; - if (!instance) throw new Error(`Unknown validation mode: ${mode}`); +// +// `true` and `false` are legal JSON Schema (accept everything / reject everything) but +// cannot key a WeakMap, so they compile fresh each call. Nothing in this repo uses a +// boolean schema; the branch exists so one does not surface as a phantom compile failure. +const validatorFor = (schema, instance) => { + if (schema === null || typeof schema !== 'object') return instance.ajv.compile(schema); let validator = instance.compiled.get(schema); if (!validator) { @@ -73,33 +76,41 @@ const summarize = errors => { /** * Validate `data` against `schema`. * - * @param {object} schema - JSON Schema (draft-07) + * @param {object|boolean} schema - JSON Schema (draft-07) * @param {*} data - value to validate; with mode 'coercing' it is mutated in place * @param {object} [options] * @param {'strict'|'coercing'} [options.mode] - see the instances above - * @returns {{valid: boolean, data: *, errors?: Array<{path: string, message: string}>, + * @returns {{valid: boolean, errors?: Array<{path: string, message: string}>, * message?: string, schemaError?: boolean}} * `schemaError` marks a schema that failed to compile. That is a defect in our own * schema rather than bad input, so callers should report it as a server-side error — * but still reject the call, since an uncompilable schema means nothing was checked. + * @throws {RangeError} when `mode` names an instance that does not exist. That is a + * caller bug rather than a schema defect, so it must not be reported as `schemaError` + * and routed to a 500 that blames the schema. */ const validate = (schema, data, { mode = 'strict' } = {}) => { + const instance = instances[mode]; + if (!instance) { + throw new RangeError(`Unknown validation mode: ${mode} (expected ${Object.keys(instances).join(' or ')})`); + } + let validator; try { - validator = validatorFor(schema, mode); + validator = validatorFor(schema, instance); } catch (err) { const message = `Schema failed to compile: ${err.message}`; - return { valid: false, data, schemaError: true, message, errors: [{ path: '(schema)', message }] }; + return { valid: false, schemaError: true, message, errors: [{ path: '(schema)', message }] }; } - if (validator(data)) return { valid: true, data }; + if (validator(data)) return { valid: true }; const errors = (validator.errors || []).map(error => ({ path: error.instancePath || '(root)', message: describeError(error) })); - return { valid: false, data, errors, message: summarize(errors) }; + return { valid: false, errors, message: summarize(errors) }; }; export { validate, describeError }; diff --git a/lib/validation/schemas/gui.js b/lib/validation/schemas/gui.js index 88924207..0e59933f 100644 --- a/lib/validation/schemas/gui.js +++ b/lib/validation/schemas/gui.js @@ -55,13 +55,20 @@ const logsSearchSchema = { // `lastId` is a log row id (`row.id`, seeded from 0 — see lib/test-runner/logStream.js) // and Gateway.logs interpolates it straight into the request URL, so constraining it to an -// integer is what stops a caller from appending their own query parameters. It stays -// optional because the GUI's first poll sends no cursor at all. +// integer is what stops a caller from appending their own query parameters. +// +// `default: 0` matters: 0 is the "from the beginning" sentinel logStream.js already uses, +// so a first poll that sends no cursor resolves to it instead of interpolating the string +// "undefined" into the upstream URL, which is what happened before this schema existed. +// +// The same field is declared `integer` here but `string` on the MCP logs tools, which pass +// it through without coercion; both are normalised to integer so the cursor has one type +// across the codebase. const logsRequestSchema = { type: 'object', additionalProperties: true, properties: { - lastId: { type: 'integer', minimum: 0 } + lastId: { type: 'integer', minimum: 0, default: 0 } } }; diff --git a/mcp-min/__tests__/constants.test.js b/mcp-min/__tests__/constants.test.js index bb9d46df..4857e1d0 100644 --- a/mcp-min/__tests__/constants.test.js +++ b/mcp-min/__tests__/constants.test.js @@ -187,9 +187,7 @@ describe('constants-set', () => { }); test('has correct schema', () => { - expect(constantsSetTool.inputSchema.required).not.toContain('env'); - expect(constantsSetTool.inputSchema.required).toContain('name'); - expect(constantsSetTool.inputSchema.required).toContain('value'); + expect(constantsSetTool.inputSchema.required).toEqual(['name', 'value']); expect(constantsSetTool.inputSchema.properties).toHaveProperty('env'); }); }); @@ -264,8 +262,7 @@ describe('constants-unset', () => { }); test('has correct schema', () => { - expect(constantsUnsetTool.inputSchema.required).not.toContain('env'); - expect(constantsUnsetTool.inputSchema.required).toContain('name'); + expect(constantsUnsetTool.inputSchema.required).toEqual(['name']); expect(constantsUnsetTool.inputSchema.properties).toHaveProperty('env'); }); }); diff --git a/mcp-min/__tests__/data.import.test.js b/mcp-min/__tests__/data.import.test.js index 247a52a0..77404cb0 100644 --- a/mcp-min/__tests__/data.import.test.js +++ b/mcp-min/__tests__/data.import.test.js @@ -328,7 +328,7 @@ describe('data-import tool', () => { test('has correct description and inputSchema', () => { expect(dataImportStatusTool.description).toContain('status'); expect(dataImportStatusTool.inputSchema.properties).toHaveProperty('jobId'); - expect(dataImportStatusTool.inputSchema.required).toContain('jobId'); + expect(dataImportStatusTool.inputSchema.required).toEqual(['jobId']); }); test('returns validation error when jobId not provided', async () => { diff --git a/mcp-min/__tests__/tools-config-validation.test.js b/mcp-min/__tests__/tools-config-validation.test.js index 6498f6df..dea9ec45 100644 --- a/mcp-min/__tests__/tools-config-validation.test.js +++ b/mcp-min/__tests__/tools-config-validation.test.js @@ -13,6 +13,7 @@ import { fileURLToPath } from 'url'; import { describe, test, expect, beforeAll, afterAll } from 'vitest'; const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '..', '..'); let tmpDir; @@ -65,14 +66,52 @@ describe('tools config validation', () => { // These parse as valid JSON but are not configs. Testing the parsed value for // truthiness would skip validation and fall through to defaults with everything on. + // + // The assertion names the schema violation deliberately: a bare `null` also crashes + // applyConfig with a TypeError, so asserting only that startup failed would pass even + // with the fail-closed check removed. test.each(['null', 'false', '0', '""'])('refuses to start on a bare %s config', literal => { const configPath = write(`falsy-${literal.replace(/\W/g, '_')}.json`, literal); const { stdout, status } = loadWithConfig(configPath); expect(stdout).toContain('REJECTED:'); + expect(stdout).toContain('Invalid tools config'); + expect(stdout).toContain('must be object'); + expect(stdout).not.toContain('TypeError'); expect(status).not.toBe(0); }); + // The schema constrains each entry's shape but cannot enumerate tool names, so this + // is the case it structurally cannot catch: a typo leaves the real tool enabled while + // the config looks like it took effect. + test('refuses to start when a config names a tool that does not exist', () => { + const configPath = write('typo.json', JSON.stringify({ tools: { 'deploy-strt': { enabled: false } } })); + const { stdout, status } = loadWithConfig(configPath); + + expect(stdout).toContain('no such tool: deploy-strt'); + expect(status).not.toBe(0); + }); + + test('names every unknown tool, not just the first', () => { + const configPath = write('typos.json', JSON.stringify({ + tools: { 'deploy-strt': { enabled: false }, 'constants-lst': { enabled: false } } + })); + const { stdout } = loadWithConfig(configPath); + + expect(stdout).toContain('deploy-strt'); + expect(stdout).toContain('constants-lst'); + }); + + test('accepts a config naming only real tools', () => { + const configPath = write('real.json', JSON.stringify({ + tools: { 'deploy-start': { description: 'Ship it' } } + })); + const { stdout, status } = loadWithConfig(configPath); + + expect(status).toBe(0); + expect(stdout).toContain('deploy-start'); + }); + test('applies a valid config and disables the named tool', () => { const configPath = write('valid.json', JSON.stringify({ tools: { 'envs-list': { enabled: false } } })); const { stdout, status } = loadWithConfig(configPath); @@ -89,6 +128,27 @@ describe('tools config validation', () => { expect(stdout).toContain('envs-list'); }); + // CLAUDE.md requires user-facing errors to go through the logger. A module-loader + // stack trace reaching the terminal is the failure mode this guards. + test('reports an invalid config as a message, not a Node stack trace', () => { + const configPath = write('for-cli.json', JSON.stringify({ tools: { 'envs-list': { enabled: 'yes' } } })); + + const result = spawnSync(process.execPath, [path.join(here, '..', '..', 'bin', 'pos-cli-mcp.js')], { + cwd: repoRoot, + env: { ...process.env, MCP_TOOLS_CONFIG: configPath }, + encoding: 'utf8', + timeout: 30000 + }); + + const output = `${result.stdout || ''}${result.stderr || ''}`; + + expect(result.status).toBe(1); + expect(output).toContain('Invalid tools config'); + expect(output).toContain('must be boolean'); + expect(output).not.toMatch(/^\s+at .+:\d+:\d+\)?$/m); // no stack frames + expect(output).not.toContain('node:internal'); + }); + test('the config shipped in the package satisfies its own schema', () => { const schema = JSON.parse(fs.readFileSync(path.join(here, '..', 'tools.config.schema.json'), 'utf8')); const config = JSON.parse(fs.readFileSync(path.join(here, '..', 'tools.config.json'), 'utf8')); diff --git a/mcp-min/__tests__/transport-validation.test.js b/mcp-min/__tests__/transport-validation.test.js index 6668dbe6..f294e36b 100644 --- a/mcp-min/__tests__/transport-validation.test.js +++ b/mcp-min/__tests__/transport-validation.test.js @@ -5,15 +5,25 @@ import http from 'http'; import { spawn } from 'child_process'; import { resolve, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; import { describe, test, expect, beforeAll, afterAll } from 'vitest'; import startHttp from '../http-server.js'; +import tools from '../tools.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..', '..'); const stdioScript = resolve(__dirname, '..', 'stdio-server.js'); -const PORT = 5931; +// A schema Ajv cannot compile, registered as a tool so the schemaError branch — which +// maps to 500 / -32603 rather than the caller-blaming 400 / -32602 — is reachable. +const BROKEN_SCHEMA_TOOL = { + description: 'Test-only tool whose schema does not compile', + inputSchema: { type: 'not-a-real-type' }, + handler: async () => ({ ok: true, data: { reached: 'handler' } }) +}; + let server; +let PORT; const post = (path, body) => new Promise((resolvePromise, reject) => { @@ -22,7 +32,9 @@ const post = (path, body) => res => { let data = ''; res.on('data', chunk => (data += chunk)); - res.on('end', () => resolvePromise({ status: res.statusCode, body: data ? JSON.parse(data) : null })); + res.on('end', () => + resolvePromise({ status: res.statusCode, headers: res.headers, body: data ? JSON.parse(data) : null }) + ); } ); req.on('error', reject); @@ -33,13 +45,78 @@ const post = (path, body) => // No .pos fixture on purpose: every assertion here is about params being rejected before // a handler runs, and writing .pos into the working directory races other suites. beforeAll(async () => { - server = await startHttp({ port: PORT }); + // The registry is a live object shared with http-server, so adding an entry here is + // visible to the running server. Port 0 lets the OS assign a free one, which removes + // the collision this suite previously risked with a hardcoded 5931. + tools['broken-schema'] = BROKEN_SCHEMA_TOOL; + server = await startHttp({ port: 0 }); + PORT = server.address().port; }); afterAll(() => { + delete tools['broken-schema']; if (server) server.close(); }); + +// Drives one request through a freshly spawned stdio server and resolves with the +// response carrying the same id. `injectBrokenTool` starts the server from an inline +// module that adds the uncompilable-schema tool to the live registry first — the server +// reads tools[name] per request, so a mutation before startStdio() is visible. +const runStdio = (message, { injectBrokenTool = false } = {}) => new Promise((done, reject) => { + const inline = [ + `import tools from ${JSON.stringify(pathToFileURL(resolve(__dirname, '..', 'tools.js')).href)};`, + `import startStdio from ${JSON.stringify(pathToFileURL(stdioScript).href)};`, + `tools['broken-schema'] = { inputSchema: { type: 'not-a-real-type' }, handler: async () => ({ ok: true }) };`, + 'startStdio();' + ].join('\n'); + + const child = injectBrokenTool + ? spawn(process.execPath, ['--input-type=module', '-e', inline], { cwd: repoRoot, stdio: 'pipe' }) + : spawn(process.execPath, [stdioScript], { cwd: repoRoot, stdio: 'pipe' }); + + let buffered = ''; + let sent = false; + let settled = false; + + const finish = (fn, value) => { + if (settled) return; + settled = true; + child.kill(); + fn(value); + }; + + child.stdout.on('data', chunk => { + buffered += chunk.toString(); + + if (!sent && buffered.includes('protocolVersion')) { + sent = true; + child.stdin.write(`${JSON.stringify(message)}\n`); + return; + } + + for (const line of buffered.split('\n')) { + if (!line.trim()) continue; + let parsed; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (parsed.id === message.id) return finish(done, parsed); + } + }); + + child.on('error', err => finish(reject, err)); + + child.stdin.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 'init', + method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {} } + })}\n`); +}); + describe('HTTP POST /call', () => { test('rejects a missing required param with 400', async () => { const res = await post('/call', { tool: 'constants-set', params: { env: 'staging' } }); @@ -101,48 +178,114 @@ describe('HTTP JSON-RPC tools/call', () => { }); describe('stdio tools/call', () => { - test('rejects invalid params with -32602', () => new Promise((done, reject) => { - const child = spawn(process.execPath, [stdioScript], { stdio: ['pipe', 'pipe', 'pipe'] }); - let buffered = ''; - let initialized = false; - - const fail = err => { child.kill(); reject(err); }; - - child.stdout.on('data', chunk => { - buffered += chunk.toString(); - - if (!initialized && buffered.includes('protocolVersion')) { - initialized = true; - child.stdin.write(JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'constants-set', arguments: { env: 'staging' } } - }) + '\n'); - return; - } + test('rejects invalid params with -32602', async () => { + const response = await runStdio({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'constants-set', arguments: { env: 'staging' } } + }); - const line = buffered.split('\n').find(l => l.includes('"id":2')); - if (!line) return; + expect(response.error.code).toBe(-32602); + expect(response.error.message).toContain("missing required property 'name'"); + }, 20000); - try { - const response = JSON.parse(line); - expect(response.error.code).toBe(-32602); - expect(response.error.message).toContain("missing required property 'name'"); - child.kill(); - done(); - } catch (err) { - fail(err); - } + test('accepts valid params', async () => { + const response = await runStdio({ + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { name: 'envs-list', arguments: {} } }); - child.on('error', fail); + expect(response.error).toBeUndefined(); + expect(response.result.content).toBeDefined(); + }, 20000); +}); + +// A schema that will not compile is our defect, not the caller's, so it must not be +// reported as 400 / -32602. The mapping lives in one place (rejectionFor); these cover +// each transport path that consumes it. +describe('uncompilable schema is reported as a server error', () => { + test('HTTP POST /call answers 500, not 400', async () => { + const res = await post('/call', { tool: 'broken-schema', params: {} }); + + expect(res.status).toBe(500); + expect(res.body.error).toContain('Schema failed to compile'); + expect(JSON.stringify(res.body)).not.toContain('reached'); // handler never ran + }); - child.stdin.write(JSON.stringify({ + test('HTTP JSON-RPC tools/call answers -32603, not -32602', async () => { + const res = await post('/call-stream', { jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { protocolVersion: '2024-11-05', capabilities: {} } - }) + '\n'); - }), 15000); + id: 21, + method: 'tools/call', + params: { name: 'broken-schema', arguments: {} } + }); + + expect(res.body.error.code).toBe(-32603); + expect(res.body.error.message).toContain('Schema failed to compile'); + }); + + test('HTTP /call-stream legacy path answers 500 before opening the stream', async () => { + const res = await post('/call-stream', { tool: 'broken-schema', params: {} }); + + expect(res.status).toBe(500); + expect(res.body.error).toContain('Schema failed to compile'); + }); + + test('stdio tools/call answers -32603', async () => { + const response = await runStdio( + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'broken-schema', arguments: {} } }, + { injectBrokenTool: true } + ); + + expect(response.error.code).toBe(-32603); + expect(response.error.message).toContain('Schema failed to compile'); + }); +}); + +// The legacy path invokes a tool by naming it as the JSON-RPC method directly, and +// answers non-JSON-RPC callers with a bare { id, error } instead of an error object. +describe('stdio legacy direct invocation', () => { + test('rejects invalid params with -32602 for a JSON-RPC caller', async () => { + const response = await runStdio({ jsonrpc: '2.0', id: 3, method: 'constants-set', params: { env: 'staging' } }); + + expect(response.error.code).toBe(-32602); + expect(response.error.message).toContain("missing required property 'name'"); + }); + + test('rejects invalid params with a bare error string for a non-JSON-RPC caller', async () => { + const response = await runStdio({ id: 4, method: 'constants-set', params: { env: 'staging' } }); + + expect(typeof response.error).toBe('string'); + expect(response.error).toContain('Invalid params'); + expect(response.result).toBeUndefined(); + }); + + test('accepts valid params on the legacy path', async () => { + const response = await runStdio({ jsonrpc: '2.0', id: 5, method: 'envs-list', params: {} }); + + expect(response.error).toBeUndefined(); + expect(response.result.ok).toBe(true); + }); +}); + +// HTTP /call-stream legacy streaming path: validation runs before the SSE handshake, so +// a rejection is still an ordinary JSON response with a status code. +describe('HTTP /call-stream legacy streaming', () => { + test('rejects invalid params with 400 before the stream opens', async () => { + const res = await post('/call-stream', { tool: 'constants-set', params: { env: 'staging' } }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("missing required property 'name'"); + expect(res.headers['content-type']).toMatch(/application\/json/); + }); + + test('rejects an unknown param with 400', async () => { + const res = await post('/call-stream', { tool: 'constants-list', params: { env: 'staging', nope: 1 } }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("unknown property 'nope'"); + }); }); diff --git a/mcp-min/__tests__/uploads.push.test.js b/mcp-min/__tests__/uploads.push.test.js index cc8d8abe..5fa216cd 100644 --- a/mcp-min/__tests__/uploads.push.test.js +++ b/mcp-min/__tests__/uploads.push.test.js @@ -94,8 +94,7 @@ describe('uploads-push', () => { test('has correct description and schema with required fields', () => { expect(uploadsTool.description).toContain('ZIP'); // `env` is optional: resolveAuth also accepts url/email/token or MPKIT_* env vars. - expect(uploadsTool.inputSchema.required).not.toContain('env'); - expect(uploadsTool.inputSchema.required).toContain('filePath'); + expect(uploadsTool.inputSchema.required).toEqual(['filePath']); expect(uploadsTool.inputSchema.properties.env).toBeDefined(); expect(uploadsTool.inputSchema.properties.filePath).toBeDefined(); }); diff --git a/mcp-min/__tests__/validate-params.test.js b/mcp-min/__tests__/validate-params.test.js index e2135d06..31d6024b 100644 --- a/mcp-min/__tests__/validate-params.test.js +++ b/mcp-min/__tests__/validate-params.test.js @@ -1,7 +1,23 @@ import { describe, test, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; +import fg from 'fast-glob'; import tools from '../tools.js'; import { validateToolParams } from '../validate-params.js'; +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + +const authFileList = fg + .sync('mcp-min/**/*.js', { cwd: repoRoot, absolute: true, ignore: ['**/__tests__/**', '**/node_modules/**'] }) + .filter(file => !file.endsWith(`${path.sep}auth.js`)) + .filter(file => /\bresolveAuth\(/.test(fs.readFileSync(file, 'utf8'))); + +// Imported once at module scope so test.each can be built from real tool objects. +const authTools = await Promise.all( + authFileList.map(file => import(pathToFileURL(file).href).then(mod => mod.default)) +); + const check = (name, params) => validateToolParams(name, tools[name], params); describe('tool input schemas', () => { @@ -58,28 +74,63 @@ describe('validateToolParams', () => { }); }); -// resolveAuth resolves credentials from params, then MPKIT_* env vars, then .pos — so a -// schema that made `env` mandatory would reject two of its three supported call styles. +// resolveAuth (mcp-min/auth.js) resolves credentials in this order: explicit +// url+email+token params, then the named `.pos` environment, then MPKIT_* env vars, then +// the first `.pos` entry. A schema that made `env` mandatory would reject three of those +// four supported call styles. describe('authentication params stay accepted', () => { - const authenticating = [ - 'constants-list', - 'constants-set', - 'constants-unset', - 'data-import', - 'data-import-status', - 'uploads-push', - 'unit-tests-run' - ]; + // Derived from the source rather than hand-listed: a tool added later is covered the + // moment it calls resolveAuth. A hand-written list silently stopped guarding tools it + // did not happen to name. + const authenticatingFiles = authFileList; + + test('the scan finds the authenticating tools', () => { + expect(authenticatingFiles.length).toBeGreaterThanOrEqual(20); + }); + + test.each(authenticatingFiles.map((file, i) => [path.relative(repoRoot, file), i]))( + '%s declares url, email and token on a closed schema', + (_label, index) => { + const schema = authTools[index]?.inputSchema; + + // Only closed schemas can reject unknown properties, so only they can make the + // explicit-credentials path unreachable by omitting these three. + if (!schema || schema.additionalProperties !== false) return; + + for (const property of ['url', 'email', 'token']) { + expect(Object.keys(schema.properties || {}), `${_label} inputSchema.properties`) + .toContain(property); + } + } + ); const requiredExtras = { 'constants-set': { name: 'A', value: '1' }, 'constants-unset': { name: 'A' }, 'data-import-status': { jobId: '1' }, 'uploads-push': { filePath: 'uploads.zip' }, - 'unit-tests-run': { name: 'example_test' } + 'unit-tests-run': { name: 'example_test' }, + 'deploy-status': { id: '1' }, + 'deploy-wait': { id: '1' }, + 'data-export-status': { jobId: '1' }, + 'data-clean-status': { jobId: '1' }, + 'data-clean': { confirmation: 'yes' }, + 'tests-run-async-result': { id: '1' }, + 'migrations-generate': { name: 'add_thing' }, + 'liquid-exec': { template: '{{ 1 }}' }, + 'graphql-exec': { query: '{ a }' }, + 'sync-file': { filePath: 'app/views/a.liquid' } }; - test.each(authenticating)('%s accepts explicit url/email/token without env', name => { + // Registry entries whose schema is the one exported by an authenticating file. Matched + // on the inputSchema object rather than the tool object, because applyConfig copies the + // tool when a config overrides its description but keeps the same schema reference. + // A name-based heuristic would wrongly sweep in portal tools like env-add, whose + // `token` parameter is data it sends rather than credentials it authenticates with. + const authSchemas = new Set(authTools.map(tool => tool?.inputSchema).filter(Boolean)); + const registeredAuthTools = Object.keys(tools).filter(name => authSchemas.has(tools[name].inputSchema)); + + test.each(registeredAuthTools)('%s accepts explicit url/email/token without env', name => { const params = { url: 'https://example.com', email: 'a@b.c', token: 'tok', ...requiredExtras[name] }; const result = check(name, params); @@ -87,11 +138,34 @@ describe('authentication params stay accepted', () => { expect(result.valid).toBe(true); }); - test.each(authenticating)('%s accepts env alone', name => { + test.each(registeredAuthTools)('%s accepts env alone', name => { expect(check(name, { env: 'staging', ...requiredExtras[name] }).valid).toBe(true); }); - test.each(authenticating)('%s accepts no auth params (MPKIT_* / default .pos entry)', name => { + test.each(registeredAuthTools)('%s accepts no auth params (MPKIT_* / default .pos entry)', name => { expect(check(name, { ...requiredExtras[name] }).valid).toBe(true); }); }); + +// The branch relaxed `required` on these two so the schema matches what the handler +// actually needs; without an assertion the relaxation could be reverted unnoticed. +describe('required relaxations', () => { + test('data-validate requires nothing: validation runs locally and env is context only', () => { + expect(tools['data-validate'].inputSchema.required).toBeUndefined(); + }); + + test('unit-tests-run requires only name', () => { + expect(tools['unit-tests-run'].inputSchema.required).toEqual(['name']); + }); + + test.each([ + ['constants-list', undefined], + ['constants-set', ['name', 'value']], + ['constants-unset', ['name']], + ['data-import', undefined], + ['data-import-status', ['jobId']], + ['uploads-push', ['filePath']] + ])('%s no longer requires env', (name, expected) => { + expect(tools[name].inputSchema.required).toEqual(expected); + }); +}); diff --git a/mcp-min/data/clean-status.js b/mcp-min/data/clean-status.js index a7ae7b04..8a806e75 100644 --- a/mcp-min/data/clean-status.js +++ b/mcp-min/data/clean-status.js @@ -2,6 +2,7 @@ import log from '../log.js'; import { resolveAuth, maskToken } from '../auth.js'; import Gateway from '../../lib/proxy.js'; +import { authProperties } from '../schemas/auth.js'; const dataCleanStatusTool = { description: 'Check the status of a data clean job. Poll until status is "done" or "failed".', @@ -10,9 +11,7 @@ const dataCleanStatusTool = { additionalProperties: false, properties: { env: { type: 'string', description: 'Environment name from .pos config' }, - url: { type: 'string', description: 'Instance URL (alternative to env)' }, - email: { type: 'string', description: 'Account email (alternative to env)' }, - token: { type: 'string', description: 'API token (alternative to env)' }, + ...authProperties, jobId: { type: 'string', description: 'Clean job ID returned from data-clean' } }, required: ['jobId'] diff --git a/mcp-min/data/clean.js b/mcp-min/data/clean.js index 111ff100..5e607525 100644 --- a/mcp-min/data/clean.js +++ b/mcp-min/data/clean.js @@ -2,6 +2,7 @@ import log from '../log.js'; import { resolveAuth, maskToken } from '../auth.js'; import Gateway from '../../lib/proxy.js'; +import { authProperties } from '../schemas/auth.js'; const CONFIRMATION_TEXT = 'CLEAN DATA'; @@ -12,9 +13,7 @@ const dataCleanTool = { additionalProperties: false, properties: { env: { type: 'string', description: 'Environment name from .pos config' }, - url: { type: 'string', description: 'Instance URL (alternative to env)' }, - email: { type: 'string', description: 'Account email (alternative to env)' }, - token: { type: 'string', description: 'API token (alternative to env)' }, + ...authProperties, confirmation: { type: 'string', description: `Confirmation text - must be exactly "${CONFIRMATION_TEXT}" to proceed` diff --git a/mcp-min/data/export-status.js b/mcp-min/data/export-status.js index 812841ae..173250ef 100644 --- a/mcp-min/data/export-status.js +++ b/mcp-min/data/export-status.js @@ -2,6 +2,7 @@ import log from '../log.js'; import { resolveAuth, maskToken } from '../auth.js'; import Gateway from '../../lib/proxy.js'; +import { authProperties } from '../schemas/auth.js'; const dataExportStatusTool = { description: 'Check the status of a data export job. When done, returns data (JSON) or zip_file_url (ZIP).', @@ -10,9 +11,7 @@ const dataExportStatusTool = { additionalProperties: false, properties: { env: { type: 'string', description: 'Environment name from .pos config' }, - url: { type: 'string', description: 'Instance URL (alternative to env)' }, - email: { type: 'string', description: 'Account email (alternative to env)' }, - token: { type: 'string', description: 'API token (alternative to env)' }, + ...authProperties, jobId: { type: 'string', description: 'Export job ID returned from data-export' }, isZip: { type: 'boolean', description: 'Whether the export is a ZIP file', default: false } }, diff --git a/mcp-min/data/export.js b/mcp-min/data/export.js index e80fbbd9..d64350bb 100644 --- a/mcp-min/data/export.js +++ b/mcp-min/data/export.js @@ -2,6 +2,7 @@ import log from '../log.js'; import { resolveAuth, maskToken } from '../auth.js'; import Gateway from '../../lib/proxy.js'; +import { authProperties } from '../schemas/auth.js'; const dataExportTool = { description: 'Start data export from platformOS instance. Returns job ID for status polling. When complete, status will include data or zip_file_url.', @@ -10,9 +11,7 @@ const dataExportTool = { additionalProperties: false, properties: { env: { type: 'string', description: 'Environment name from .pos config' }, - url: { type: 'string', description: 'Instance URL (alternative to env)' }, - email: { type: 'string', description: 'Account email (alternative to env)' }, - token: { type: 'string', description: 'API token (alternative to env)' }, + ...authProperties, exportInternalIds: { type: 'boolean', description: 'Use internal object IDs instead of external_id in exported data', diff --git a/mcp-min/deploy/start.js b/mcp-min/deploy/start.js index 59bfdc45..cf70f8a1 100644 --- a/mcp-min/deploy/start.js +++ b/mcp-min/deploy/start.js @@ -11,6 +11,7 @@ import { deployAssets } from '../../lib/assets.js'; const archive = { makeArchive }; const assets = { deployAssets }; import dir from '../../lib/directories.js'; +import { authProperties } from '../schemas/auth.js'; const startDeployTool = { description: 'Deploy to platformOS instance. Creates archive from app/ and modules/ directories, uploads it, and deploys assets directly to S3.', @@ -19,9 +20,7 @@ const startDeployTool = { additionalProperties: false, properties: { env: { type: 'string', description: 'Environment name from .pos config' }, - url: { type: 'string', description: 'Instance URL (alternative to env)' }, - email: { type: 'string', description: 'Account email (alternative to env)' }, - token: { type: 'string', description: 'API token (alternative to env)' }, + ...authProperties, partial: { type: 'boolean', description: 'Partial deploy - does not remove files missing from build', default: false } } }, diff --git a/mcp-min/deploy/status.js b/mcp-min/deploy/status.js index a8ea4237..7c161744 100644 --- a/mcp-min/deploy/status.js +++ b/mcp-min/deploy/status.js @@ -1,6 +1,7 @@ // platformos.deploy.status - check deployment status via Gateway.getStatus import { resolveAuth } from '../auth.js'; import Gateway from '../../lib/proxy.js'; +import { authProperties } from '../schemas/auth.js'; const statusDeployTool = { description: 'Get current deployment status using Gateway.getStatus(id).', @@ -9,9 +10,7 @@ const statusDeployTool = { additionalProperties: false, properties: { env: { type: 'string' }, - url: { type: 'string' }, - email: { type: 'string' }, - token: { type: 'string' }, + ...authProperties, endpoint: { type: 'string' }, id: { type: 'string', description: 'Deployment ID returned from start' } }, diff --git a/mcp-min/deploy/wait.js b/mcp-min/deploy/wait.js index ae8e2a02..ab3f3c5d 100644 --- a/mcp-min/deploy/wait.js +++ b/mcp-min/deploy/wait.js @@ -1,6 +1,7 @@ // platformos.deploy.wait - poll deployment status until completion import { resolveAuth } from '../auth.js'; import Gateway from '../../lib/proxy.js'; +import { authProperties } from '../schemas/auth.js'; function delay(ms) { return new Promise(r => setTimeout(r, ms)); } @@ -11,9 +12,7 @@ const waitDeployTool = { additionalProperties: false, properties: { env: { type: 'string' }, - url: { type: 'string' }, - email: { type: 'string' }, - token: { type: 'string' }, + ...authProperties, endpoint: { type: 'string' }, id: { type: 'string', description: 'Deployment ID' }, intervalMs: { type: 'integer', minimum: 200, default: 1000 }, diff --git a/mcp-min/graphql/exec.js b/mcp-min/graphql/exec.js index 6bedfe3c..02911950 100644 --- a/mcp-min/graphql/exec.js +++ b/mcp-min/graphql/exec.js @@ -2,6 +2,7 @@ import { resolveAuth, maskToken } from '../auth.js'; import Gateway from '../../lib/proxy.js'; import { graphQLErrors, formatGraphQLErrors } from '../../lib/graph/response.js'; +import { authProperties } from '../schemas/auth.js'; const execGraphqlTool = { description: 'Execute a GraphQL query or mutation on a platformOS instance via /api/graph. Returns JSON data and errors from the instance. Auth resolved from: explicit params > MPKIT_* env vars > .pos config. Use variables to pass dynamic values safely instead of string interpolation.', @@ -10,9 +11,7 @@ const execGraphqlTool = { additionalProperties: false, properties: { env: { type: 'string', description: 'Environment name from .pos config (e.g., staging, production). Used to resolve auth when url/email/token are not provided.' }, - url: { type: 'string', description: 'Instance URL (e.g., https://my-app.staging.oregon.platform-os.com). Requires email and token.' }, - email: { type: 'string', description: 'Email for instance authentication. Required with url and token.' }, - token: { type: 'string', description: 'API token for instance authentication. Required with url and email.' }, + ...authProperties, endpoint: { type: 'string', description: 'Override the base URL for the GraphQL endpoint. Defaults to the resolved instance URL.' }, query: { type: 'string', description: 'GraphQL query or mutation string (e.g., "{ users { results { id email } } }").' }, variables: { type: 'object', additionalProperties: true, description: 'Variables to pass to the GraphQL query/mutation. Preferred over string interpolation for dynamic values.' } diff --git a/mcp-min/http-server.js b/mcp-min/http-server.js index 9cbc48ac..52306762 100644 --- a/mcp-min/http-server.js +++ b/mcp-min/http-server.js @@ -2,7 +2,8 @@ import express from 'express'; import bodyParser from 'body-parser'; import { randomUUID } from 'crypto'; import tools from './tools.js'; -import { validateToolParams } from './validate-params.js'; +import { rejectionFor } from './validate-params.js'; +import { OPEN_OBJECT_SCHEMA } from './schemas/default.js'; import { sseHandler, writeSSE } from './sse.js'; import { DEBUG } from './config.js'; import log from './log.js'; @@ -93,12 +94,11 @@ export default async function startHttp({ port = 5910 } = {}) { const entry = tools[tool]; if (!entry) return res.status(404).json({ error: `tool not found: ${tool}` }); - const validation = validateToolParams(tool, entry, params); - if (!validation.valid) { - // A schema that will not compile is our defect, not the caller's — but the call is - // still rejected, because an uncompilable schema means nothing was checked. - const status = validation.schemaError ? 500 : 400; - return res.status(status).json({ error: `invalid params: ${validation.message}`, details: validation.errors }); + const rejection = rejectionFor(tool, entry, params); + if (rejection) { + return res + .status(rejection.httpStatus) + .json({ error: `invalid params: ${rejection.message}`, details: rejection.errors }); } try { @@ -172,7 +172,7 @@ export default async function startHttp({ port = 5910 } = {}) { const list = Object.keys(tools).map((name) => ({ name, description: tools[name].description || '', - inputSchema: tools[name].inputSchema || { type: 'object', additionalProperties: true } + inputSchema: tools[name].inputSchema || OPEN_OBJECT_SCHEMA })); respond({ result: { tools: list } }); return; @@ -191,10 +191,15 @@ export default async function startHttp({ port = 5910 } = {}) { respond({ error: { code: -32601, message: `Tool not found: ${name}` } }); return; } - const validation = validateToolParams(name, entry, args); - if (!validation.valid) { - const code = validation.schemaError ? -32603 : -32602; - respond({ error: { code, message: `Invalid params: ${validation.message}`, data: { errors: validation.errors } } }); + const rejection = rejectionFor(name, entry, args); + if (rejection) { + respond({ + error: { + code: rejection.jsonRpcCode, + message: `Invalid params: ${rejection.message}`, + data: { errors: rejection.errors } + } + }); return; } const result = await entry.handler(args, { transport: 'jsonrpc', debug: DEBUG }); @@ -228,10 +233,11 @@ export default async function startHttp({ port = 5910 } = {}) { // Validate before the SSE handshake: once the stream is open the status code is // already sent, so a rejection could only be reported as an in-band error event. - const streamValidation = validateToolParams(tool, entry, params); - if (!streamValidation.valid) { - const status = streamValidation.schemaError ? 500 : 400; - return res.status(status).json({ error: `invalid params: ${streamValidation.message}`, details: streamValidation.errors }); + const streamRejection = rejectionFor(tool, entry, params); + if (streamRejection) { + return res + .status(streamRejection.httpStatus) + .json({ error: `invalid params: ${streamRejection.message}`, details: streamRejection.errors }); } // Prepare SSE response diff --git a/mcp-min/liquid/exec.js b/mcp-min/liquid/exec.js index 31ab50ac..7d0a441c 100644 --- a/mcp-min/liquid/exec.js +++ b/mcp-min/liquid/exec.js @@ -1,6 +1,7 @@ // platformos.liquid.exec tool - execute Liquid on remote instance via Gateway.liquid import { resolveAuth, maskToken } from '../auth.js'; import Gateway from '../../lib/proxy.js'; +import { authProperties } from '../schemas/auth.js'; const execLiquidTool = { description: 'Render a Liquid template on a platformOS instance server-side via /api/app_builder/liquid_exec. Returns the rendered output. Useful for testing Liquid code, running one-off queries via {% graphql %}, or inspecting instance state. Auth resolved from: explicit params > MPKIT_* env vars > .pos config.', @@ -9,9 +10,7 @@ const execLiquidTool = { additionalProperties: false, properties: { env: { type: 'string', description: 'Environment name from .pos config (e.g., staging, production). Used to resolve auth when url/email/token are not provided.' }, - url: { type: 'string', description: 'Instance URL (e.g., https://my-app.staging.oregon.platform-os.com). Requires email and token.' }, - email: { type: 'string', description: 'Email for instance authentication. Required with url and token.' }, - token: { type: 'string', description: 'API token for instance authentication. Required with url and email.' }, + ...authProperties, endpoint: { type: 'string', description: 'Override the base URL for the Liquid exec endpoint. Defaults to the resolved instance URL.' }, template: { type: 'string', description: 'Liquid template string to render server-side (e.g., "Hello {{ name }}", "{% graphql g = \'users/search\' %}").' }, locals: { type: 'object', additionalProperties: true, description: 'Variables available inside the template as top-level Liquid variables (e.g., { "name": "World" } makes {{ name }} render "World").' } diff --git a/mcp-min/logs/fetch.js b/mcp-min/logs/fetch.js index 301fdc4c..433f8446 100644 --- a/mcp-min/logs/fetch.js +++ b/mcp-min/logs/fetch.js @@ -1,6 +1,7 @@ // platformos.logs.fetch tool - batch fetch logs based on pos-cli fetch-logs import { resolveAuth, maskToken } from '../auth.js'; import Gateway from '../../lib/proxy.js'; +import { authProperties } from '../schemas/auth.js'; const fetchLogsTool = { description: 'Fetch recent logs in batches (NDJSON semantics, returns JSON array here). Mirrors pos-cli fetch-logs.', @@ -9,10 +10,8 @@ const fetchLogsTool = { additionalProperties: false, properties: { env: { type: 'string' }, - url: { type: 'string' }, - email: { type: 'string' }, - token: { type: 'string' }, - lastId: { type: 'string' }, + ...authProperties, + lastId: { type: 'integer', minimum: 0, description: 'Log row id to resume from (default 0)' }, endpoint: { type: 'string', description: 'Override API base url' }, limit: { type: 'integer', minimum: 1, maximum: 10000 } } diff --git a/mcp-min/logs/stream.js b/mcp-min/logs/stream.js index e16c9d0e..af2cebb2 100644 --- a/mcp-min/logs/stream.js +++ b/mcp-min/logs/stream.js @@ -2,6 +2,7 @@ import log from '../log.js'; import { resolveAuth } from '../auth.js'; import Gateway from '../../lib/proxy.js'; +import { authProperties } from '../schemas/auth.js'; function matchesFilter(row, filter) { if (!filter) return true; @@ -16,13 +17,11 @@ const streamTool = { additionalProperties: false, properties: { env: { type: 'string' }, - url: { type: 'string' }, - email: { type: 'string' }, - token: { type: 'string' }, + ...authProperties, endpoint: { type: 'string' }, interval: { type: 'integer', minimum: 250 }, filter: { type: 'string' }, - startLastId: { type: 'string', description: 'Starting last id (default 0)' }, + startLastId: { type: 'integer', minimum: 0, description: 'Starting log row id (default 0)' }, maxDuration: { type: 'integer', description: 'Optional max duration ms' } } }, diff --git a/mcp-min/migrations/generate.js b/mcp-min/migrations/generate.js index fe4b2a33..015ea464 100644 --- a/mcp-min/migrations/generate.js +++ b/mcp-min/migrations/generate.js @@ -4,6 +4,7 @@ import path from 'path'; import { resolveAuth } from '../auth.js'; import Gateway from '../../lib/proxy.js'; import dir from '../../lib/directories.js'; +import { authProperties } from '../schemas/auth.js'; function ensureMigrationsDir() { const appDirectory = fs.existsSync(dir.APP) ? dir.APP : dir.LEGACY_APP; @@ -18,9 +19,7 @@ const generateMigrationTool = { additionalProperties: false, properties: { env: { type: 'string' }, - url: { type: 'string' }, - email: { type: 'string' }, - token: { type: 'string' }, + ...authProperties, name: { type: 'string', description: 'Base name of the migration, without timestamp' }, skipWrite: { type: 'boolean', description: 'When true, do not create local file', default: false }, endpoint: { type: 'string', description: 'Override API base URL' } diff --git a/mcp-min/migrations/list.js b/mcp-min/migrations/list.js index 5c5c0df5..a505b86f 100644 --- a/mcp-min/migrations/list.js +++ b/mcp-min/migrations/list.js @@ -1,6 +1,7 @@ // platformos.migrations.list - list migrations and their statuses via Gateway import { resolveAuth } from '../auth.js'; import Gateway from '../../lib/proxy.js'; +import { authProperties } from '../schemas/auth.js'; const listMigrationsTool = { description: 'List migrations deployed to the server with their current status.', @@ -9,9 +10,7 @@ const listMigrationsTool = { additionalProperties: false, properties: { env: { type: 'string' }, - url: { type: 'string' }, - email: { type: 'string' }, - token: { type: 'string' }, + ...authProperties, endpoint: { type: 'string', description: 'Override API base URL' } } }, diff --git a/mcp-min/migrations/run.js b/mcp-min/migrations/run.js index 4273cae7..cf75cef2 100644 --- a/mcp-min/migrations/run.js +++ b/mcp-min/migrations/run.js @@ -1,6 +1,7 @@ // platformos.migrations.run - run a specific migration by name or timestamp via Gateway import { resolveAuth } from '../auth.js'; import Gateway from '../../lib/proxy.js'; +import { authProperties } from '../schemas/auth.js'; function buildFormData({ timestamp, name }) { if (!timestamp && !name) throw new Error('INVALID_INPUT: Provide timestamp or name'); @@ -15,9 +16,7 @@ const runMigrationTool = { additionalProperties: false, properties: { env: { type: 'string' }, - url: { type: 'string' }, - email: { type: 'string' }, - token: { type: 'string' }, + ...authProperties, timestamp: { type: 'string', description: 'Numeric timestamp' }, name: { type: 'string', description: 'Alias for timestamp; full migration name without .liquid' }, endpoint: { type: 'string', description: 'Override API base URL' } diff --git a/mcp-min/schemas/auth.js b/mcp-min/schemas/auth.js index 3502d11c..9ab21096 100644 --- a/mcp-min/schemas/auth.js +++ b/mcp-min/schemas/auth.js @@ -6,13 +6,29 @@ * close their schema with `additionalProperties: false` must therefore declare these * three, or validation would reject the very callers that path exists to serve. * + * Spread into every authenticating tool rather than restated inline, so `tools/list` + * documents the same three parameters identically everywhere. The invariant is enforced + * by mcp-min/__tests__/validate-params.test.js, which derives the tool list from the + * registry rather than a hand-written list. + * * `env` is deliberately not included: it is required on some tools and optional on * others, and its description varies, so it stays declared per tool. */ const authProperties = { - url: { type: 'string', description: 'Instance URL (with email and token, bypasses .pos)' }, - email: { type: 'string', description: 'Account email (with url and token, bypasses .pos)' }, - token: { type: 'string', description: 'API token (with url and email, bypasses .pos)' } + url: { + type: 'string', + format: 'uri', + description: 'Instance URL (with email and token, bypasses .pos)' + }, + email: { + type: 'string', + format: 'email', + description: 'Account email (with url and token, bypasses .pos)' + }, + token: { + type: 'string', + description: 'API token (with url and email, bypasses .pos)' + } }; export { authProperties }; diff --git a/mcp-min/schemas/default.js b/mcp-min/schemas/default.js new file mode 100644 index 00000000..4c4a3b2e --- /dev/null +++ b/mcp-min/schemas/default.js @@ -0,0 +1,18 @@ +/** + * The schema used for a tool that declares no `inputSchema` of its own. + * + * There is exactly one of these because the same object has to serve both roles: what + * the transports advertise in `tools/list` and what the enforcement path in + * validate-params.js actually checks against. Three separate literals previously + * disagreed about whether the default was open or closed, which is precisely the drift + * that validating each tool's own advertised schema is meant to rule out. + * + * It stays open (no `additionalProperties: false`): a tool that declared no schema never + * promised to reject unknown parameters, and enforcement must not invent a contract that + * `tools/list` did not publish. Every registered tool declares its own closed schema, so + * this is a fallback rather than a live policy. + */ +const OPEN_OBJECT_SCHEMA = { type: 'object', properties: {} }; + +export { OPEN_OBJECT_SCHEMA }; +export default OPEN_OBJECT_SCHEMA; diff --git a/mcp-min/stdio-server.js b/mcp-min/stdio-server.js index c97c58b8..b93d399d 100644 --- a/mcp-min/stdio-server.js +++ b/mcp-min/stdio-server.js @@ -2,7 +2,8 @@ import { createInterface } from 'readline'; import { fileURLToPath } from 'url'; import path from 'path'; import tools from './tools.js'; -import { validateToolParams } from './validate-params.js'; +import { rejectionFor } from './validate-params.js'; +import { OPEN_OBJECT_SCHEMA } from './schemas/default.js'; import { DEBUG } from './config.js'; import log from './log.js'; @@ -49,7 +50,7 @@ function getToolsList() { return Object.entries(tools).map(([name, tool]) => ({ name, description: tool.description || '', - inputSchema: tool.inputSchema || { type: 'object', properties: {} } + inputSchema: tool.inputSchema || OPEN_OBJECT_SCHEMA })); } @@ -85,12 +86,9 @@ const mcpHandlers = { return; } - const validation = validateToolParams(name, tool, args); - if (!validation.valid) { - // -32603 (internal error) when our own schema failed to compile; -32602 (invalid - // params) when the caller is genuinely at fault. - const code = validation.schemaError ? -32603 : -32602; - sendError(id, code, `Invalid params: ${validation.message}`, { errors: validation.errors }); + const rejection = rejectionFor(name, tool, args); + if (rejection) { + sendError(id, rejection.jsonRpcCode, `Invalid params: ${rejection.message}`, { errors: rejection.errors }); return; } @@ -170,11 +168,11 @@ export default function startStdio() { // Fallback: direct tool invocation (legacy/custom protocol) const tool = tools[method]; if (tool) { - const validation = validateToolParams(method, tool, params); - if (!validation.valid) { - const message = `Invalid params: ${validation.message}`; + const rejection = rejectionFor(method, tool, params); + if (rejection) { + const message = `Invalid params: ${rejection.message}`; if (jsonrpc === '2.0') { - sendError(id, validation.schemaError ? -32603 : -32602, message, { errors: validation.errors }); + sendError(id, rejection.jsonRpcCode, message, { errors: rejection.errors }); } else { send({ id, error: message }); } diff --git a/mcp-min/sync/single-file.js b/mcp-min/sync/single-file.js index a3c11de8..44f29a6b 100644 --- a/mcp-min/sync/single-file.js +++ b/mcp-min/sync/single-file.js @@ -14,6 +14,7 @@ import { fillInTemplateValues } from '../../lib/templates.js'; import dir from '../../lib/directories.js'; import log from '../log.js'; import { resolveAuth, maskToken, runWithAuth } from '../auth.js'; +import { authProperties } from '../schemas/auth.js'; // Alias for backwards compatibility const templates = { fillInTemplateValues }; @@ -97,9 +98,7 @@ const singleFileTool = { properties: { filePath: { type: 'string', description: 'Absolute or relative path to the file to sync. Must be inside app/, marketplace_builder/, or modules/.' }, env: { type: 'string', description: 'Environment name from .pos config (e.g., staging, production). Used to resolve auth when url/email/token are not provided.' }, - url: { type: 'string', description: 'Instance URL (e.g., https://my-app.staging.oregon.platform-os.com). Requires email and token.' }, - email: { type: 'string', description: 'Email for instance authentication. Required with url and token.' }, - token: { type: 'string', description: 'API token for instance authentication. Required with url and email.' }, + ...authProperties, op: { type: 'string', enum: ['upload', 'delete'], description: 'Operation: "upload" to push file, "delete" to remove from instance. Auto-detected from file existence if omitted.' }, dryRun: { type: 'boolean', description: 'Validate file path, auth, and sync rules without actually uploading. Default: false.' }, confirmDelete: { type: 'boolean', description: 'Safety flag -- must be true to execute delete operations. Default: false.' } diff --git a/mcp-min/tests/run-async-result.js b/mcp-min/tests/run-async-result.js index ab52a323..64e70c4e 100644 --- a/mcp-min/tests/run-async-result.js +++ b/mcp-min/tests/run-async-result.js @@ -1,6 +1,7 @@ // platformos.tests.run-async-result - check result of an async test run via /_tests/results/:id import log from '../log.js'; import { resolveAuth, maskToken } from '../auth.js'; +import { authProperties } from '../schemas/auth.js'; async function makeRequest(options) { const { uri, method = 'GET', headers = {} } = options; @@ -34,9 +35,7 @@ const testsRunAsyncResultTool = { additionalProperties: false, properties: { env: { type: 'string', description: 'Environment name from .pos config' }, - url: { type: 'string', description: 'Instance URL (alternative to env)' }, - email: { type: 'string', description: 'Account email (alternative to env)' }, - token: { type: 'string', description: 'API token (alternative to env)' }, + ...authProperties, id: { type: 'string', description: 'Test run ID returned by tests-run-async' } }, required: ['id'] diff --git a/mcp-min/tests/run-async.js b/mcp-min/tests/run-async.js index df8939a6..efb9c144 100644 --- a/mcp-min/tests/run-async.js +++ b/mcp-min/tests/run-async.js @@ -1,6 +1,7 @@ // platformos.tests.run-async - trigger tests via /_tests/run_async (returns immediately) import log from '../log.js'; import { resolveAuth, maskToken } from '../auth.js'; +import { authProperties } from '../schemas/auth.js'; async function makeRequest(options) { const { uri, method = 'GET', headers = {} } = options; @@ -16,9 +17,7 @@ const testsRunAsyncTool = { additionalProperties: false, properties: { env: { type: 'string', description: 'Environment name from .pos config' }, - url: { type: 'string', description: 'Instance URL (alternative to env)' }, - email: { type: 'string', description: 'Account email (alternative to env)' }, - token: { type: 'string', description: 'API token (alternative to env)' } + ...authProperties, } }, handler: async (params, ctx = {}) => { diff --git a/mcp-min/tests/run.js b/mcp-min/tests/run.js index f0a88a8c..a473b8fb 100644 --- a/mcp-min/tests/run.js +++ b/mcp-min/tests/run.js @@ -1,6 +1,7 @@ // platformos.tests.run - execute tests via /_tests/run?formatter=text import log from '../log.js'; import { resolveAuth, maskToken } from '../auth.js'; +import { authProperties } from '../schemas/auth.js'; // Helper to make HTTP requests (replaces request-promise) async function makeRequest(options) { @@ -251,9 +252,7 @@ const testsRunTool = { additionalProperties: false, properties: { env: { type: 'string', description: 'Environment name from .pos config' }, - url: { type: 'string', description: 'Instance URL (alternative to env)' }, - email: { type: 'string', description: 'Account email (alternative to env)' }, - token: { type: 'string', description: 'API token (alternative to env)' }, + ...authProperties, path: { type: 'string', description: 'Optional test path filter (e.g., "tests/users")' }, name: { type: 'string', description: 'Test name filter (e.g., "create_user_test"). Required to avoid running all tests which causes timeouts.' } }, diff --git a/mcp-min/tools-config-error.js b/mcp-min/tools-config-error.js new file mode 100644 index 00000000..e9cc0069 --- /dev/null +++ b/mcp-min/tools-config-error.js @@ -0,0 +1,16 @@ +/** + * Raised when mcp-min/tools.config.json (or MCP_TOOLS_CONFIG) is present but invalid. + * + * A distinct type so the CLI boundary in bin/pos-cli-mcp.js can tell a user-fixable + * configuration problem from an internal crash, and report it through the logger instead + * of letting a module-loader stack trace reach the terminal. + */ +class ToolsConfigError extends Error { + constructor(message) { + super(message); + this.name = 'ToolsConfigError'; + } +} + +export { ToolsConfigError }; +export default ToolsConfigError; diff --git a/mcp-min/tools.js b/mcp-min/tools.js index a6f11ffc..e3724bdd 100644 --- a/mcp-min/tools.js +++ b/mcp-min/tools.js @@ -5,40 +5,14 @@ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import files from '../lib/files.js'; import { validate } from '../lib/validation/index.js'; +import { ToolsConfigError } from './tools-config-error.js'; -// Load tool configuration (descriptions and enabled/disabled state) -// MCP_TOOLS_CONFIG env var overrides the bundled config +// MCP_TOOLS_CONFIG env var overrides the bundled config. The config itself is loaded +// further down, once the tool registry exists to validate its keys against. const __dirname = dirname(fileURLToPath(import.meta.url)); -let toolsConfig = { tools: {} }; const configPath = process.env.MCP_TOOLS_CONFIG || join(__dirname, 'tools.config.json'); const configSchema = JSON.parse(readFileSync(join(__dirname, 'tools.config.schema.json'), 'utf-8')); -// Tracked separately from the parsed value: `null`, `false`, `0` and `""` are all valid -// JSON, so testing the value for truthiness would skip validation on exactly the configs -// that need rejecting and fall through to defaults with every tool enabled. -let rawConfig; -let configParsed = false; -try { - rawConfig = JSON.parse(readFileSync(configPath, 'utf-8')); - configParsed = true; -} catch (err) { - // A missing file is the normal case for a custom path that was never created, and a - // malformed one cannot be interpreted at all. Either way there is nothing to apply. - log.debug('tools config not found or unparseable, using defaults', { path: configPath, error: String(err) }); -} - -if (configParsed) { - const result = validate(configSchema, rawConfig); - if (!result.valid) { - // Fail closed. This config decides which tools are exposed, so ignoring a broken one - // would silently re-enable every tool the author meant to switch off. - log.error(`invalid tools config at ${configPath}: ${result.message}`); - throw new Error(`Invalid tools config at ${configPath}: ${result.message}`); - } - toolsConfig = rawConfig; - log.debug('tools config loaded', { path: configPath, tools: Object.keys(toolsConfig.tools || {}).length }); -} - // Keep tools.js lean by extracting complex tools into modules import singleFileTool from './sync/single-file.js'; import fetchLogsTool from './logs/fetch.js'; @@ -99,6 +73,7 @@ const tools = { description: 'List configured environments from .pos (name and url)', inputSchema: { type: 'object', + additionalProperties: false, properties: {} }, handler: async (_params, ctx) => { @@ -179,6 +154,46 @@ const tools = { 'env-add': envAddTool }; +/** + * Read and validate the tools config. + * + * Fails closed on anything it can detect: this file decides which tools are exposed, so + * a config that is present but wrong must stop the server rather than be ignored, which + * would silently re-enable every tool the author meant to switch off. A missing or + * unparseable file is the one benign case — there is nothing to apply, so defaults win. + * + * @param {object} registry - the tool registry, used to reject names that match no tool + * @throws {ToolsConfigError} when the file is present but does not describe a valid config + */ +function loadToolsConfig(registry) { + let raw; + try { + raw = JSON.parse(readFileSync(configPath, 'utf-8')); + } catch (err) { + log.debug('tools config not found or unparseable, using defaults', { path: configPath, error: String(err) }); + return { tools: {} }; + } + + const result = validate(configSchema, raw); + if (!result.valid) { + throw new ToolsConfigError(`Invalid tools config at ${configPath}: ${result.message}`); + } + + // The schema constrains the shape of each entry but cannot enumerate tool names, so a + // typo like "deploy-strt" would otherwise be accepted, match nothing in applyConfig, + // and leave "deploy-start" enabled — the exact fail-open the schema check exists to + // prevent, and the harder one to notice because the config looks like it took effect. + const unknown = Object.keys(raw.tools || {}).filter(name => !(name in registry)); + if (unknown.length > 0) { + throw new ToolsConfigError( + `Invalid tools config at ${configPath}: no such tool: ${unknown.join(', ')}` + ); + } + + log.debug('tools config loaded', { path: configPath, tools: Object.keys(raw.tools || {}).length }); + return raw; +} + // Apply configuration: override descriptions and filter disabled tools function applyConfig(allTools, config) { const result = {}; @@ -201,6 +216,6 @@ function applyConfig(allTools, config) { return result; } -const configuredTools = applyConfig(tools, toolsConfig); +const configuredTools = applyConfig(tools, loadToolsConfig(tools)); export default configuredTools; diff --git a/mcp-min/validate-params.js b/mcp-min/validate-params.js index 61ecf368..50aa3777 100644 --- a/mcp-min/validate-params.js +++ b/mcp-min/validate-params.js @@ -1,10 +1,7 @@ import { validate } from '../lib/validation/index.js'; +import { OPEN_OBJECT_SCHEMA } from './schemas/default.js'; import log from './log.js'; -// Tools that declare no schema accept any object — which is exactly what the transports -// already advertise on their behalf in tools/list. -const OPEN_SCHEMA = { type: 'object' }; - /** * Validate tool params against the tool's advertised `inputSchema`. * @@ -17,7 +14,7 @@ const OPEN_SCHEMA = { type: 'object' }; * @returns {{valid: boolean, errors?: Array, message?: string, schemaError?: boolean}} */ const validateToolParams = (name, tool, params) => { - const result = validate(tool.inputSchema || OPEN_SCHEMA, params ?? {}); + const result = validate(tool.inputSchema || OPEN_OBJECT_SCHEMA, params ?? {}); if (!result.valid) { log.debug('tool params rejected', { @@ -30,5 +27,33 @@ const validateToolParams = (name, tool, params) => { return result; }; -export { validateToolParams }; +/** + * Validate tool params and, if they are rejected, describe the rejection in the terms + * both transports need. + * + * The status mapping lives here rather than at each call site so there is exactly one + * decision about how a rejection is reported. A schema that will not compile is our own + * defect, so it is a server error (500 / -32603) rather than the caller's fault + * (400 / -32602) — but either way the call is rejected, because a schema that did not + * compile checked nothing. + * + * @param {string} name - tool name, for logging + * @param {object} tool - tool entry from tools.js + * @param {*} params - untrusted params from the client + * @returns {null|{httpStatus: number, jsonRpcCode: number, message: string, errors: Array}} + * null when the params are valid. + */ +const rejectionFor = (name, tool, params) => { + const result = validateToolParams(name, tool, params); + if (result.valid) return null; + + return { + httpStatus: result.schemaError ? 500 : 400, + jsonRpcCode: result.schemaError ? -32603 : -32602, + message: result.message, + errors: result.errors + }; +}; + +export { validateToolParams, rejectionFor }; export default validateToolParams; diff --git a/test/unit/server.validation.test.js b/test/unit/server.validation.test.js index 19510145..1df17c0c 100644 --- a/test/unit/server.validation.test.js +++ b/test/unit/server.validation.test.js @@ -129,11 +129,23 @@ describe('GET /api/logs', () => { expect(forwarded.logs.at(-1).lastId).toBe(42); }); - test('allows a first poll with no cursor', async () => { + // 0 is the "from the beginning" sentinel. Before the schema supplied it as a default, + // an absent cursor put the string "undefined" into the upstream URL. + test('defaults an absent cursor to 0', async () => { const res = await agent.get('/api/logs'); expect(res.status).toBe(200); - expect(forwarded.logs.at(-1).lastId).toBeUndefined(); + expect(forwarded.logs.at(-1).lastId).toBe(0); + }); + + // gui/next up to this change built the query with `args.last ?? null`, so every first + // poll sent the literal string "null". Ajv will not coerce that to an integer, which + // made the admin Logs page 400 on load. Installed GUI builds still send it. + test.each(['null', '', 'undefined'])('accepts the cursor an older gui/next sends: %p', async value => { + const res = await agent.get('/api/logs').query({ lastId: value }); + + expect(res.status).toBe(200); + expect(forwarded.logs.at(-1).lastId).toBe(0); }); // Gateway.logs interpolates the cursor into the request URL, so a value carrying its @@ -183,6 +195,58 @@ describe('/api/logsv2', () => { }); }); +describe('/api/logsv2 payload shapes', () => { + // gui/next posts the search as an object; the GET route can only ever deliver a string. + // Both branches are live in Gateway.logsv2, and the union is why the Ajv instance runs + // with allowUnionTypes. + test('accepts query as an object on POST', async () => { + const res = await agent.post('/api/logsv2').send({ query: { sql: 'select 1', from: 0, size: 10 } }); + + expect(res.status).toBe(200); + expect(forwarded.logsv2.at(-1).query).toEqual({ sql: 'select 1', from: 0, size: 10 }); + }); + + test('accepts query as a string on GET', async () => { + const res = await agent.get('/api/logsv2').query({ query: 'select 1' }); + + expect(res.status).toBe(200); + expect(forwarded.logsv2.at(-1).query).toBe('select 1'); + }); + + // Coercing mode narrows a scalar into the string half of the union rather than + // rejecting it; the route is a pass-through, so normalising is the useful behaviour. + test('coerces a scalar query into the string branch of the union', async () => { + const res = await agent.post('/api/logsv2').send({ query: 42 }); + + expect(res.status).toBe(200); + expect(forwarded.logsv2.at(-1).query).toBe('42'); + }); + + test('rejects a query that matches neither branch of the union', async () => { + const res = await agent.post('/api/logsv2').send({ query: ['select 1'] }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('/query'); + }); + + // The route runs POST bodies through coercing mode too, so a client that spells a + // numeric field as a string is normalised rather than rejected. + test('coerces numeric fields in a POST body', async () => { + const res = await agent.post('/api/logsv2').send({ sql: 'select 1', size: '25', from: '5' }); + + expect(res.status).toBe(200); + expect(forwarded.logsv2.at(-1).size).toBe(25); + expect(forwarded.logsv2.at(-1).from).toBe(5); + }); + + test('accepts a searchAround payload', async () => { + const res = await agent.post('/api/logsv2').send({ key: 'abc', stream_name: 'logs', size: 10 }); + + expect(res.status).toBe(200); + expect(forwarded.logsv2.at(-1).key).toBe('abc'); + }); +}); + describe('PUT /api/app_builder/marketplace_releases/sync', () => { const url = '/api/app_builder/marketplace_releases/sync'; @@ -213,3 +277,31 @@ describe('PUT /api/app_builder/marketplace_releases/sync', () => { expect(res.body.error).toContain('marketplace_builder_file_body'); }); }); + +// A schema that will not compile is our defect, not the caller's, so the GUI routes must +// answer 500 rather than 400 — while still refusing to forward the request upstream. +describe('uncompilable schema on a GUI route', () => { + test('answers 500 and does not call the gateway', async () => { + const express = (await import('express')).default; + const bodyParser = (await import('body-parser')).default; + const { validate } = await import('#lib/validation/index.js'); + + let forwardedCalls = 0; + const app = express(); + app.use(bodyParser.json()); + app.post('/broken', (req, res) => { + const result = validate({ type: 'not-a-real-type' }, req.body); + if (!result.valid) { + return res.status(result.schemaError ? 500 : 400).json({ error: `Invalid request: ${result.message}` }); + } + forwardedCalls += 1; + return res.json({ ok: true }); + }); + + const res = await request(app).post('/broken').send({ anything: true }); + + expect(res.status).toBe(500); + expect(res.body.error).toContain('Schema failed to compile'); + expect(forwardedCalls).toBe(0); + }); +}); diff --git a/test/unit/validation.test.js b/test/unit/validation.test.js index 2606d8f9..a1152439 100644 --- a/test/unit/validation.test.js +++ b/test/unit/validation.test.js @@ -74,6 +74,26 @@ describe('validate', () => { expect(result.schemaError).toBe(true); }); + // schemaError routes to 500 / -32603, which blames our schema. An unknown mode is a + // caller bug and a boolean schema is perfectly legal, so neither belongs on that path. + test('throws on an unknown mode rather than blaming the schema', () => { + expect(() => validate(schema, { name: 'core' }, { mode: 'nope' })).toThrow(RangeError); + expect(() => validate(schema, { name: 'core' }, { mode: 'nope' })).toThrow(/Unknown validation mode/); + }); + + test('accepts boolean schemas, which cannot key the compile cache', () => { + expect(validate(true, { anything: 1 })).toEqual({ valid: true }); + + const rejected = validate(false, { anything: 1 }); + expect(rejected.valid).toBe(false); + expect(rejected.schemaError).toBeUndefined(); + }); + + test('does not return a data field', () => { + expect(validate(schema, { name: 'core' })).not.toHaveProperty('data'); + expect(validate(schema, {})).not.toHaveProperty('data'); + }); + test('caps the summary message but keeps every error', () => { const wide = { type: 'object', @@ -87,8 +107,21 @@ describe('validate', () => { }); test('compiles each schema once and reuses it', () => { - const first = validate(schema, { name: 'a' }); - const second = validate(schema, { name: 'b' }); - expect(first.valid && second.valid).toBe(true); + // Counting compiles directly: a schema object validated twice must reach Ajv once. + const counted = { type: 'object', properties: { name: { type: 'string' } } }; + const seen = []; + const proxied = new Proxy(counted, { + get(target, prop, receiver) { + seen.push(prop); + return Reflect.get(target, prop, receiver); + } + }); + + validate(proxied, { name: 'a' }); + const afterFirst = seen.length; + validate(proxied, { name: 'b' }); + + expect(afterFirst).toBeGreaterThan(0); // first call compiled, so it read the schema + expect(seen.length).toBe(afterFirst); // second call read nothing: cache hit }); }); From a16db7026d7eef718bdad249f7727a7eec7289d6 Mon Sep 17 00:00:00 2001 From: Rafal Krysiak Date: Wed, 2 Sep 2026 15:58:41 +0200 Subject: [PATCH 5/6] fix cursor round-trip and prototype-chain hole in config validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logs-fetch returned its paging cursor as a string while the schema it accepts declares an integer, so handing the returned cursor back — the documented paging flow — failed with -32602. The unknown-tool guard used `name in registry`, which walks the prototype chain, so a config keyed `toString` or `constructor` passed as a known tool and was then silently ignored. Also: report a config error without an OS notification, and document the first-.pos-entry fallback on the mutating tools whose descriptions the bundled config overrides. --- CLAUDE.md | 7 +++- bin/pos-cli-mcp.js | 2 +- .../__tests__/tools-config-validation.test.js | 13 +++++++ mcp-min/__tests__/validate-params.test.js | 39 +++++++++++++++++++ mcp-min/constants/set.js | 2 +- mcp-min/constants/unset.js | 2 +- mcp-min/data/import.js | 2 +- mcp-min/logs/fetch.js | 5 ++- mcp-min/tools.config.json | 8 ++-- mcp-min/tools.js | 5 ++- mcp-min/uploads/push.js | 2 +- 11 files changed, 74 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4b190fce..de0b8d8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -445,8 +445,11 @@ auth properties are what use it. | `mcp-min/tools.js` | `tools.config.json` vs `tools.config.schema.json`, plus tool names | | `lib/server.js` | GUI requests for graph / liquid / logs / logsv2 / sync | -All five sites route through `rejectionFor` in `mcp-min/validate-params.js`, so the mapping -from a rejection to a status code (400/500, `-32602`/`-32603`) is made in exactly one place. +The five MCP dispatch sites (the first three rows) route through `rejectionFor` in +`mcp-min/validate-params.js`, so the mapping from a rejection to a status code (400/500, +`-32602`/`-32603`) is made in one place for the transports. The GUI server keeps its own +`rejectInvalid` in `lib/server.js` because it answers with a different body shape; the two +apply the same 400/500 rule and have to be changed together. A tool that declares no schema falls back to `OPEN_OBJECT_SCHEMA` in `mcp-min/schemas/default.js` — the same constant both `tools/list` responses advertise, so what is published and what is enforced cannot disagree. diff --git a/bin/pos-cli-mcp.js b/bin/pos-cli-mcp.js index 457ffbe0..cab79090 100755 --- a/bin/pos-cli-mcp.js +++ b/bin/pos-cli-mcp.js @@ -10,7 +10,7 @@ try { await import('../mcp-min/index.js'); } catch (error) { if (error?.name === 'ToolsConfigError') { - await logger.Error(error.message, { exit: false, hideTimestamp: true }); + await logger.Error(error.message, { exit: false, notify: false, hideTimestamp: true }); process.exit(1); } throw error; diff --git a/mcp-min/__tests__/tools-config-validation.test.js b/mcp-min/__tests__/tools-config-validation.test.js index dea9ec45..ae6b4eab 100644 --- a/mcp-min/__tests__/tools-config-validation.test.js +++ b/mcp-min/__tests__/tools-config-validation.test.js @@ -102,6 +102,19 @@ describe('tools config validation', () => { expect(stdout).toContain('constants-lst'); }); + // `name in registry` would accept these, because `in` walks the prototype chain — they + // would then match nothing in applyConfig and be silently ignored. + test.each(['toString', 'constructor', 'hasOwnProperty', 'valueOf'])( + 'refuses a config keyed by the inherited property %s', + key => { + const configPath = write(`proto-${key}.json`, JSON.stringify({ tools: { [key]: { enabled: false } } })); + const { stdout, status } = loadWithConfig(configPath); + + expect(stdout).toContain(`no such tool: ${key}`); + expect(status).not.toBe(0); + } + ); + test('accepts a config naming only real tools', () => { const configPath = write('real.json', JSON.stringify({ tools: { 'deploy-start': { description: 'Ship it' } } diff --git a/mcp-min/__tests__/validate-params.test.js b/mcp-min/__tests__/validate-params.test.js index 31d6024b..3f832db1 100644 --- a/mcp-min/__tests__/validate-params.test.js +++ b/mcp-min/__tests__/validate-params.test.js @@ -169,3 +169,42 @@ describe('required relaxations', () => { expect(tools[name].inputSchema.required).toEqual(expected); }); }); + +// logs-fetch documents `lastId` as the cursor to hand back on the next call, so what it +// returns has to satisfy the schema it accepts. It previously returned a string while the +// schema demanded an integer, which broke paging with -32602. +describe('logs-fetch cursor round-trips', () => { + test('the returned cursor is accepted as the next request cursor', async () => { + const rows = [{ id: 41, message: 'a' }, { id: 42, message: 'b' }]; + let call = 0; + class MockGateway { + async logs() { + call += 1; + return { logs: call === 1 ? rows : [] }; + } + } + + const result = await tools['logs-fetch'].handler( + { url: 'https://example.com', email: 'a@b.c', token: 'tok' }, + { Gateway: MockGateway } + ); + + expect(result.ok).toBe(true); + expect(result.lastId).toBe(42); + expect(check('logs-fetch', { lastId: result.lastId }).valid).toBe(true); + }); + + test('the default cursor is also a valid next cursor', async () => { + class MockGateway { + async logs() { return { logs: [] }; } + } + + const result = await tools['logs-fetch'].handler( + { url: 'https://example.com', email: 'a@b.c', token: 'tok' }, + { Gateway: MockGateway } + ); + + expect(result.lastId).toBe(0); + expect(check('logs-fetch', { lastId: result.lastId }).valid).toBe(true); + }); +}); diff --git a/mcp-min/constants/set.js b/mcp-min/constants/set.js index 9b36db6d..6b0d2779 100644 --- a/mcp-min/constants/set.js +++ b/mcp-min/constants/set.js @@ -6,7 +6,7 @@ import { graphQLErrorMessage } from '../../lib/graph/response.js'; import { authProperties } from '../schemas/auth.js'; const constantsSetTool = { - description: 'Set a constant on a platformOS instance. Creates or updates the constant.', + description: 'Set a constant on a platformOS instance. Creates or updates the constant. Omitting env (and url/email/token) targets the first environment in .pos, so name the environment explicitly.', inputSchema: { type: 'object', additionalProperties: false, diff --git a/mcp-min/constants/unset.js b/mcp-min/constants/unset.js index 4221b5d0..3ec7b1f4 100644 --- a/mcp-min/constants/unset.js +++ b/mcp-min/constants/unset.js @@ -6,7 +6,7 @@ import { graphQLErrorMessage } from '../../lib/graph/response.js'; import { authProperties } from '../schemas/auth.js'; const constantsUnsetTool = { - description: 'Delete a constant from a platformOS instance.', + description: 'Delete a constant from a platformOS instance. Omitting env (and url/email/token) targets the first environment in .pos, so name the environment explicitly.', inputSchema: { type: 'object', additionalProperties: false, diff --git a/mcp-min/data/import.js b/mcp-min/data/import.js index e171fe6d..e363b1df 100644 --- a/mcp-min/data/import.js +++ b/mcp-min/data/import.js @@ -34,7 +34,7 @@ async function uploadZipBuffer(buffer, gateway, presignUrlFn, uploadFileFn) { } const dataImportTool = { - description: 'Import data to platformOS instance. Accepts JSON (converted to CSV internally) or ZIP file with CSV files.', + description: 'Import data to platformOS instance. Accepts JSON (converted to CSV internally) or ZIP file with CSV files. Omitting env (and url/email/token) targets the first environment in .pos, so name the environment explicitly.', inputSchema: { type: 'object', additionalProperties: false, diff --git a/mcp-min/logs/fetch.js b/mcp-min/logs/fetch.js index 433f8446..6b0a8b64 100644 --- a/mcp-min/logs/fetch.js +++ b/mcp-min/logs/fetch.js @@ -63,7 +63,10 @@ const fetchLogsTool = { return { ok: true, logs: out, - lastId: latestId, + // Number, not the string the paging loop carries: `lastId` is declared as an + // integer on the way in, and the documented use of this field is to hand it + // straight back as the next call's cursor. + lastId: Number(latestId), meta: { startedAt, finishedAt: new Date().toISOString(), diff --git a/mcp-min/tools.config.json b/mcp-min/tools.config.json index 6de244a3..f0eac729 100644 --- a/mcp-min/tools.config.json +++ b/mcp-min/tools.config.json @@ -56,7 +56,7 @@ }, "data-import": { "enabled": true, - "description": "Import data to platformOS instance. Accepts JSON (converted to CSV internally) or ZIP file with CSV files." + "description": "Import data to platformOS instance. Accepts JSON (converted to CSV internally) or ZIP file with CSV files. Omitting env (and url/email/token) targets the first environment in .pos, so name the environment explicitly." }, "data-import-status": { "enabled": true, @@ -108,7 +108,7 @@ }, "uploads-push": { "enabled": true, - "description": "Upload a ZIP file containing property uploads to platformOS instance. The ZIP should contain files referenced by upload-type properties." + "description": "Upload a ZIP file containing property uploads to platformOS instance. The ZIP should contain files referenced by upload-type properties. Omitting env (and url/email/token) targets the first environment in .pos, so name the environment explicitly." }, "constants-list": { "enabled": true, @@ -116,11 +116,11 @@ }, "constants-set": { "enabled": true, - "description": "Set a constant on a platformOS instance. Creates or updates the constant." + "description": "Set a constant on a platformOS instance. Creates or updates the constant. Omitting env (and url/email/token) targets the first environment in .pos, so name the environment explicitly." }, "constants-unset": { "enabled": true, - "description": "Delete a constant from a platformOS instance." + "description": "Delete a constant from a platformOS instance. Omitting env (and url/email/token) targets the first environment in .pos, so name the environment explicitly." }, "instance-create": { "enabled": true, diff --git a/mcp-min/tools.js b/mcp-min/tools.js index e3724bdd..e5e6638d 100644 --- a/mcp-min/tools.js +++ b/mcp-min/tools.js @@ -183,7 +183,10 @@ function loadToolsConfig(registry) { // typo like "deploy-strt" would otherwise be accepted, match nothing in applyConfig, // and leave "deploy-start" enabled — the exact fail-open the schema check exists to // prevent, and the harder one to notice because the config looks like it took effect. - const unknown = Object.keys(raw.tools || {}).filter(name => !(name in registry)); + // hasOwnProperty, not `in`: `in` walks the prototype chain, so an entry keyed + // `toString` or `constructor` would pass as a known tool and then be ignored. + const unknown = Object.keys(raw.tools || {}) + .filter(name => !Object.prototype.hasOwnProperty.call(registry, name)); if (unknown.length > 0) { throw new ToolsConfigError( `Invalid tools config at ${configPath}: no such tool: ${unknown.join(', ')}` diff --git a/mcp-min/uploads/push.js b/mcp-min/uploads/push.js index f778f978..acbee5a5 100644 --- a/mcp-min/uploads/push.js +++ b/mcp-min/uploads/push.js @@ -10,7 +10,7 @@ import { resolveAuth, runWithAuth } from '../auth.js'; import { authProperties } from '../schemas/auth.js'; const uploadsPushTool = { - description: 'Upload a ZIP file containing property uploads to platformOS instance. The ZIP should contain files referenced by upload-type properties.', + description: 'Upload a ZIP file containing property uploads to platformOS instance. The ZIP should contain files referenced by upload-type properties. Omitting env (and url/email/token) targets the first environment in .pos, so name the environment explicitly.', inputSchema: { type: 'object', additionalProperties: false, From 77ef7b6b9d01f62d2c6e24323375a94bd10b2fa2 Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Wed, 2 Sep 2026 16:26:27 +0200 Subject: [PATCH 6/6] Closed task 3 --- ...se-the-gaps-in-the-new-validation-layer.md | 106 +++++++++++++++--- ...ith-a-test-that-exercises-lib-server.js.md | 52 +++++++++ 2 files changed, 140 insertions(+), 18 deletions(-) rename backlog/{tasks => completed}/task-3 - add-ajv-input-validation-fix-GUI-logs-regression-and-close-the-gaps-in-the-new-validation-layer.md (56%) create mode 100644 backlog/tasks/task-3.1 - Pin-the-GUI-servers-schemaError-status-mapping-with-a-test-that-exercises-lib-server.js.md diff --git a/backlog/tasks/task-3 - add-ajv-input-validation-fix-GUI-logs-regression-and-close-the-gaps-in-the-new-validation-layer.md b/backlog/completed/task-3 - add-ajv-input-validation-fix-GUI-logs-regression-and-close-the-gaps-in-the-new-validation-layer.md similarity index 56% rename from backlog/tasks/task-3 - add-ajv-input-validation-fix-GUI-logs-regression-and-close-the-gaps-in-the-new-validation-layer.md rename to backlog/completed/task-3 - add-ajv-input-validation-fix-GUI-logs-regression-and-close-the-gaps-in-the-new-validation-layer.md index b1a927b0..37ffae20 100644 --- a/backlog/tasks/task-3 - add-ajv-input-validation-fix-GUI-logs-regression-and-close-the-gaps-in-the-new-validation-layer.md +++ b/backlog/completed/task-3 - add-ajv-input-validation-fix-GUI-logs-regression-and-close-the-gaps-in-the-new-validation-layer.md @@ -3,9 +3,10 @@ id: TASK-3 title: >- add-ajv-input-validation: fix GUI logs regression and close the gaps in the new validation layer -status: To Do +status: Done assignee: [] created_date: '2026-09-02 10:21' +updated_date: '2026-09-02 14:17' labels: - bug - validation @@ -118,23 +119,92 @@ Note: `ajv`/`ajv-formats` were absent from the project's `node_modules` during r ## Acceptance Criteria -- [ ] #1 GET /api/logs accepts the cursor shape gui/next actually sends, and the admin Logs page populates on first load with no 400 response -- [ ] #2 The log cursor is typed consistently across lib/validation/schemas/gui.js, mcp-min/logs/fetch.js and mcp-min/logs/stream.js, or the divergence is documented in the schema -- [ ] #3 A single shared constant describes the no-schema default, used by both tools/list responses and the enforcement path in validate-params.js -- [ ] #4 The envs-list tool schema is closed with additionalProperties: false like every other tool -- [ ] #5 validate() distinguishes an unknown mode and a boolean schema from a schema compile failure, and neither is reported as 500 / -32603 -- [ ] #6 validate() no longer returns a field that no caller reads -- [ ] #7 Removing url, email or token from the schema of any tool that calls resolveAuth fails the test suite (today: stripping them from mcp-min/migrations/list.js leaves all 338 mcp-min tests passing) -- [ ] #8 mcp-min/schemas/auth.js is spread into every tool with a closed schema that authenticates, or removed in favour of the inline declarations -- [ ] #9 ajv-formats is used by at least one schema or removed from package.json dependencies -- [ ] #10 A tools.config.json entry naming a tool that does not exist is rejected or loudly warned about, so the fail-closed guarantee in CLAUDE.md holds -- [ ] #11 An invalid tools config reports through logger with a user-facing message and exits non-zero, with no raw Node stack trace -- [ ] #12 CLAUDE.md and the comment in validate-params.test.js state resolveAuth's actual precedence: explicit params, named .pos environment, MPKIT_* env vars, first .pos entry +- [x] #1 GET /api/logs accepts the cursor shape gui/next actually sends, and the admin Logs page populates on first load with no 400 response +- [x] #2 The log cursor is typed consistently across lib/validation/schemas/gui.js, mcp-min/logs/fetch.js and mcp-min/logs/stream.js, or the divergence is documented in the schema +- [x] #3 A single shared constant describes the no-schema default, used by both tools/list responses and the enforcement path in validate-params.js +- [x] #4 The envs-list tool schema is closed with additionalProperties: false like every other tool +- [x] #5 validate() distinguishes an unknown mode and a boolean schema from a schema compile failure, and neither is reported as 500 / -32603 +- [x] #6 validate() no longer returns a field that no caller reads +- [x] #7 Removing url, email or token from the schema of any tool that calls resolveAuth fails the test suite (today: stripping them from mcp-min/migrations/list.js leaves all 338 mcp-min tests passing) +- [x] #8 mcp-min/schemas/auth.js is spread into every tool with a closed schema that authenticates, or removed in favour of the inline declarations +- [x] #9 ajv-formats is used by at least one schema or removed from package.json dependencies +- [x] #10 A tools.config.json entry naming a tool that does not exist is rejected or loudly warned about, so the fail-closed guarantee in CLAUDE.md holds +- [x] #11 An invalid tools config reports through logger with a user-facing message and exits non-zero, with no raw Node stack trace +- [x] #12 CLAUDE.md and the comment in validate-params.test.js state resolveAuth's actual precedence: explicit params, named .pos environment, MPKIT_* env vars, first .pos entry - [ ] #13 The schemaError branch is covered by a test at each of the four sites that maps it to 500 / -32603 -- [ ] #14 No vacuous assertions remain in the new suites: the compile-once test either verifies that Ajv compiles a schema once, or is removed -- [ ] #15 The bare-null tools-config case fails because validation rejected the config, not because applyConfig dereferenced null -- [ ] #16 Validation is covered for the stdio legacy direct-invocation path, the /call-stream legacy streaming path, logsv2 query-as-object and query-as-string, and POST-body coercion on /api/logsv2 -- [ ] #17 transport-validation.test.js binds an ephemeral port rather than a hardcoded one -- [ ] #18 Schema assertions use one consistent style, and the required relaxations on data-validate and unit-tests-run are asserted +- [x] #14 No vacuous assertions remain in the new suites: the compile-once test either verifies that Ajv compiles a schema once, or is removed +- [x] #15 The bare-null tools-config case fails because validation rejected the config, not because applyConfig dereferenced null +- [x] #16 Validation is covered for the stdio legacy direct-invocation path, the /call-stream legacy streaming path, logsv2 query-as-object and query-as-string, and POST-body coercion on /api/logsv2 +- [x] #17 transport-validation.test.js binds an ephemeral port rather than a hardcoded one +- [x] #18 Schema assertions use one consistent style, and the required relaxations on data-validate and unit-tests-run are asserted - [ ] #19 No line added by this branch exceeds the 120-character limit in .editorconfig + +## Final Summary + + +Verified at commits dfdd3ab ("fix GUI logs regression and close gaps in the validation layer") and a16db70 ("fix cursor round-trip and prototype-chain hole in config validation"). 17 of 19 acceptance criteria met; 2 carried forward, detailed below. + +## Blocker fixed and verified end to end + +Fixed on both sides: `gui/next/src/lib/api/logs.js` now sends `args.last ?? 0`, and `lib/server.js` strips `'null'`/`'undefined'`/`''` from the cursor before validating, with a comment naming it a shim for GUI builds already installed and stating the condition for removing it. `logsRequestSchema` gained `default: 0` so an absent cursor no longer interpolates `"undefined"` upstream. + +Probed against a real `lib/server.js` listener (a fake upstream, so 502 = passed validation and attempted the request): + +``` +?lastId=null (what the SHIPPED gui/next/build still sends) 502 +?lastId=0 (what the fixed source sends) 502 +?lastId=42 (subsequent poll) 502 +(no param) 502 +?lastId=1&admin=true 400 /lastId must be integer +?lastId=newest 400 /lastId must be integer +?lastId=-1 400 /lastId must be >= 0 +``` + +Note `gui/next/build` was deliberately not rebuilt — the shipped bundle still contains `c.last??null`, confirmed by grep. The shim is therefore what actually fixes the page for users today, and the source fix is dormant until someone runs `npm run build` in `gui/next`. That is the right trade (a SvelteKit rebuild would churn every hashed asset filename), it is documented in the code, and both shapes are covered by tests. + +## Verified by mutation, not just by reading + +Each mutation applied to production code, suite run, mutation reverted: + +- strip `...authProperties` from `mcp-min/migrations/list.js` (a tool the old hand-written list never named) → 2 failures, one naming the file. The invariant now scans `mcp-min/**` for `resolveAuth(` with fast-glob, so a tool added later is covered the moment it authenticates. This was the escape proven in the review. +- flatten `rejectionFor`'s status mapping to a constant 400 / -32602 → 4 failures, one per MCP dispatch path. +- flatten `rejectInvalid`'s mapping in `lib/server.js` to a constant 400 → **0 failures**. See carried-forward item below. + +## Criterion-by-criterion + +1, 2 — cursor accepted in every shape the GUI sends; typed `integer` consistently in `gui.js`, `mcp-min/logs/fetch.js` and `mcp-min/logs/stream.js`. +3, 4 — `OPEN_OBJECT_SCHEMA` in the new `mcp-min/schemas/default.js`, imported by both `tools/list` responses and `validate-params.js`; `envs-list` closed with `additionalProperties: false`. +5, 6 — unknown mode now throws `RangeError`; boolean schemas skip the WeakMap and validate normally; `data` removed from the return. All three asserted. +7, 8 — every one of the 22 `resolveAuth` tools spreads `authProperties`; no inline `url`/`email`/`token` triple remains anywhere. +9 — `ajv-formats` is now earned: `format: 'uri'` and `format: 'email'` on the shared auth properties. This matches `lib/validators/url.js`, which already requires a parseable absolute URL, so the CLI and MCP surfaces now agree. +10, 11 — `loadToolsConfig` rejects config keys matching no registered tool, using `hasOwnProperty` rather than `in` (with the reasoning in a comment), and names every unknown key. New `ToolsConfigError` caught at the `bin/pos-cli-mcp.js` boundary and reported through `logger`; a test asserts exit 1, the message present, and no stack frames or `node:internal` in the output. +12 — CLAUDE.md now states the real precedence (params → named `.pos` → `MPKIT_*` → first entry) and adds the first-`.pos`-entry warning to the descriptions of `data-import`, `constants-set`, `constants-unset` and `uploads-push` in `tools.config.json`. +14 — the compile-once test now counts schema property reads through a Proxy and asserts the second call reads nothing. It actually measures caching. +15 — the bare-`null` config case now asserts `must be object` and `not.toContain('TypeError')`, so it can no longer pass on the incidental `applyConfig` crash. +16 — added: stdio legacy direct invocation in both its JSON-RPC and bare `{ id, error }` shapes, `/call-stream` legacy streaming (including a `content-type: application/json` assertion, proving rejection precedes the SSE handshake), `logsv2` query-as-object and query-as-string, scalar-into-string-union coercion, a union miss, POST-body coercion, and a `searchAround` payload. +17 — `startHttp({ port: 0 })` with `server.address().port`. +18 — `required` assertions are now uniformly `toEqual([...])` or `toBeUndefined()`, plus a dedicated "required relaxations" block covering `data-validate` and `unit-tests-run`. + +## Found and fixed beyond the task + +Tightening `logs-fetch.lastId` to `integer` (criterion 2) would have broken the tool's own paging: the handler returned the cursor as a string, which the schema it advertises would then reject with -32602. The fix returns `Number(latestId)` and adds a round-trip test that feeds the returned cursor back through validation. `latestId` is only ever assigned a numeric string, so there is no NaN path. + +Worth a release note: `logs-fetch.lastId` and `logs-stream.startLastId` changed type from `string` to `integer`, and `envs-list` now rejects unknown parameters. MCP clients read `tools/list` per session so they adapt automatically, but a hard-coded caller sending `lastId: "42"` will now get -32602. + +## Carried forward + +**Criterion 13 — partially met.** Three of the four `schemaError` sites are genuinely covered via a test-only tool with an uncompilable schema, mutation-verified. The GUI leg is not: the "uncompilable schema on a GUI route" block in `test/unit/server.validation.test.js` builds a throwaway express app and reimplements the status expression inline, so it never exercises `lib/server.js`. Flattening the real `rejectInvalid` mapping to 400 passes all 32 tests in that file. Tracked as TASK-3.1. Left as a follow-up rather than a blocker because no schema in the repo fails to compile, so the branch is unreachable in practice — it is regression protection for a rule CLAUDE.md itself notes is written in two places. + +**Criterion 19 — deviation accepted.** Both code lines originally flagged in `mcp-min/http-server.js` are now within 120 characters. Four new lines exceed it, all of them single-quoted `description:` string literals in tool definitions, in files where `master` already carries description lines of 150–320 characters (`liquid/exec.js:7` is 317). Wrapping them would need string concatenation that no other tool uses. Flagged rather than silently accepted; reopen if the repo ever adopts a linter that enforces `.editorconfig`. + +**One stale comment.** `lib/validation/schemas/gui.js` still reads "The same field is declared `integer` here but `string` on the MCP logs tools" — both are now `integer`, which the same sentence goes on to say. Reads as a description of the change rather than of the code. Cosmetic. + +**Docs/test tension.** CLAUDE.md says "a tool with no schema accepts any object", but `validate-params.test.js` asserts `inputSchema.type === 'object'` for every registered tool, so such a tool would fail the suite. Both are defensible; they just do not agree about whether a schema is optional. + +## Suite status + +`mcp-min/__tests__` + `test/unit`: **1605 passing, 6 skipped, 1 failing** — `modules.test.js > publishVersion() … single-dir workflow`, reproduced identically on a clean `master` worktree and tracked as TASK-9. Up from ~1491 tests before the fix. + +Scope was respected exactly: TASK-4 through TASK-12 are untouched. Confirmed by inspection — no error middleware in `lib/server.js`, all six `tools[name]` / `mcpHandlers[method]` dispatch lookups still unguarded, `bin/pos-cli-mcp-config.js` still does not validate, `mcp-min/auth.js` JSDoc still has the wrong order, and `gui/next/src/lib/api/logsv2.js` untouched. The `hasOwnProperty` fix in commit a16db70 hardens config-key lookup, which is a different site from TASK-5's dispatch lookups, so there is no overlap. + diff --git a/backlog/tasks/task-3.1 - Pin-the-GUI-servers-schemaError-status-mapping-with-a-test-that-exercises-lib-server.js.md b/backlog/tasks/task-3.1 - Pin-the-GUI-servers-schemaError-status-mapping-with-a-test-that-exercises-lib-server.js.md new file mode 100644 index 00000000..b58667bb --- /dev/null +++ b/backlog/tasks/task-3.1 - Pin-the-GUI-servers-schemaError-status-mapping-with-a-test-that-exercises-lib-server.js.md @@ -0,0 +1,52 @@ +--- +id: TASK-3.1 +title: >- + Pin the GUI server's schemaError status mapping with a test that exercises + lib/server.js +status: To Do +assignee: [] +created_date: '2026-09-02 14:16' +labels: + - tests + - validation + - gui +dependencies: [] +references: + - lib/server.js + - test/unit/server.validation.test.js + - mcp-min/__tests__/transport-validation.test.js + - mcp-min/validate-params.js +parent_task_id: TASK-3 +priority: low +ordinal: 28000 +--- + +## Description + + +Carried forward from TASK-3, acceptance criterion 13 ("the schemaError branch is covered by a test at each of the four sites"). Three of the four sites are genuinely covered; the GUI site is not. + +The three MCP dispatch paths route through `rejectionFor` in `mcp-min/validate-params.js`, and `mcp-min/__tests__/transport-validation.test.js` registers a test-only tool with an uncompilable schema (`{ type: 'not-a-real-type' }`) to reach them for real. Mutation-verified: flattening the mapping in `rejectionFor` to a constant 400 / -32602 fails 4 tests. + +The GUI server keeps its own copy of the mapping in the `rejectInvalid` closure inside `lib/server.js` (CLAUDE.md notes the two "apply the same 400/500 rule and have to be changed together"). The test that claims to cover it — `test/unit/server.validation.test.js`, describe block "uncompilable schema on a GUI route" — builds a throwaway express app and calls `validate()` plus its own inline status expression. It never touches `lib/server.js`. Mutation-verified: replacing + +```js +const status = result.schemaError ? 500 : 400; // lib/server.js +``` + +with `const status = 400;` leaves all 32 tests in that file passing. + +So the duplicated rule is pinned on one side only, and the side that is not pinned is the one that duplicates it. + +The straightforward approach is to make the real route see an uncompilable schema — `vi.mock('#lib/validation/schemas/gui.js')` in a dedicated test file, returning a schema Ajv cannot compile for one of the five routes, then assert the route answers 500 and does not forward to the mocked Gateway. A separate file is needed because the existing suite depends on the real schemas. Replacing the throwaway-app test with that closes the gap; the alternative — exporting `rejectInvalid` so it can be tested directly — would also work but widens the module's surface for a test. + +Not urgent: no schema in the repo fails to compile today, so the 500 branch is unreachable in practice. It matters as regression protection for a rule that is written down in two places. + + +## Acceptance Criteria + +- [ ] #1 A test drives a real lib/server.js route whose schema will not compile, and asserts the response is 500 +- [ ] #2 That test asserts the mocked Gateway was not called, so an uncompilable schema still rejects rather than forwards +- [ ] #3 Changing the status expression in lib/server.js rejectInvalid to a constant 400 fails at least one test +- [ ] #4 The throwaway-express-app test in test/unit/server.validation.test.js is replaced rather than left alongside, so there is one place asserting this rule per side +