Skip to content

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_package config); you never pass a package. Altering an entity owned by a different package is refused (package_mismatch).
  • createEntity does DDL and metadata indexing (fields, grants, labels) in one call.
  • alterEntity is non-destructive: it never drops or renames, and never adds NOT NULL to 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:

  • getForm returns the full, round-trippable edit tree — feed it back into setFormTree (after local edits) to update the form.
  • createForm only creates an empty form shell; you build it out with one or more setFormTree calls.
  • setFormTree is a whole-tree declarative write: nodes carrying a guid are 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 returns structure_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, ... ],
}
  • typetab, section, row, col, text, textarea, number, boolean, timestamp, select, entity_component, connection, subform. Containers (tab, section, row, col) hold children and never bind a field; everything else is a leaf/input component and must not carry children.
  • properties carries per-type extras — a select typically supplies source_kind (e.g. "static") with either source_values (a newline-separated code:label list) or source_ref. A select/typeahead may also carry parent_field to become a dependent (cascading) select — see the worked example below.
  • roles is a deny-list / most-restrictive override, one row per (component, role): can_read=false hides the component for that role; can_write=false makes 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 children must be all tab or no tab — never mixed.
  • row may contain only col children.
  • 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:

  1. A parent_field property 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 as parent_guid.
  2. A source_ref query 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 ::uuid cast on the IS NULL term is required — a bare :parent_guid IS NULL is 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:

  • getView returns the full, round-trippable definition — feed it back into setView (after local edits) to update the view.
  • createView only creates an empty view shell; you build it out with one or more setView calls. View-level settings such as page_sizes and default_sort_field/default_sort_dir are configured via setView's settings, not at create time.
  • setView is a whole-view declarative write, per collection: columns, actions, and roles each behave like setFormTree's tree — rows carrying a guid are updated in place, rows without one are created, and any previously-active row omitted from a posted collection is soft-deleted. Unlike the tree in setFormTree, 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., update settings alone without touching columns.

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.

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 no createMenu/setMenu whole-tree write. getMenu reads the whole active-package tree; every write (createMenuItem/updateMenuItem/moveMenuItem/removeMenuItem) targets one node by guid.
  • updateMenuItem only patches a node's content (node_type, target, icon, lng, roles) — position (parent_guid, sorter) is handled exclusively by moveMenuItem.
  • removeMenuItem soft-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_typefolder, route, view, form, external, dashboard. A folder is a pure container (target: null, holds children); the rest are leaf links carrying a target — a route/dashboard path (e.g. /, /views/hook_list), a form cid (for node_type: "form"), or a URL (for external).
  • roles is a grant list, the same convention as view roles: 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 empty 202. 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.