Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`**.
Expand Down
8 changes: 8 additions & 0 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions migrations/024_normalize_labels.sql
Original file line number Diff line number Diff line change
@@ -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);
5 changes: 3 additions & 2 deletions src/items/custom-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down
18 changes: 18 additions & 0 deletions src/items/field-limits.ts
Original file line number Diff line number Diff line change
@@ -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<FieldLimitType, number> = {
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];
}
5 changes: 3 additions & 2 deletions src/items/field-proposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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] };
}

Expand Down
95 changes: 71 additions & 24 deletions src/items/library-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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;
Expand Down Expand Up @@ -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)`;
Expand Down Expand Up @@ -420,7 +426,7 @@ function isoDate(label: string) {
}

function zodForField(field: SchemaFieldDef): z.ZodType<string> {
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(""));
}
Expand All @@ -430,8 +436,15 @@ function zodForField(field: SchemaFieldDef): z.ZodType<string> {
.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
Expand All @@ -443,7 +456,7 @@ function zodForField(field: SchemaFieldDef): z.ZodType<string> {
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<string>([...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");
Expand All @@ -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 {
Expand All @@ -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);
}

Expand All @@ -522,25 +561,34 @@ export function itemForLibrarySchema(
const payload: Record<string, unknown> = {
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);
}

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<string, unknown>;
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<string, string> & { 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] ?? ""]),
Expand Down Expand Up @@ -606,7 +654,7 @@ export function itemForLibrarySchema(
);
}

return { ok: false, message: schemaFieldMessage(schema) };
return failed([]);
}

/**
Expand All @@ -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, " ");
Expand Down
17 changes: 17 additions & 0 deletions src/items/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,23 @@ export const createLibraryInputSchema = z.object({

export type CreateLibraryInput = z.infer<typeof createLibraryInputSchema>;

/**
* 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<string>();
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 [];
Expand Down
5 changes: 3 additions & 2 deletions src/items/table-proposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 };
Expand Down
Loading
Loading