MCP services
The platform exposes MCP (Model Context Protocol) tool servers over HTTP, so an AI agent — Claude, Codex, or any MCP-capable client — can drive the platform through typed, authorized tools instead of hand-writing SQL/DDL or reverse-engineering the schema. Each server is a normal authenticated Symfony route; authorization reuses the platform's JWT + RBAC.
The first server is entity structure; forms, views, and the menu ship on the same endpoint.
Entity structure server
Endpoint: POST /api/v1/mcp/structure
Transport: MCP Streamable-HTTP, JSON-RPC 2.0 (initialize, tools/list, tools/call;
synchronous JSON responses — no SSE).
Auth: a JWT bearer whose principal holds the admin or structure_admin role (else 403).
It wraps EntityStructureService, which encodes "the right way" to evolve the schema:
- The base contract (
id,guid,created,modified,status) is added automatically — you declare only business fields. - New entities are created in the active package (
active_packageconfig); you never pass a package. Altering an entity owned by a different package is refused (package_mismatch). createEntitydoes DDL and metadata indexing (fields, grants, labels) in one call.alterEntityis non-destructive: it never drops or renames, and never addsNOT NULLto an existing column.
Tools
| Tool | Purpose |
|---|---|
getEntity |
{ table } → the entity + its fields (name, type, nullable, length, references, flags). |
listEntities |
{ q? } → entities (table_name, kind, package_cid, field_count), optional substring filter. |
createEntity |
Create a table + register it. |
alterEntity |
Add fields / resize / relax-nullable / update metadata on an existing entity. |
createEntity
{
"table_name": "crm_partner",
"kind": "transactional",
"display_field": "name",
"fields": [
{ "name": "name", "type": "varchar", "length": 150, "nullable": false, "indexed": true },
{ "name": "email", "type": "varchar", "length": 254 },
{ "name": "credit_limit","type": "decimal", "precision": 12, "scale": 2 },
{ "name": "owner_id", "type": "reference", "references_table": "syntec_user" }
]
}
type is one of: varchar, text, integer, smallint, bigint, boolean, decimal, date, datetime,
time, timestamptz, uuid, jsonb, reference. Declaring a base-contract field name is rejected.
alterEntity
{
"table": "crm_partner",
"add_fields": [ { "name": "phone", "type": "varchar", "length": 40 } ],
"field_updates": [
{ "name": "name", "length": 200 }, // resize (widen) a varchar
{ "name": "email", "set_null": true }, // relax NOT NULL
{ "name": "count", "type": "bigint" } // integer widening smallint/integer -> bigint
],
"set": { "display_field": "name" }
}
Allowed on existing columns: resize length/precision/scale, integer widening
(smallint → integer → bigint), and set_null: true. Rejected with unsupported_alter: rename,
cross-family type change, set_null: false (SET NOT NULL), altering a system column, and drop
(there is no drop parameter — the platform never drops).
Error model
Tool failures come back as a structured tool result (not a transport crash), with a stable
code: not_found, validation, package_mismatch, not_null_on_populated, unsupported_alter,
resize_failed. Auth failure is a JSON-RPC error mapping to HTTP 403 (unauthorized).
Forms server
Endpoint: POST /api/v1/mcp/structure — the same endpoint, same JSON-RPC transport, same
JWT + admin/structure_admin gate, and the same active_package scoping as the entity tools
above. Structure writes (entity DDL and form-tree writes) run elevated — a
structure_admin service token can build out a form without holding per-field record grants on
the entity it edits.
It wraps FormStructureService (backed by FormTreeReconciler /
FormStructureValidator for the actual tree diff/validate/write), which encodes the safe way to
author an edit form:
getFormreturns the full, round-trippable edit tree — feed it back intosetFormTree(after local edits) to update the form.createFormonly creates an empty form shell; you build it out with one or moresetFormTreecalls.setFormTreeis a whole-tree declarative write: nodes carrying aguidare updated in place, nodes without one are created, and any previously-active component omitted from the posted tree is soft-deleted. It is all-or-nothing — an invalid tree writes nothing and returnsstructure_invalid.
Tools
| Tool | Purpose |
|---|---|
listForms |
{ q? } → forms (cid, entity, package_cid, component_count), optional substring filter on cid. |
getForm |
{ cid } → { cid, entity, package_cid, open_read_mode, icon, color, tree: { children: [node, ...] } }. |
createForm |
{ cid, entity, display?, icon?, color?, open_read_mode? } → an empty form shell in the active package. |
setFormTree |
{ cid, tree } → reconciles the whole component tree; returns the reconciled form (i.e. getForm(cid)) so the caller sees server-assigned guids. |
createForm
{
"cid": "crm_partner_edit",
"entity": "crm_partner",
"display": "Partner",
"icon": "mdi-domain",
"open_read_mode": false
}
cid must match ^[a-z][a-z0-9_]*$ and be unique; entity must be an existing entity (physical
table name). A duplicate cid, a malformed cid, or an unknown entity is rejected with
validation.
Tree node shape (used by both getForm's response and setFormTree's request):
{
guid?, // omit to create a new node; include (from a prior getForm) to update in place
type, // see the type list below
field?, // entity field NAME the component is bound to (input components only)
settings?: { width_span, label_width, is_visible, is_required, is_readonly, default_value,
placeholder, regex, min_value, max_value, min_length, max_length, scale,
visible_if, require_if, disable_if, col_key, data_type, component_ref,
subform /* target subform's cid, for type: "subform" */ },
lng?: [ { language: <language cid>, label, help_text? }, ... ],
properties?: [ { key, value, value_type }, ... ], // per-type extras, e.g. a select's source
roles?: [ { role: <role cid>, can_read, can_write }, ... ],
children?: [ node, ... ],
}
type∈tab, section, row, col, text, textarea, number, boolean, timestamp, select, entity_component, connection, subform. Containers (tab, section, row, col) holdchildrenand never bind afield; everything else is a leaf/input component and must not carrychildren.propertiescarries per-type extras — aselecttypically suppliessource_kind(e.g."static") with eithersource_values(a newline-separatedcode:labellist) orsource_ref. Aselect/typeaheadmay also carryparent_fieldto become a dependent (cascading) select — see the worked example below.rolesis a deny-list / most-restrictive override, one row per(component, role):can_read=falsehides the component for that role;can_write=falsemakes it read-only for that role; a component with no role rows simply inherits the table's own permission for every role.
Nesting rules, enforced server-side (violations come back as structure_invalid with a
per-node path/rule/message list, and the write is fully rolled back):
- Root
childrenmust be alltabor notab— never mixed. rowmay contain onlycolchildren.- A leaf/input component cannot have
children. - Every container (
tab, section, row, col) needs at least one child.
Error model
Same structured-tool-result pattern as the entity tools, with codes: not_found, validation,
package_mismatch, structure_invalid (nesting violation — inspect
structuredContent.error.message for the per-node list), stale_write (concurrent edit to the
same form), unauthorized (HTTP 403).
Worked example
Create a form for crm_partner, build a tab → section → (text + select) tree with a
component-level role override, then read it back. (Uses $URL and the $H header array from
the "Raw HTTP (any language)" setup below — define those first.)
# 1. createForm
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"createForm","arguments":{
"cid": "crm_partner_edit", "entity": "crm_partner", "display": "Partner"
}}}' "$URL"
# 2. setFormTree — one tab, one section, a bound text field and a select with a source + a role
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"setFormTree","arguments":{
"cid": "crm_partner_edit",
"tree": {"children": [
{"type": "tab", "lng": [{"language": "en", "label": "General"}], "children": [
{"type": "section", "lng": [{"language": "en", "label": "Main"}], "children": [
{"type": "text", "field": "name",
"settings": {"width_span": 6, "is_required": true},
"lng": [{"language": "en", "label": "Name"}]},
{"type": "select", "field": "status",
"settings": {"width_span": 6},
"lng": [{"language": "en", "label": "Status"}],
"properties": [
{"key": "source_kind", "value": "static", "value_type": "string"},
{"key": "source_values", "value": "1:Active\n2:Inactive", "value_type": "string"}
],
"roles": [{"role": "sales_readonly", "can_read": true, "can_write": false}]}
]}
]}
]}
}}}' "$URL"
# 3. getForm — read back the reconciled tree, now with server-assigned guids
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"getForm","arguments":{"cid": "crm_partner_edit"}}}' "$URL"
setFormTree's response already equals what step 3 would return — the explicit getForm call is
shown only to make the round trip obvious. Re-posting the tree from step 3 (edited, with its
guids intact) is exactly how you'd make a further change to the same form.
Dependent (cascading) selects
A select (or typeahead) can filter its options by the value of another field on the same
form — the classic case is a Field picker that lists only the fields of the Entity
chosen above it. Two pieces wire it up:
- A
parent_fieldproperty on the child, naming the sibling component's field name (a key of the record). At runtime the child sends that field's current value — a reference field's stored guid — to its source query asparent_guid. - A
source_refquery that consumes:parent_guid. Use the optional-filter idiom so the same source still works when there's no parent:(:parent_guid::uuid IS NULL OR <fk>.guid = :parent_guid). The::uuidcast on theIS NULLterm is required — a bare:parent_guid IS NULLis type-ambiguous to Postgres.
Runtime behavior: with no parent chosen the child shows no options and issues no query;
picking a parent fetches the filtered set; changing the parent re-fetches and clears a child
value that is no longer valid (a typeahead clears on any parent change). Supported only for
select and typeahead (not bigselect/checkboxlist/radio). It is also editable in the
Studio form builder — the component's Source section has a Parent field input.
# A Mapping section where Entity (parent) drives Field (child).
# syntec_field_select is the built-in source that filters fields by :parent_guid (the entity guid).
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"setFormTree","arguments":{
"cid": "my_mapping_edit",
"tree": {"children": [
{"type": "section", "lng": [{"language": "en", "label": "Mapping"}], "children": [
{"type": "select", "field": "syntec_entity_id",
"settings": {"width_span": 6, "is_required": true},
"lng": [{"language": "en", "label": "Entity"}],
"properties": [
{"key": "source_kind", "value": "sql", "value_type": "string"},
{"key": "source_ref", "value": "syntec_entity_select", "value_type": "string"}
]},
{"type": "select", "field": "syntec_field_id",
"settings": {"width_span": 6, "is_required": true},
"lng": [{"language": "en", "label": "Field"}],
"properties": [
{"key": "source_kind", "value": "sql", "value_type": "string"},
{"key": "source_ref", "value": "syntec_field_select", "value_type": "string"},
{"key": "parent_field", "value": "syntec_entity_id", "value_type": "string"}
]}
]}
]}
}}}' "$URL"
The child's source_ref above (syntec_field_select) is a syntec_sql row shaped like the
following — the :parent_guid clause is what makes the cascade work; every other dependent select
follows the same pattern with its own foreign-key column:
SELECT f.guid, e.table_name || '.' || f.name AS field
FROM syntec_field f JOIN syntec_entity e ON e.id = f.syntec_entity_id
WHERE f.status = :active
AND (:parent_guid::uuid IS NULL OR e.guid = :parent_guid)
AND (:autocompletequery = '' OR f.name ILIKE '%' || :autocompletequery || '%')
ORDER BY e.table_name, f.name
Views server
Endpoint: POST /api/v1/mcp/structure — the same endpoint, same JSON-RPC transport, same
JWT + admin/structure_admin gate, and the same active_package scoping as the entity and form
tools above. Structure writes run elevated, same as forms. There are 17 tools total on this
server: 4 entity, 4 form, 4 view, 5 menu.
It wraps ViewStructureService, which encodes the safe way to author a list/grid view:
getViewreturns the full, round-trippable definition — feed it back intosetView(after local edits) to update the view.createViewonly creates an empty view shell; you build it out with one or moresetViewcalls. View-level settings such aspage_sizesanddefault_sort_field/default_sort_dirare configured viasetView'ssettings, not at create time.setViewis a whole-view declarative write, per collection:columns,actions, androleseach behave likesetFormTree's tree — rows carrying aguidare updated in place, rows without one are created, and any previously-active row omitted from a posted collection is soft-deleted. Unlike the tree insetFormTree, each collection is independent, and a collection key absent entirely from the call (not even an empty array) is left untouched — so you can, e.g., updatesettingsalone without touchingcolumns.
Tools
| Tool | Purpose |
|---|---|
listViews |
{ q? } → views (cid, entity, package_cid, column_count), optional substring filter on cid. |
getView |
{ cid } → { cid, entity, package_cid, settings, lng, columns: [...], actions: [...], roles: [...] }. |
createView |
{ cid, entity, display?, open_form_cid? } → an empty view shell in the active package. |
setView |
{ cid, settings?, lng?, columns?, actions?, roles? } → reconciles the parts of the view supplied; returns the reconciled view (i.e. getView(cid)). |
createView
{
"cid": "crm_partner_list",
"entity": "crm_partner",
"display": "Partners",
"open_form_cid": "crm_partner_edit"
}
cid must match ^[a-z][a-z0-9_]*$ and be unique; entity must be an existing entity (physical
table name). A duplicate cid, a malformed cid, or an unknown entity is rejected with
validation.
settings — view scalars, set via setView: open_form_cid, page_sizes,
default_sort_field, default_sort_dir, default_empty, skip_total, sql, parent_field.
Column node (columns[], used by both getView's response and setView's request):
{
guid?, // omit to create a new column; include (from a prior getView) to update in place
field?, // entity field NAME the column is bound to
col_key?, // needed if the column isn't field-bound (e.g. a computed/action column)
is_visible?, default_hidden?, is_sortable?, is_filter?, is_global_search?, is_primary?,
exclude_from_export?, width?, align?, sorter?, prefix?, suffix?, format?, cell_component?,
data_type?,
lng?: [ { language: <language cid>, label }, ... ],
}
A column needs either field (a real field on the view's entity — unknown field is rejected with
validation) or col_key. When field is set, col_key defaults to the field name.
Action node (actions[]):
{
guid?, action_key, kind?, http_method?, endpoint?, component_key?, needs_selection?, confirm?,
confirm_message?, refresh_after?, clear_selection?, icon?, style?, sorter?,
lng?: [ { language: <language cid>, label, tooltip? }, ... ],
roles?: [ <role cid>, ... ],
}
roles (both view-level setView({ roles }) and action-level actions[].roles) is a list of
role cids — a grant list, unlike form component roles (which are a deny-list with
can_read/can_write): presence of a role cid makes the view/action visible to that role; a
view/action with no role rows at all is unrestricted (visible to everyone).
Error model
Same structured-tool-result pattern as the entity/form tools, with codes: not_found,
validation, package_mismatch, structure_invalid (a setView write violated a structural rule
— inspect structuredContent.error.message), stale_write (concurrent edit to the same view),
unauthorized (HTTP 403), internal_error (unexpected server-side failure; message is redacted).
Worked example
Create a view for crm_partner, add two columns and an action with a role, set sort defaults and
a view-level role, then read it back. (Uses $URL and the $H header array from the "Raw HTTP
(any language)" setup below — define those first.)
# 1. createView
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"createView","arguments":{
"cid": "crm_partner_list", "entity": "crm_partner", "display": "Partners"
}}}' "$URL"
# 2. setView — two columns (one field-bound with a label, one sortable/filterable), one action
# with a role, view-level sort settings, and a view-level role
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"setView","arguments":{
"cid": "crm_partner_list",
"settings": {"default_sort_field": "name", "default_sort_dir": "asc"},
"columns": [
{"field": "name", "is_visible": true, "is_primary": true,
"lng": [{"language": "en", "label": "Name"}]},
{"field": "status", "is_visible": true, "is_sortable": true, "is_filter": true,
"lng": [{"language": "en", "label": "Status"}]}
],
"actions": [
{"action_key": "open", "kind": "navigate", "needs_selection": true,
"lng": [{"language": "en", "label": "Open"}],
"roles": ["admin"]}
],
"roles": ["admin"]
}}}' "$URL"
# 3. getView — read back the reconciled definition, now with server-assigned guids
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"getView","arguments":{"cid": "crm_partner_list"}}}' "$URL"
setView's response already equals what step 3 would return — the explicit getView call is
shown only to make the round trip obvious. Re-posting columns/actions/roles from step 3
(edited, with their guids intact) is exactly how you'd make a further change to the same view;
omitting a collection key entirely leaves that part of the view untouched.
Menu server
Endpoint: POST /api/v1/mcp/structure — the same endpoint, same JSON-RPC transport, same
JWT + admin/structure_admin gate, and the same active_package scoping as the entity/form/view
tools above. Structure writes run elevated, same as forms/views. There are 17 tools total on
this server: 4 entity, 4 form, 4 view, 5 menu.
It wraps MenuStructureService, which encodes the safe way to author the platform's navigation
menu:
- The menu is a single global tree per package — there is no
cid, no list of named menus, and nocreateMenu/setMenuwhole-tree write.getMenureads the whole active-package tree; every write (createMenuItem/updateMenuItem/moveMenuItem/removeMenuItem) targets one node byguid. updateMenuItemonly patches a node's content (node_type,target,icon,lng,roles) — position (parent_guid,sorter) is handled exclusively bymoveMenuItem.removeMenuItemsoft-deletes a node and its entire subtree.
Tools
| Tool | Purpose |
|---|---|
getMenu |
{} → { package_cid, tree: { children: [node, ...] } }, the whole active-package menu tree. |
createMenuItem |
{ parent_guid?, node_type, target?, icon?, sorter?, lng, roles? } → create one node under parent_guid (omit/null = top level); returns the created node. |
updateMenuItem |
{ guid, node_type?, target?, icon?, lng?, roles? } → patch a node's content in place; only the supplied keys change. |
moveMenuItem |
{ guid, parent_guid?, sorter? } → reparent (null parent_guid = top level) and/or reorder a node; rejects making a node its own ancestor (validation). |
removeMenuItem |
{ guid } → soft-delete a node and its entire subtree. |
Node shape (emitted by getMenu, accepted by createMenuItem/updateMenuItem):
{
guid?, // omit to create a new node; include (from a prior getMenu) to update in place
node_type, // folder, route, view, form, external, dashboard
target?, // null for folder; a route/dashboard path, a form CID, or a URL for the others
icon?,
sorter?,
lng: [ { language: <language cid>, label }, ... ],
roles: [ <role cid>, ... ],
children?: [ node, ... ], // folder only, as emitted by getMenu
}
node_type∈folder, route, view, form, external, dashboard. Afolderis a pure container (target: null, holdschildren); the rest are leaf links carrying atarget— a route/dashboard path (e.g./,/views/hook_list), a form cid (fornode_type: "form"), or a URL (forexternal).rolesis a grant list, the same convention as viewroles: presence of a role cid makes the node visible to that role; a node with no role rows at all is unrestricted. This is not the form-component deny-list convention (can_read/can_write) — don't conflate the two.
Error model
Same structured-tool-result pattern as the other structure tools, with codes: not_found,
validation (includes the moveMenuItem own-ancestor cycle guard), package_mismatch (the node
belongs to a package other than active_package — checked by updateMenuItem, moveMenuItem, and
removeMenuItem), structure_invalid, stale_write, unauthorized (HTTP 403), internal_error
(unexpected server-side failure; message is redacted).
Worked example
Read the menu, add a folder with a role grant, add a view link under it, relabel the link, then
read the tree back. (Uses $URL and the $H header array from the "Raw HTTP (any language)"
setup below — define those first.)
# 1. getMenu — read the current tree
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"getMenu","arguments":{}}}' "$URL"
# 2. createMenuItem — a top-level folder, visible only to admin
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"createMenuItem","arguments":{
"node_type": "folder",
"lng": [{"language": "en", "label": "Localization"}],
"roles": ["admin"]
}}}' "$URL"
# → note the returned "guid"; use it as parent_guid below
# 3. createMenuItem — a view link under the folder
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"createMenuItem","arguments":{
"parent_guid": "<folder-guid-from-step-2>",
"node_type": "view",
"target": "/views/language_list",
"lng": [{"language": "en", "label": "Languages"}]
}}}' "$URL"
# → note the returned "guid"; use it as guid below
# 4. updateMenuItem — relabel the link
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":4,"method":"tools/call",
"params":{"name":"updateMenuItem","arguments":{
"guid": "<link-guid-from-step-3>",
"lng": [{"language": "en", "label": "Languages & Locales"}]
}}}' "$URL"
# 5. getMenu — read the tree back
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":5,"method":"tools/call",
"params":{"name":"getMenu","arguments":{}}}' "$URL"
updateMenuItem's response is the single patched node, not the whole tree — the final getMenu
call is how you see the new folder and its child in place. To reorder or reparent a node, use
moveMenuItem instead of updateMenuItem.
Authorizing an agent
The endpoint is JWT-authenticated and gated by the structure_admin role (or admin). Because an
MCP client sends a static bearer header and cannot refresh the 15-minute access token, mint a
long-lived service token:
# default user 'admin', default TTL 1 year; prints the bare token on stdout
docker compose exec app php bin/console syntec:auth:service-token --user=admin --ttl=31536000
Treat the token as a secret. For least privilege, point it at a dedicated user that only holds
structure_admin rather than admin.
Connecting a client
Claude Code
claude mcp add --transport http --scope local syntec-structure \
http://localhost:8080/api/v1/mcp/structure \
--header "Authorization: Bearer <SERVICE_TOKEN>"
claude mcp list # → syntec-structure ... ✔ Connected
MCP servers load at session start, so restart/reopen Claude Code after adding one. --scope local
keeps the token private to your machine (in ~/.claude.json, not committed); use --scope project
to write a shared .mcp.json (do not commit a real token there).
Codex / other MCP clients
MCP HTTP servers are configured with a URL and headers. The shape varies by client; the values are always the same:
# Codex (~/.codex/config.toml) — HTTP MCP server
[mcp_servers.syntec-structure]
url = "http://localhost:8080/api/v1/mcp/structure"
headers = { Authorization = "Bearer <SERVICE_TOKEN>" }
// Generic MCP client config (e.g. an IDE extension)
{
"mcpServers": {
"syntec-structure": {
"type": "http",
"url": "http://localhost:8080/api/v1/mcp/structure",
"headers": { "Authorization": "Bearer <SERVICE_TOKEN>" }
}
}
}
Raw HTTP (any language)
The three-step handshake, if you drive the protocol yourself:
TOKEN=<SERVICE_TOKEN>
URL=http://localhost:8080/api/v1/mcp/structure
H=(-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json')
# 1. initialize
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"me","version":"1"}}}' "$URL"
# 2. initialized notification (no id → HTTP 202, empty body)
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' "$URL"
# 3. call a tool
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"listEntities","arguments":{"q":"crm"}}}' "$URL"
A JSON-RPC notification (no
id, e.g.notifications/initialized) must be answered with an empty202. Sending it is part of the handshake — a client that skips it, or a server that errors on it, fails to connect.
Security notes
- The server only touches catalog/metadata tables (entity DDL and
syntec_form*form structure) — it never writes business records, and it is on the write-flow guardrail allowlist for exactly that reason. - Package scoping means an authorized agent can still only create/alter entities, and
create/rebuild forms, in the current
active_package. - Long-lived tokens are the current mechanism; per-agent API keys with rotation/revocation are a
planned enhancement. Prefer a short TTL and a scoped
structure_admin-only user in shared environments.