diff --git a/CHANGELOG.md b/CHANGELOG.md index 26add10..34f927c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Stricter, clearer item writes over MCP + +- **Labels are normalized everywhere:** `create_item` and `update_item` now store labels trimmed, lower case and unique, like the web form. Migration `024_normalize_labels.sql` cleans existing labels, including the published copy and the previous published copy (without counting as a new publish: citation version and review date are unchanged). +- **No more silent drops:** a field value that is not a string (or a string array for labels), for example a number or an object, is refused with the key names. `summary` on a plain General library is refused (it comes from the body) instead of being ignored. +- **Optional version fields** must look like `1.0` when a value is given. +- **One default length per field type** for the tool's argument list and the save check (they used to disagree for text fields without their own limit: 200 vs 8,000). +- **All problems at once:** a refused write lists every issue, not only the first. Web form messages follow. + ### Breaking: Compose uses Postgres 18 on a new volume - `docker-compose.yml` runs `postgres:18` with the volume mounted at `/var/lib/postgresql` (Postgres 18 refused the old `/var/lib/postgresql/data` mount, so a fresh `docker compose up` failed). The volume is now **`postgres18_data`**. diff --git a/docs/concepts.md b/docs/concepts.md index 41984bc..b824227 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -61,6 +61,14 @@ Each Connect link is one library. Tools offered depend on the caller's role in t | `propose_table` / `propose_field` | Publisher | Design a library; a person approves the schema. After approval the tools pick up the new field on the next request; there is no need to reconnect. | | `list_audit` | Publisher, Site admin | Who called which tool in this library; each row has `actor` (the username) | +**How item writes are checked.** `create_item`, `update_item` and the web form go through the same check against the library's stored schema: +- **Fields:** keys that are not fields of this library are refused (`Unknown field(s): …`). Every value must be a string; `labels` is an array of strings. Numbers, booleans, objects and mixed arrays are refused with the key names, not dropped. `null` counts as not sent. +- **Required, dates, versions:** required fields must be non-empty; dates are `YYYY-MM-DD` and a real day; versions look like `1`, `1.0` or `1.0.2`, also when the field is optional and a value is given. +- **Lengths:** name 200, summary 160, labels 40 each and at most 20. A field without its own limit uses one default per type (text 200, longtext 8,000, markdown 50,000), the same in the tool's argument list and in the save check. +- **Labels** are stored trimmed, lower case and without duplicates, whether they come from an agent, the form or `suggest_labels`. Labels are free-form. +- **Summary** on a plain General library comes from the body; an agent that sends `summary` there is told to put the text in `body`. +- **Errors** list every problem in one message (with the library's field list), so an agent can fix them in one retry. + **How `list_items q=` searches.** 1. Every word must match (name, labels and aliases, summary, then body; the full-text index applies English stemming). Aliases score exactly like labels. 2. If nothing matches every word, any word may match. A clear winner that matched on its name or labels, and on most of the words, is returned as the one answer. Weaker candidates come back as **"Did you mean"** suggestions, which the agent must open and check before citing. diff --git a/migrations/024_normalize_labels.sql b/migrations/024_normalize_labels.sql new file mode 100644 index 0000000..40652ea --- /dev/null +++ b/migrations/024_normalize_labels.sql @@ -0,0 +1,51 @@ +-- Labels used to be stored exactly as an agent sent them over MCP ("Ops", +-- " ops", "ops" twice), while the web form stored them trimmed, lower case and +-- unique. Label filters match exactly, so the variants split results. From now +-- on every write normalizes labels; this backfills existing rows the same way: +-- trim, lower case, drop empties and duplicates, keep first-seen order. +-- The published copy (published_snapshot, previous_published_snapshot) carries +-- its own labels list, which Readers see, so it is normalized too. + +CREATE OR REPLACE FUNCTION mcpencode_normalize_labels(input JSONB) RETURNS JSONB +LANGUAGE sql IMMUTABLE AS $$ + SELECT COALESCE(jsonb_agg(label ORDER BY first_pos), '[]'::jsonb) + FROM ( + SELECT label, MIN(pos) AS first_pos + FROM ( + SELECT lower(btrim(value)) AS label, pos + FROM jsonb_array_elements_text( + CASE WHEN jsonb_typeof(input) = 'array' THEN input ELSE '[]'::jsonb END + ) WITH ORDINALITY AS t(value, pos) + ) cleaned + WHERE label <> '' + GROUP BY label + ) unique_labels +$$; + +UPDATE items +SET labels = mcpencode_normalize_labels(labels) +WHERE jsonb_typeof(labels) = 'array' + AND labels IS DISTINCT FROM mcpencode_normalize_labels(labels); + +-- Snapshots are TEXT holding JSON; only touch ones that parse to an object with a labels array. +-- A label clean-up is not a new publish: keep published_version (citations), +-- published_by and review_by as they are by pausing those triggers here. +ALTER TABLE items DISABLE TRIGGER items_published_version; +ALTER TABLE items DISABLE TRIGGER items_review_by; + +UPDATE items +SET published_snapshot = jsonb_set(published_snapshot::jsonb, '{labels}', mcpencode_normalize_labels(published_snapshot::jsonb -> 'labels'))::text +WHERE published_snapshot IS NOT NULL + AND jsonb_typeof((CASE WHEN pg_input_is_valid(published_snapshot, 'jsonb') THEN published_snapshot::jsonb END) -> 'labels') = 'array' + AND ((CASE WHEN pg_input_is_valid(published_snapshot, 'jsonb') THEN published_snapshot::jsonb END) -> 'labels') IS DISTINCT FROM mcpencode_normalize_labels((CASE WHEN pg_input_is_valid(published_snapshot, 'jsonb') THEN published_snapshot::jsonb END) -> 'labels'); + +UPDATE items +SET previous_published_snapshot = jsonb_set(previous_published_snapshot::jsonb, '{labels}', mcpencode_normalize_labels(previous_published_snapshot::jsonb -> 'labels'))::text +WHERE previous_published_snapshot IS NOT NULL + AND jsonb_typeof((CASE WHEN pg_input_is_valid(previous_published_snapshot, 'jsonb') THEN previous_published_snapshot::jsonb END) -> 'labels') = 'array' + AND ((CASE WHEN pg_input_is_valid(previous_published_snapshot, 'jsonb') THEN previous_published_snapshot::jsonb END) -> 'labels') IS DISTINCT FROM mcpencode_normalize_labels((CASE WHEN pg_input_is_valid(previous_published_snapshot, 'jsonb') THEN previous_published_snapshot::jsonb END) -> 'labels'); + +ALTER TABLE items ENABLE TRIGGER items_published_version; +ALTER TABLE items ENABLE TRIGGER items_review_by; + +DROP FUNCTION mcpencode_normalize_labels(JSONB); diff --git a/src/items/custom-schema.ts b/src/items/custom-schema.ts index 752f6d9..ac82029 100644 --- a/src/items/custom-schema.ts +++ b/src/items/custom-schema.ts @@ -3,6 +3,7 @@ * Name, labels, and summary stay on every item. These fields are the rest. * Creating the library locks them. Later adds are still propose → Approve. */ +import { FIELD_TYPE_MAX } from "./field-limits.ts"; import { FIELD_PROPOSAL_TYPES, MAX_FIELD_LABEL, @@ -34,8 +35,8 @@ function fieldDef(label: string, key: string, type: FieldProposalType, required: required, kind: type === "longtext" ? "body" : "control", }; - if (type === "text") field.max = 200; - if (type === "longtext") field.max = 8_000; + if (type === "text") field.max = FIELD_TYPE_MAX.text; + if (type === "longtext") field.max = FIELD_TYPE_MAX.longtext; return field; } diff --git a/src/items/field-limits.ts b/src/items/field-limits.ts new file mode 100644 index 0000000..06f3f12 --- /dev/null +++ b/src/items/field-limits.ts @@ -0,0 +1,18 @@ +/** + * Default length cap per field type when a field has no own `max`. The MCP tool + * argument schema, the save check, and new fields from proposals all read this, + * so they always agree. No imports: safe to use from any items module. + */ +export type FieldLimitType = "text" | "date" | "version" | "longtext" | "markdown"; + +export const FIELD_TYPE_MAX: Record = { + text: 200, + date: 10, + version: 20, + longtext: 8_000, + markdown: 50_000, +}; + +export function fieldMax(field: { type: FieldLimitType; max?: number }): number { + return field.max ?? FIELD_TYPE_MAX[field.type]; +} diff --git a/src/items/field-proposal.ts b/src/items/field-proposal.ts index 94cd302..7ef33d7 100644 --- a/src/items/field-proposal.ts +++ b/src/items/field-proposal.ts @@ -2,6 +2,7 @@ * One optional field proposed over MCP. Not on the live schema until a * publisher Approves it in the portal. Agents cannot approve or reject. */ +import { FIELD_TYPE_MAX } from "./field-limits.ts"; import { z } from "zod/v4"; import type { LibrarySchema, SchemaFieldDef, SchemaFieldType } from "./library-schema.ts"; @@ -129,8 +130,8 @@ export function schemaWithApprovedField( required: false, kind: proposal.type === "longtext" ? "body" : "control", }; - if (proposal.type === "text") field.max = 200; - if (proposal.type === "longtext") field.max = 8_000; + if (proposal.type === "text") field.max = FIELD_TYPE_MAX.text; + if (proposal.type === "longtext") field.max = FIELD_TYPE_MAX.longtext; return { ...schema, fields: [...schema.fields, field] }; } diff --git a/src/items/library-schema.ts b/src/items/library-schema.ts index 63e28a5..eceacfb 100644 --- a/src/items/library-schema.ts +++ b/src/items/library-schema.ts @@ -8,11 +8,15 @@ import { itemInputSchema, excerptOf, normalizeEntityKey, + normalizeLabels, type ItemInput, } from "./schema.ts"; import { formatTableItem } from "./table-proposal.ts"; +import { FIELD_TYPE_MAX, fieldMax } from "./field-limits.ts"; import { type LibraryTemplate, isLibraryTemplate } from "./templates.ts"; +export { FIELD_TYPE_MAX, fieldMax }; + export type SchemaFieldType = "text" | "date" | "version" | "longtext" | "markdown"; export type SchemaFieldDef = { @@ -25,6 +29,8 @@ export type SchemaFieldDef = { max?: number; }; +const VERSION_PATTERN = /^\d+(\.\d+){0,2}$/; + /** Stored on libraries.schema (JSONB). Starter id kept for UI / MCP copy. */ export type LibrarySchema = { starter: LibraryTemplate; @@ -292,7 +298,7 @@ export function schemaAgentBlurb(schema: LibrarySchema): string { * Built from the stored schema so clients do not see other starters' fields. */ function mcpZodForField(field: SchemaFieldDef): z.ZodType { - const max = field.max ?? (field.type === "markdown" ? 50_000 : field.type === "text" ? 200 : 8_000); + const max = fieldMax(field); let description = field.label; if (field.type === "date") description = `${field.label} (YYYY-MM-DD)`; if (field.type === "version") description = `${field.label} (for example 1.0)`; @@ -420,7 +426,7 @@ function isoDate(label: string) { } function zodForField(field: SchemaFieldDef): z.ZodType { - const max = field.max ?? (field.type === "markdown" ? 50_000 : 8_000); + const max = fieldMax(field); if (field.type === "date") { return field.required ? isoDate(field.label) : isoDate(field.label).or(z.literal("")); } @@ -430,8 +436,15 @@ function zodForField(field: SchemaFieldDef): z.ZodType { .trim() .min(1, `${field.label} is required`) .max(max, `${field.label} must be ${max} characters or fewer`) - .regex(/^\d+(\.\d+){0,2}$/, `${field.label} must look like 1.0`); - return field.required ? version : z.string().trim().max(max).default(""); + .regex(VERSION_PATTERN, `${field.label} must look like 1.0`); + if (field.required) return version; + // Optional, but a value that is given must still look like a version. + return z + .string() + .trim() + .max(max, `${field.label} must be ${max} characters or fewer`) + .refine((value) => value === "" || VERSION_PATTERN.test(value), `${field.label} must look like 1.0`) + .default(""); } if (field.required) { return z @@ -443,7 +456,7 @@ function zodForField(field: SchemaFieldDef): z.ZodType { return z.string().trim().max(max).default(""); } -function unexpectedFields(schema: LibrarySchema, input: ItemWriteInput): string | undefined { +function unexpectedFields(schema: LibrarySchema, input: ItemWriteInput): string[] { const allowed = new Set([...META_KEYS, ...schema.fields.map((field) => field.key)]); // Policy may still accept legacy "rule" as an alias for "policy". if (schema.fields.some((field) => field.key === "policy")) allowed.add("rule"); @@ -463,8 +476,31 @@ function unexpectedFields(schema: LibrarySchema, input: ItemWriteInput): string ) { extras.push("body"); } - if (extras.length === 0) return undefined; - return `Unknown field(s): ${[...new Set(extras)].join(", ")}. ${schemaFieldMessage(schema)}`; + const issues: string[] = []; + if (extras.length > 0) issues.push(`Unknown field(s): ${[...new Set(extras)].join(", ")}.`); + return issues; +} + +/** + * Agent writes only: plain General derives the summary from the body, so a summary + * sent over MCP used to be dropped silently. Refuse it instead. (Internal callers + * may still pass one; it is ignored there as before.) + */ +export function plainGeneralSummaryIssue(schema: LibrarySchema, input: ItemWriteInput): string | undefined { + if (!isPlainGeneral(schema)) return undefined; + if (typeof input.summary !== "string" || !input.summary.trim()) return undefined; + return `summary is not a field in this library: it is derived from the body. Put the text in body instead. ${schemaFieldMessage(schema)}`; +} + +/** One readable line per zod issue; generic zod wording gets the field key in front. */ +function issueLines(issues: readonly { message: string; path: readonly PropertyKey[] }[]): string[] { + return issues.map((issue) => { + const key = issue.path.map(String).join("."); + const message = issue.message.trim(); + const generic = /^(Invalid|Too (small|big)|Expected)/.test(message); + const line = generic && key ? `${key}: ${message}` : message; + return /[.!?]$/.test(line) ? line : `${line}.`; + }); } function readField(input: ItemWriteInput, key: string): string { @@ -487,20 +523,23 @@ export function itemForLibrarySchema( const schema = typeof schemaOrStarter === "string" ? schemaForStarter(schemaOrStarter) : schemaOrStarter; - const unexpected = unexpectedFields(schema, input); - if (unexpected) return { ok: false, message: unexpected }; + // Every problem is collected and returned together, not just the first. + const issues = unexpectedFields(schema, input); + const failed = (more: string[]) => ({ + ok: false as const, + message: `${[...issues, ...more].join(" ")} ${schemaFieldMessage(schema)}`.trim(), + }); // Plain Documents: markdown body only (no approved add-on fields yet). if (isPlainGeneral(schema)) { const parsed = itemInputSchema.safeParse({ name: input.name, body: input.body ?? "", - labels: input.labels ?? [], + labels: normalizeLabels(input.labels), entity_key: input.entity_key, }); - if (!parsed.success) { - return { ok: false, message: parsed.error.issues[0]?.message ?? "Invalid item" }; - } + if (!parsed.success) return failed(issueLines(parsed.error.issues)); + if (issues.length > 0) return failed([]); return attachEntityKey({ ...parsed.data, details: null }, input); } @@ -522,7 +561,7 @@ export function itemForLibrarySchema( const payload: Record = { name: input.name, summary: input.summary ?? "", - labels: input.labels ?? [], + labels: normalizeLabels(input.labels), }; for (const field of schema.fields) { payload[field.key] = readField(input, field.key); @@ -530,17 +569,26 @@ export function itemForLibrarySchema( const parsed = z.object(shape).safeParse(payload); if (!parsed.success) { - const detail = parsed.error.issues[0]?.message ?? "Invalid item"; - return { ok: false, message: `${schemaFieldMessage(schema)} ${detail}` }; + const lines = issueLines(parsed.error.issues); + const raw = payload as Record; + if ( + schema.starter === "policy" && + typeof raw.review_date === "string" && + typeof raw.effective_date === "string" && + /^\d{4}-\d{2}-\d{2}$/.test(raw.review_date.trim()) && + /^\d{4}-\d{2}-\d{2}$/.test(raw.effective_date.trim()) && + raw.review_date.trim() < raw.effective_date.trim() + ) { + lines.push("Review date must be on or after the effective date."); + } + return failed(lines); } + if (issues.length > 0) return failed([]); if (schema.starter === "policy") { const data = parsed.data as Record & { name: string; summary: string; labels: string[] }; if (data.review_date && data.effective_date && data.review_date < data.effective_date) { - return { - ok: false, - message: `${schemaFieldMessage(schema)} Review date must be on or after the effective date`, - }; + return failed(["Review date must be on or after the effective date."]); } const details = Object.fromEntries( schema.fields.map((field) => [field.key, data[field.key] ?? ""]), @@ -606,7 +654,7 @@ export function itemForLibrarySchema( ); } - return { ok: false, message: schemaFieldMessage(schema) }; + return failed([]); } /** @@ -618,10 +666,9 @@ export function humanItemMessage(message: string, schema: LibrarySchema): string if (/entity_key already exists|duplicate key value/i.test(message)) { return "Another item in this library already uses this entity key. Edit that item instead, or use a different key."; } - const unknown = /^Unknown field\(s\): ([^.]+)\./.exec(message); - if (unknown) return `This library has no field called ${unknown[1]}.`; const prefix = schemaFieldMessage(schema); - let text = message.startsWith(prefix) ? message.slice(prefix.length).trim() : message; + let text = message.split(prefix).join(" ").replace(/\s+/g, " ").trim(); + text = text.replace(/Unknown field\(s\): ([^.]+)\./, (_m, keys: string) => `This library has no field called ${keys}.`); text = text.replace(/\s*Do not invent fields\.?/g, "").trim(); const label = (key: string) => schema.fields.find((field) => field.key === key)?.label ?? key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "); diff --git a/src/items/schema.ts b/src/items/schema.ts index 3cf47f8..d8cdbf5 100644 --- a/src/items/schema.ts +++ b/src/items/schema.ts @@ -113,6 +113,23 @@ export const createLibraryInputSchema = z.object({ export type CreateLibraryInput = z.infer; +/** + * Labels as stored: trimmed, lower case, unique, empty entries dropped. Same rules + * as the web form (parseLabelField) but without truncating, so an over-long label + * or too many labels is still refused by validation instead of being cut silently. + */ +export function normalizeLabels(labels: readonly string[] | undefined): string[] { + const seen = new Set(); + const out: string[] = []; + for (const raw of labels ?? []) { + const label = raw.trim().toLowerCase(); + if (!label || seen.has(label)) continue; + seen.add(label); + out.push(label); + } + return out; +} + /** Parse a form labels field (`ops, poc`) into the stored string array. */ export function parseLabelField(raw: string | undefined): string[] { if (!raw) return []; diff --git a/src/items/table-proposal.ts b/src/items/table-proposal.ts index 64b10f3..a8df99b 100644 --- a/src/items/table-proposal.ts +++ b/src/items/table-proposal.ts @@ -2,6 +2,7 @@ * A table proposed over MCP. It is not the library schema until a publisher * approves it in the portal. Agents cannot approve or reject. */ +import { FIELD_TYPE_MAX } from "./field-limits.ts"; import { z } from "zod/v4"; import type { LibrarySchema, SchemaFieldDef } from "./library-schema.ts"; @@ -207,8 +208,8 @@ export function schemaFromProposal(proposal: TableProposal): LibrarySchema { required: column.required, kind: column.type === "longtext" ? "body" : "control", }; - if (column.type === "text") field.max = 200; - if (column.type === "longtext") field.max = 8_000; + if (column.type === "text") field.max = FIELD_TYPE_MAX.text; + if (column.type === "longtext") field.max = FIELD_TYPE_MAX.longtext; return field; }); return { starter: "table", tableName: proposal.name, fields }; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 9e2e0fd..b7f7c84 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -41,7 +41,7 @@ import { proposeFieldInputSchema } from "../items/field-proposal.ts"; import { normalizeTableProposal, proposeTableInputSchema } from "../items/table-proposal.ts"; import { itemForLibrary } from "../items/templates.ts"; import { bodyWithoutSummary } from "../markdown/table.ts"; -import { isPlainGeneral, summaryIsDerived } from "../items/library-schema.ts"; +import { isPlainGeneral, plainGeneralSummaryIssue, summaryIsDerived } from "../items/library-schema.ts"; import { parseItemFieldValues, parseTableValues, valuesForSchemaEdit } from "../items/table-proposal.ts"; import { APP_VERSION } from "../version.ts"; import { libraryDesignGuide } from "./library-design.ts"; @@ -161,13 +161,14 @@ function ok(text: string) { export function storedItemWrite(row: ItemRow, schema: LibrarySchema): ItemWriteInput { const write: ItemWriteInput = { name: row.name, - summary: row.summary ?? "", labels: [...(row.labels ?? [])], }; if (isPlainGeneral(schema)) { + // No summary field here: it is derived from the body. write.body = row.body ?? ""; return write; } + write.summary = row.summary ?? ""; const values = parseItemFieldValues(row.details) ?? parseTableValues(row.details) ?? valuesForSchemaEdit(row, schema); for (const field of schema.fields) { @@ -177,13 +178,29 @@ export function storedItemWrite(row: ItemRow, schema: LibrarySchema): ItemWriteI return write; } -function asItemWrite(input: Record): ItemWriteInput { +/** + * Tool payload as item write input. Values must be strings (or a string array); + * anything else is refused with the key names instead of being dropped silently. + * null and undefined mean "not sent". + */ +export function asItemWrite( + input: Record, +): { ok: true; write: ItemWriteInput } | { ok: false; message: string } { const write: ItemWriteInput = {}; + const bad: string[] = []; for (const [key, value] of Object.entries(input)) { + if (value === undefined || value === null) continue; if (typeof value === "string") write[key] = value; else if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) write[key] = value; + else bad.push(key); } - return write; + if (bad.length > 0) { + return { + ok: false, + message: `Unsupported value for field(s): ${bad.join(", ")}. Every field takes a string (labels: an array of strings). Numbers, booleans, objects and mixed arrays are refused, not converted.`, + }; + } + return { ok: true, write }; } export type LibraryMcpContext = { @@ -784,7 +801,11 @@ export function buildMcpServer( }; const libraryRow = await getLibrary(db, library.libraryId); if (!libraryRow) return deny("Library not found."); - const given = asItemWrite(input); + const parsedInput = asItemWrite(input); + if (!parsedInput.ok) return fail(parsedInput.message); + const given = parsedInput.write; + const summaryIssue = plainGeneralSummaryIssue(libraryRow.schema, given); + if (summaryIssue) return fail(summaryIssue); const stored = storedItemWrite(existing, libraryRow.schema); // A derived summary follows the body: re-derive it unless one is given. if (summaryIsDerived(libraryRow.schema)) delete stored.summary; @@ -831,7 +852,15 @@ export function buildMcpServer( head: `Updated the existing item with entity_key ${keyed.entity_key} (id ${keyed.id}) instead of adding a duplicate.`, }); } - const result = await addDraftItem(db, library.libraryId, asItemWrite(input), access.sub); + const parsedInput = asItemWrite(input as Record); + const current = parsedInput.ok ? await getLibrary(db, library.libraryId) : undefined; + const summaryIssue = + parsedInput.ok && current ? plainGeneralSummaryIssue(current.schema, parsedInput.write) : undefined; + const result = !parsedInput.ok + ? parsedInput + : summaryIssue + ? { ok: false as const, message: summaryIssue } + : await addDraftItem(db, library.libraryId, parsedInput.write, access.sub); if (!result.ok) { await writeAudit(db, { actorSub: access.sub, diff --git a/test/write-validation.test.ts b/test/write-validation.test.ts new file mode 100644 index 0000000..3f8198d --- /dev/null +++ b/test/write-validation.test.ts @@ -0,0 +1,238 @@ +/** MCP and form item writes: label normalization, refused values, version format, shared limits, all issues at once. */ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; +import type { AuthInfo } from "@modelcontextprotocol/server"; +import { createItem, createLibrary, getItem, getLibrary, publishItem } from "../src/db/queries.ts"; +import type { Database } from "../src/db/types.ts"; +import { + FIELD_TYPE_MAX, + fieldMax, + humanItemMessage, + itemForLibrarySchema, + mcpCreateItemInputSchema, + plainGeneralSummaryIssue, + schemaForStarter, + type LibrarySchema, +} from "../src/items/library-schema.ts"; +import { normalizeLabels } from "../src/items/schema.ts"; +import { asItemWrite, buildMcpServer } from "../src/mcp/server.ts"; +import { withTestDatabase } from "./db.ts"; + +type ToolResult = { content?: { type: string; text: string }[]; isError?: boolean }; +type Tool = { handler: (args: unknown, extra: unknown) => Promise }; + +const auth: AuthInfo = { + token: "test", + clientId: "test-client", + scopes: [], + expiresAt: Math.floor(Date.now() / 1000) + 600, + extra: { sub: "pub-1", role: "readonly" }, +}; + +async function call(db: Database, libraryId: string, name: string, args: unknown) { + const library = await getLibrary(db, libraryId); + assert.ok(library); + const server = buildMcpServer(db, auth, { + libraryId: library.id, + libraryName: library.name, + libraryRole: "publisher", + idpRole: "readonly", + schema: library.schema, + writeMode: library.writeMode, + }); + const tool = (server as unknown as { _registeredTools: Record })._registeredTools[name]; + assert.ok(tool, `${name} is registered`); + const result = await tool.handler(args, {}); + return { isError: result.isError === true, text: (result.content ?? []).map((part) => part.text).join("\n") }; +} + +const idOf = (text: string) => { + const match = /^id: ([0-9a-f-]{36})$/m.exec(text); + assert.ok(match, `id line in:\n${text}`); + return match[1]; +}; + +const policy = { + name: "Patching", + summary: "Monthly patching", + owner: "Ops", + version: "1.0", + effective_date: "2026-01-01", + review_date: "2027-01-01", + purpose: "Keep hosts patched", + scope: "All servers", + policy: "Patch monthly", + responsibilities: "Ops patches", +}; + +test("normalizeLabels trims, lowercases, dedupes and drops empties without truncating", () => { + assert.deepEqual(normalizeLabels([" Ops", "ops", "POC ", "", " "]), ["ops", "poc"]); + assert.deepEqual(normalizeLabels(undefined), []); + const long = "x".repeat(41); + assert.deepEqual(normalizeLabels([long]), [long]); +}); + +test("item writes store labels normalized; over-long labels are refused, not cut", () => { + const general = itemForLibrarySchema("general", { name: "A", body: "b", labels: ["Ops", " ops ", "Net"] }); + assert.ok(general.ok); + assert.deepEqual(general.item.labels, ["ops", "net"]); + const structured = itemForLibrarySchema("policy", { ...policy, labels: ["Security", "SECURITY"] }); + assert.ok(structured.ok); + assert.deepEqual(structured.item.labels, ["security"]); + const tooLong = itemForLibrarySchema("general", { name: "A", labels: ["y".repeat(41)] }); + assert.equal(tooLong.ok, false); +}); + +test("MCP create_item and update_item normalize labels", async () => { + await withTestDatabase(async (db) => { + const library = await createLibrary(db, "Notes", "pub-1", "general"); + const created = await call(db, library.id, "create_item", { name: "Printer", body: "Reset it", labels: ["Ops", "OPS", " Net "] }); + assert.equal(created.isError, false, created.text); + const id = idOf(created.text); + assert.deepEqual((await getItem(db, id, { publishedOnly: false }))?.labels, ["ops", "net"]); + const updated = await call(db, library.id, "update_item", { id, labels: ["Printers", "printers"] }); + assert.equal(updated.isError, false, updated.text); + assert.deepEqual((await getItem(db, id, { publishedOnly: false }))?.labels, ["printers"]); + }); +}); + +test("asItemWrite refuses non-string values instead of dropping them", () => { + const good = asItemWrite({ name: "A", labels: ["x"], gone: null }); + assert.ok(good.ok); + assert.deepEqual(good.write, { name: "A", labels: ["x"] }); + const bad = asItemWrite({ name: "A", priority: 3, flag: true, meta: { a: 1 }, mixed: ["a", 1] }); + assert.equal(bad.ok, false); + assert.match(bad.ok ? "" : bad.message, /priority, flag, meta, mixed/); +}); + +test("MCP writes with a number or object field are refused with the key name", async () => { + await withTestDatabase(async (db) => { + const library = await createLibrary(db, "Notes", "pub-1", "general"); + const created = await call(db, library.id, "create_item", { name: "Printer", body: "x", priority: 3 }); + assert.equal(created.isError, true); + assert.match(created.text, /Unsupported value for field\(s\): priority/); + const ok = await call(db, library.id, "create_item", { name: "Printer", body: "x" }); + const id = idOf(ok.text); + const updated = await call(db, library.id, "update_item", { id, extra: { nested: true } }); + assert.equal(updated.isError, true); + assert.match(updated.text, /Unsupported value for field\(s\): extra/); + }); +}); + +test("summary on a plain General library is refused, not dropped", async () => { + const general = schemaForStarter("general"); + assert.match(plainGeneralSummaryIssue(general, { name: "A", summary: "My summary" }) ?? "", /summary is not a field in this library/); + // Empty summary is the same as not sending it; structured libraries take a summary. + assert.equal(plainGeneralSummaryIssue(general, { name: "A", summary: "" }), undefined); + assert.equal(plainGeneralSummaryIssue(schemaForStarter("policy"), { name: "A", summary: "S" }), undefined); + await withTestDatabase(async (db) => { + const library = await createLibrary(db, "Notes", "pub-1", "general"); + const created = await call(db, library.id, "create_item", { name: "Printer", body: "Reset it", summary: "Nope" }); + assert.equal(created.isError, true); + assert.match(created.text, /derived from the body/); + // A partial update of a stored plain General item still works (no stored summary is replayed). + const ok = await call(db, library.id, "create_item", { name: "Printer", body: "Reset it" }); + const updated = await call(db, library.id, "update_item", { id: idOf(ok.text), labels: ["ops"] }); + assert.equal(updated.isError, false, updated.text); + }); +}); + +test("optional version fields must look like a version when given", () => { + const schema: LibrarySchema = { + starter: "general", + custom: true, + fields: [{ key: "release", label: "Release", type: "version", required: false, kind: "control" }], + }; + const base = { name: "A", summary: "S" }; + assert.ok(itemForLibrarySchema(schema, { ...base }).ok); + assert.ok(itemForLibrarySchema(schema, { ...base, release: "" }).ok); + assert.ok(itemForLibrarySchema(schema, { ...base, release: " 2.1 " }).ok); + const bad = itemForLibrarySchema(schema, { ...base, release: "latest" }); + assert.equal(bad.ok, false); + assert.match(bad.ok ? "" : bad.message, /Release must look like 1\.0/); +}); + +test("tool schema and save check use the same default max per type", () => { + const schema: LibrarySchema = { + starter: "general", + custom: true, + fields: [ + { key: "host", label: "Host", type: "text", required: false, kind: "control" }, + { key: "notes", label: "Notes", type: "longtext", required: false, kind: "body" }, + ], + }; + const tool = mcpCreateItemInputSchema(schema); + for (const field of schema.fields) { + const max = fieldMax(field); + assert.equal(max, FIELD_TYPE_MAX[field.type]); + const atMax = { name: "A", summary: "S", [field.key]: "a".repeat(max) }; + const over = { name: "A", summary: "S", [field.key]: "a".repeat(max + 1) }; + assert.equal(tool.safeParse(atMax).success, true, `${field.key} at max passes tool schema`); + assert.equal(itemForLibrarySchema(schema, atMax).ok, true, `${field.key} at max passes save`); + assert.equal(tool.safeParse(over).success, false, `${field.key} over max fails tool schema`); + assert.equal(itemForLibrarySchema(schema, over).ok, false, `${field.key} over max fails save`); + } +}); + +test("every validation problem is reported at once", () => { + const result = itemForLibrarySchema(schemaForStarter("policy"), { + ...policy, + owner: "", + version: "v2", + effective_date: "2026-13-40", + colour: "blue", + }); + assert.equal(result.ok, false); + const message = result.ok ? "" : result.message; + assert.match(message, /Unknown field\(s\): colour/); + assert.match(message, /Owner is required/); + assert.match(message, /Version must look like 1\.0/); + assert.match(message, /Effective date must be/); + const human = humanItemMessage(message, schemaForStarter("policy")); + assert.match(human, /no field called colour/); + assert.match(human, /Owner is required/); + assert.doesNotMatch(human, /Do not invent fields/); +}); + +test("review date before effective date is still reported, alongside other issues", () => { + const alone = itemForLibrarySchema("policy", { ...policy, review_date: "2025-01-01" }); + assert.equal(alone.ok, false); + assert.match(alone.ok ? "" : alone.message, /Review date must be on or after the effective date/); + const both = itemForLibrarySchema("policy", { ...policy, review_date: "2025-01-01", owner: "" }); + assert.match(both.ok ? "" : both.message, /Owner is required.*Review date must be on or after/s); +}); + +test("migration 024 normalizes stored labels and published copies without a new publish", async () => { + await withTestDatabase(async (db) => { + const library = await createLibrary(db, "Notes", "pub-1", "general"); + const item = await createItem(db, { name: "Printer", body: "Reset", labels: ["ops"] }, "pub-1", library.id); + await publishItem(db, item.id, "pub-1"); + const before = (await db.get("SELECT published_version, published_snapshot FROM items WHERE id = ?", item.id)) as { + published_version: number; + published_snapshot: string; + }; + const snapshot = JSON.parse(before.published_snapshot); + snapshot.labels = ["Ops", " ops", "NET", ""]; + await db.run( + "UPDATE items SET labels = ?::jsonb, published_snapshot = ?, previous_published_snapshot = ? WHERE id = ?", + JSON.stringify(["Ops", "ops ", "Net", ""]), + JSON.stringify(snapshot), + "not json", + item.id, + ); + const versionBefore = ( + (await db.get("SELECT published_version FROM items WHERE id = ?", item.id)) as { published_version: number } + ).published_version; + await db.exec(await readFile(new URL("../migrations/024_normalize_labels.sql", import.meta.url), "utf8")); + const after = (await db.get( + "SELECT labels, published_snapshot, previous_published_snapshot, published_version FROM items WHERE id = ?", + item.id, + )) as { labels: string | string[]; published_snapshot: string; previous_published_snapshot: string; published_version: number }; + const labels = typeof after.labels === "string" ? JSON.parse(after.labels) : after.labels; + assert.deepEqual(labels, ["ops", "net"]); + assert.deepEqual(JSON.parse(after.published_snapshot).labels, ["ops", "net"]); + assert.equal(after.previous_published_snapshot, "not json"); + assert.equal(after.published_version, versionBefore); + }); +});