Skip to content

documents

Seed documents.template/documents.template_variable rows without a dfl-schema migration, and author documents.document rows from any template category (or from a literal body) for any entity.

Endpointhttps://engineering.mcp.devfellowship.com/mcp
Tools4
Backing datadocuments.template, documents.template_variable, documents.document; read of public.diagrams and work.epics for diagram injection and entity resolution.
ToolDescription
seed_templateCreate or update a documents.template + its documents.template_variable rows, without a dfl-schema migration. Idempotent by template name — re-running with the same name updates it in place and replaces the full variable set. Reusable for any template, not handoff-specific.
get_handoff_documentRead documents.document rows. Pass id for a single document, or epic_id to list every document linked to a work.epics id.
write_documentThe generic document writer. Create or update a documents.document row from a template category, an explicit template_id, or literal content. Not tied to any one template kind.
write_handoff_documentThin wrapper over write_document for the category: "handoff" case: resolves a work.epics id to its name and injects {{dfl-diagram:UUID}} tokens for every diagram already created for that epic.

write_handoff_document does not have its own write path — it resolves the epic and then calls the same writeDocument core as write_document (src/tools/documents/write-document-core.ts). Everything below — required fields, duplicate handling, RLS behaviour, the audit entry — is therefore identical for both tools.

Required — title. documents.document.title is NOT NULL with no default.

Required — exactly one body source, out of category, template_id, or content. category picks the most recently updated active template in that category; template_id names one explicitly (an inactive one is refused); content writes a literal body with no template. Zero sources, or more than one, is an error — the tool does not guess.

Optional:

  • variables — variable_key → value map stored in variables_data. Substitution happens in the frontend.
  • document_type — defaults to the template’s type, else "other".
  • status — "draft" (default) or "pending" only. The signature-lifecycle values (sent, partially_signed, completed, finished, cancelled) belong to the signing flow and are rejected by the input schema: an agent must not be able to declare a document signed.
  • entity_id / entity_name — free linkage to whatever row the document is about.
  • diagram_entity_id — injects diagram tokens for that entity (see below).
  • document_id — update this row instead of creating one.
  • on_duplicate — "error" (default), "update", or "create".

Update is in scope. Pass document_id to rewrite an existing row in place; the result reports action: "created" or action: "updated". There is no delete tool.

Duplicates are refused, not silently multiplied. documents.document has no uniqueness constraint, so nothing at the database level stops two rows with the same title. Before inserting, write_document looks for an existing document with the same title and the same entity_id (or the same title with a null entity_id). If it finds one, the default on_duplicate: "error" refuses and returns existing_document_ids so the caller can decide; "update" rewrites the most recently updated match; "create" inserts another row anyway.

RLS, attribution, and why a failed write is never a success

Section titled “RLS, attribution, and why a failed write is never a success”

Every call runs on the caller’s user JWT (createSupabaseClient(jwt)), never service_role — the fleet rule from Tainan, 2026-06-17. documents.document carries a single policy, document_write (ALL for authenticated, USING/WITH CHECK = iam.is_member()), so a caller who is not a DFL member cannot read or write these rows at all.

That has a sharp edge worth stating: under RLS, an UPDATE on a row you may not see affects zero rows and returns no error — indistinguishable from “the row does not exist”, and easy to mistake for success. This tool treats a zero-row update as a failure, with an explicit message saying nothing was written. Likewise, passing diagram_entity_id when the body has no diagram sentinel is an error rather than a quiet no-op.

documents.document has no created_by column, so authorship is recorded separately: each successful write inserts a documents.document_log row with event_type: "mcp_document_created" / "mcp_document_updated" and event_data.actor_user_id set to the calling user. The document is committed before that log is attempted, so a log failure is reported honestly as audit_logged: false rather than failing or hiding the write.

documents.template/documents.template_variable are reference/seed data, not schema — per the pipeline plan (Tainan, 2026-07-09), seeding them goes through this tool, not a PR touching dfl-schema/supabase/migrations/ (that repo’s own AGENTS.md forbids INSERT in migrations; see PR #143 there, rejected for exactly this). seed_template is idempotent by name, so re-seeding the same template (e.g. after editing its content) is safe to run repeatedly.

No document_type: "handoff" — use category: "handoff" instead

Section titled “No document_type: "handoff" — use category: "handoff" instead”

documents.document_type is a fixed Postgres enum (contract | proposal | nda | sow | other | amendment) — it has no "handoff" value. The convention used here is document_type: "other" (or "sow") plus the free-form category: "handoff" column, which the dfl-documents frontend already uses to group templates. Passing document_type: "handoff" to seed_template fails at the database level with an enum error.

Diagram injection does not render images — it inserts tokens

Section titled “Diagram injection does not render images — it inserts tokens”

Neither tool renders Mermaid/PlantUML to an image server-side. Given diagram_entity_id (which write_handoff_document sets to the epic id), the writer queries public.diagrams for that entity (same filter as list_diagrams) and replaces the body’s diagram sentinel with real {{dfl-diagram:UUID}} tokens — one per diagram found. The dfl-documents frontend (src/utils/diagram-token.ts) already resolves that token into a rendered diagram when the document is viewed or edited there. This was a deliberate scope decision (confirmed by Tainan): porting the Mermaid/PlantUML parser into this Node package was judged unnecessary complexity once the client-side token mechanism was found to already cover the need.

{
"name": "Handoff Técnico v2",
"category": "handoff",
"document_type": "other",
"content": "...markdown with {{variable_key}} placeholders and one {{dfl-diagram:00000000-0000-0000-0000-000000000000}} sentinel...",
"variables": [
{ "variable_key": "projeto_nome", "label": "Nome do projeto", "is_required": true },
{ "variable_key": "estimativa_pontos", "label": "Estimativa (pontos de história)", "variable_type": "number" }
]
}

Estimates in a handoff template must use variable_type: "number" with the unit spelled out in the label (pontos or horas) — never variable_type: "currency". documents.variable_type has no dedicated points/hours value, and the plan’s business rule is that a handoff document never exposes a monetary value.

Authoring a document in an arbitrary category

Section titled “Authoring a document in an arbitrary category”

write_document is category-parameterised, so a new kind of document needs no new tool — seed a template with seed_template, then instantiate it:

{
"title": "Runbook — deploy do dfl-mcp-engineering",
"category": "runbook",
"entity_id": "3f1c…",
"entity_name": "Deploy pipeline",
"variables": { "responsavel": "William", "ambiente": "prod" }
}

Or with no template at all, for a one-off body:

{ "title": "Notas da reunião 2026-08-05", "content": "# Notas\n\n- ..." }