Skip to content

feat(mcp)!: enforce the field constraints the schema already accepted#84

Merged
ABB65 merged 6 commits into
mainfrom
fix/field-constraints
Jul 16, 2026
Merged

feat(mcp)!: enforce the field constraints the schema already accepted#84
ABB65 merged 6 commits into
mainfrom
fix/field-constraints

Conversation

@ABB65

@ABB65 ABB65 commented Jul 15, 2026

Copy link
Copy Markdown
Member

A project reported that items, accept and maxSize are accepted on a field but never enforced — emails: ["not-an-email"] and accept: "image/jpeg" against a .webp both produced zero errors.

The report was right. It was also the visible tip: 4 of 27 field types had any validation beyond typeof, three constraints were read by nothing, unique didn't work where the templates declare it, and none of it blocked a write anyway.

Same through-line as #82: the tool accepts input, reports success, and silently does not do what it claims. A constraint that isn't a constraint is worse than no constraint — the author stops looking.

Studio delegates to this validator, so it picks all of this up for free.

What was wrong

Nothing validated before writing plan → commit → validate → report. An invalid value landed in git, was auto-merged, and the caller learned about it from a string in next_steps while status said "committed". bulk imported no validator at all.
17 types were typeof checks type: 'slug' ignored the SLUG_PATTERN we already own — and every template declares slug: { type: 'slug' }, so "Hello World!!" validated clean. date/datetime unparsed, though schedule.ts already did exactly that parse for meta.
Array items ran a second, worse implementation A parallel type switch knowing 10 of 27 types, typeof only. Items never saw min/max/pattern/options. items as a FieldDef with a non-object type ({type:'array', items:{type:'string', max:50}}) matched no branch at all — while the type emitter rendered it as real. integer rejected 3.7 in an array but accepted it as a scalar.
unique was a no-op on documents Gated on a context only the collection validator passed — and documents are exactly where every shipped template declares it.
accept/maxSize/default read by nothing All three documented as functional in schema-rules.md. media-rules.md even told agents "do not silently accept oversized files" — a rule with no machine backing.
options on a non-select silently ignored The refine was one-directional. And z.object strips unknown keys, so a typo'd requird: true vanished without a word.

What changed

The write gate. Validation runs on the pending changes and refuses to commit on any error — isError, no branch, nothing to clean up. Two calls had to be made:

  • Only this save's entries are fatal. validateProject checks the whole model; blocking on everything would let one pre-existing bad entry hold up an unrelated valid save by someone who may not be able to fix it. Scoped by id/slug/locale to the entries in the call.
  • Warnings pass. Heuristics are approximations and a legitimate value can sit outside one. They ride back in the response.

The sharpest consequence wasn't designed, it fell out: a secret can no longer reach git.

One rule set per type. Array items recurse through validateField itself instead of a parallel switch — closing the 10-of-27 hole, the items-FieldDef black hole, the missing constraints on items, and the integer split, while deleting code.

Semantic types, reusing what we own (validateSlug, schedule.ts's date parse). Severity follows one rule: definitional → error (slug, date, datetime, integer, percent); heuristic → warning (color, phone). email/url keep their existing warning — the tests pin it deliberately. rating is left alone: its scale is never declared, so any range would be invented.

The dead constraints, honestly. accept is enforced by extension-sniff and the message says that's what it is. default is coherence-checked at model_save but not written into content — supplying values is the agent's job. maxSize cannot be enforced by MCP (it holds a path, never bytes; LocalProvider has no media facet; MediaProvider.get keys on id, not path), so model_save says so and points at the provider. The docs are corrected.

model_save rejects what it won't enforce — including unique on a singleton, which holds one record per locale and so has nothing to compare against. Nested fields/items are validated recursively; they were z.unknown() and unchecked.

Tests

72 new across 4 files. validateModelDefinition and the CDN document query had no tests at all.

Each fix was verified by reverting it and watching the tests go red — stubbing validateSemanticType to a no-op turns 10 red, including both of the reporter's cases. The gate tests assert on the repository, not the response — a gate that only claims to have blocked is the bug being fixed.

Two fixtures broke, and the break was the signal. Four contentrain_validate tests planted invalid content through content_save to prove validate reports it — precisely what the gate now prevents. They plant it on disk instead, which is the case that still matters: the gate stops new problems, it does not clean a repository.

One regression I introduced, caught by an existing fixture: unifying items dropped the blocks[0].title path prefix. Fixed, and taken one step further — top-level object errors are now qualified too (seo.title), where a bare title was ambiguous with a real top-level field.

Green: MCP validator 51, model-definition 23, gate 6, workflow 15, content/apply/normalize 51, bulk/server/conformance 39, types 110, SDK 260 + type tests. oxlint 0/0 across 172 files, tsc clean in mcp/types/cli/sdk.

Breaking

@contentrain/mcp major, @contentrain/types minor.

  • content_save rejects content it previously committed. Run contentrain_validate before upgrading to see what would now be blocked. Blast radius is limited to the entries in each call.
  • model_save rejects models it previously accepted (unknown keys, min > max, options on a non-select, unique on a singleton).
  • validateModelDefinition returns { errors, warnings } instead of string[].
  • Array-item type errors carry validateFieldValue's message; the field path is unchanged.
  • Nested object errors are qualified by their parent.

One consequence worth naming: a broken relation now blocks the save, so a target must exist before the entry referencing it. Within a single content_save call the overlay resolves them, so batches are fine; across calls it means ordering. That follows from relation integrity already being an error in validateProject — flagging it because it's a real workflow change, not a bug.

🤖 Generated with Claude Code

ABB65 added 6 commits July 15, 2026 23:59
Only 4 of the 27 field types had any validation beyond `typeof`: email, url,
select and relation. The other 17 were indistinguishable from `string` at
runtime — a `slug` field accepted "Hello World!!", a `date` field accepted
"yesterday", and `integer` accepted 3.7. The types existed for the Studio UI
and the emitted TS, and stopped meaning anything after that.

New `validateSemanticType(value, type)`, called from validateFieldValue after
the type match. It reuses what the codebase already owned rather than inventing
rules: `validateSlug` (with its path-traversal check) for slug, and the same
date parse `validator/schedule.ts` has always done for publish_at/expire_at.

Severity follows one rule. A *definitional* check is an error — a slug that does
not match SLUG_PATTERN is not a slug, an unparseable date is not a date. A
*heuristic* is a warning, because the pattern is an approximation and a real
value can sit outside it (international phone formats, CSS colour keywords).
email/url keep the warning severity they already had; the tests pin it on
purpose. `rating` is deliberately left alone — its scale is never declared, so
any range would be invented.

Also `validateAccept` + `isMediaType`: `accept` was declared on FieldDef and
read by nothing. It is now checked against the value's file extension, at
warning severity, and the message says that is what it is — the stored value is
a path, so the real MIME type is only knowable at the provider's ingest. An
extension we cannot map to a MIME type is not evidence of a violation, so it
stays silent rather than guessing.

`maxSize` is untouched here on purpose: byte length is not knowable from a path.

Tests: 110 in packages/types pass; the rules are exercised through
@contentrain/mcp's validateContent fixtures, where stubbing validateSemanticType
to a no-op turns 10 red.
The reported case — `emails: ["not-an-email"]` producing zero errors — was one
symptom of a parallel implementation. `validateArrayItemType` was a second type
switch that knew 10 of the 27 types and checked only `typeof`, so:

- items never saw min/max/pattern/options/required — only their JS kind;
- `items` given as a FieldDef with a non-object type (`{type:'array',
  items:{type:'string', max:50}}`) matched neither the string-form branch nor
  the object+fields branch, so it was validated by nothing at all — while the
  type emitter rendered the constraint as real;
- `integer` rejected 3.7 inside an array but accepted it as a scalar, because
  the two switches disagreed.

Items now recurse through `validateField` itself, which already knows every
type, relations, and nested objects. One rule set for a type wherever it
appears, and the second switch is deleted. `ctx` is deliberately not passed to
items: `unique` compares an entry against its siblings, which means nothing for
positions inside one array. A depth cap bounds items-inside-items.

Two deliberate contract changes:

- Item type errors now carry validateFieldValue's message ("Type mismatch:
  expected string, got number") rather than "must be a string". The field path
  still names the index.
- Nested object errors are qualified by their parent — `seo.title`, not `title`,
  which was ambiguous with a top-level field of the same name. The old
  array-of-object branch already did this; unifying spread it to plain objects
  too, and dropping the prefix would have been a regression the fixtures caught.

email/url heuristics move to @contentrain/types with the rest of the semantic
rules, so scalars and items share them.

Tests: 39 in entry.test.ts. New cases cover the reported bug, a type the old
switch never knew (date), the items-FieldDef black hole, options on items, and
the depth cap. entry.test.ts:171,201 updated for the two changes above.
`unique` is gated on a validation context carrying the entry's siblings, and
only the collection validator ever passed one. Documents were validated one file
at a time, so the check silently did nothing — which is exactly where it is most
often declared: every shipped template writes
`slug: { type: 'slug', required: true, unique: true }` on a document model
(templates/blog.ts, docs.ts, ecommerce.ts, saas.ts).

validateDocumentModel now reads each document once up front, keeps the
frontmatter per locale, and validates with `{ allEntries, currentEntryId: slug }`.
The pre-pass replaces the read inside the loop rather than adding one, so a
remote provider still pays one fetch per file.

Uniqueness stays scoped to a locale: the same value in en and tr is a
translation, not a collision.

Nested objects now receive `ctx` too (entry.ts recursion dropped it), so
`unique` inside `object.fields` is checked rather than quietly skipped.

Singletons are handled in the model_save commit that follows: a singleton holds
one record per locale, so `unique` has nothing to compare against and is
rejected at schema level rather than given a fake implementation here.

Tests: tests/core/validator/unique.test.ts (4) — duplicate detection, distinct
values, self-comparison, and locale scoping. Removing the ctx turns the first red.
`accept`, `maxSize` and `default` were declared on FieldDef, accepted by the zod
schema, stored in the model — and read by nothing. `options` on a non-select was
accepted and then ignored at validation time, because the refine only checked
select ⇒ options and never the reverse. A constraint that silently does nothing
is worse than no constraint: the author stops looking.

model_save now rejects a property declared where it cannot apply — `options` off
select, `items` off array, `fields` off object, `accept`/`maxSize` off media,
`unique` on a singleton (one record per locale, so nothing to compare against).
Plus `min > max`, and a `pattern` that does not compile: left to validation time
that degraded to a per-entry warning and silently disabled the rule.

The field schema is now `.strict()`. z.object *strips* unknown keys by default,
so a typo'd `requird: true` vanished without a word and the field quietly lost
the constraint its author thought they had declared.

Nested `fields`/`items` are validated recursively. They were typed `z.unknown()`
and the loop was top-level only, so a nested invalid type, a nested select
without options, or a nested bad field name all sailed through.

`default` is checked for coherence — right type for its field, and within its
own `options`. It is not written into content: supplying values is the agent's
job, and quietly materialising them would be MCP making a content decision.

Two things MCP cannot enforce now say so instead of hiding, via a new warnings
channel (`validateModelDefinition` returns `{errors, warnings}`; model_save
surfaces `schema_warnings`):

- `maxSize` — MCP stores a path and never sees the bytes. The provider enforces
  it at ingest; provider.ts already assigns it that duty.
- `max` on a media field — it limits the *path string length*, not the file,
  which is almost certainly not what the author meant.

Tests: tests/core/model-definition.test.ts (23) — validateModelDefinition had no
tests at all. Templates and the model tool still pass unchanged.
content_save ran plan → commit → validate → report. An invalid value was written
to git and auto-merged, and the caller learned about it from a string in
next_steps while `status` still said "committed". Nothing on the write path ever
consulted model.fields before the bytes landed, so required fields, patterns,
select options and secrets were all advisory after the fact.

Validation now runs on the pending changes and blocks. The move itself is
mechanical — the existing call already used an OverlayReader over `plan.changes`,
which needs nothing from the commit — but two things had to be decided:

**Only this save's entries are fatal.** validateProject checks the whole model,
so blocking on every error would let one pre-existing bad entry hold up an
unrelated, valid save by a caller who may not be able to fix it.
`touchesSavedEntries` filters to the entries in the call — by id for
collections, slug for documents, locale for singletons and dictionaries.
Everything else is reported, not enforced.

**Warnings pass.** email/url/colour/phone patterns and the `accept` extension
check are approximations; a legitimate value can sit outside one. They come back
in the response as `warnings` instead of failing the write.

The sharpest consequence is one that falls out rather than being designed:
a secret can no longer reach git. detectSecrets is an error, and errors no
longer commit.

Four contentrain_validate fixtures broke, and the break was the signal: each
planted invalid content *through content_save* to prove validate reports it, and
that is exactly what the gate now prevents. They plant it on disk instead — the
way a legacy project or a hand-edit leaves it — which is the case that still
matters: the gate stops new problems, it does not clean a repository.

Tests: tests/tools/content-gate.test.ts (6). They assert on the repository, not
the response — a gate that only *claims* to have blocked is the bug being fixed
— so they check the content file is empty and no branch was created. Also
covered: a valid save still commits, a warning still commits, a secret does not,
and a broken sibling entry does not block a good save.
schema-rules.md listed accept, maxSize and default as working, and
media-rules.md told agents 'do not silently accept oversized files' — a rule
with no machine backing. The table now names who enforces each property, and
says plainly that maxSize is the provider's job at ingest and that accept is an
extension check. Essentials and the MCP docs page gain the write-gate contract.
@netlify

netlify Bot commented Jul 15, 2026

Copy link
Copy Markdown

Deploy Preview for contentrain-ai ready!

Name Link
🔨 Latest commit 173326c
🔍 Latest deploy log https://app.netlify.com/projects/contentrain-ai/deploys/6a57f76e1d940300081584c5
😎 Deploy Preview https://deploy-preview-84--contentrain-ai.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@ABB65
ABB65 merged commit dbda3cf into main Jul 16, 2026
6 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 16, 2026
@ABB65
ABB65 deleted the fix/field-constraints branch July 16, 2026 09:55
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant