Skip to main content

Documentation

Make your app AI-ready

The path from an app.manifest.yaml tool declaration to a callable Claude/ChatGPT MCP tool — ownership, the four permission gates, approvals, and writing tools models use well.

What "AI-ready" means here

Two different audiences reach your app through AI, and both come from the same declaration in your app.manifest.yaml:

  • People, through an AI they already use. Claude and ChatGPT both speak

Model Context Protocol (MCP). A user adds the SynaptaGrid AI connector to their assistant once, and from then on any tool your app exposes over MCP is something they can ask Claude or ChatGPT to use — "create a support ticket for this bug", "draft this week's workflow report" — without opening your product at all.

  • In-product agents. The same tool declaration is also what a

conversational or single-purpose agent running inside the platform (the Data Assistant, platform.builder, a domain agent your own app declares) calls mid-run.

There is one tool contract, declared once. Whether it reaches an external MCP client, an in-product agent, both, or neither is a manifest decision — not a second integration you build. This page is about that decision and how to get it right; AI surface is the full field reference for every spec.ai block.

The path from your manifest to a callable MCP tool

  1. You declare a tool under spec.ai.tools[] — kind: http wrapping one

of your own endpoints is the common case; see Tools for the full field list.

  1. You publish the manifest. The Control Plane validates it and emits

cp.manifest.published.

  1. egav-ai syncs the catalogue. It consumes that event and upserts your

tool as an AiTool row — no redeploy of egav-ai, no registration call you make yourself.

  1. An agent must claim the tool. A tool existing is not the same as a tool

being reachable. Some agent's tool_codes allow-list has to name it — literally, or by a glob like support.* — before anything can call it. That agent can be one your own app's manifest declares, or a platform-owned one such as platform.builder that already holds a broad glob across many apps' codes.

  1. The MCP surface advertises it only if the claiming agent opted in.

expose_to_mcp: true on the agent row is what makes the union of its resolved tools show up in tools/list for an external MCP client. An agent with expose_to_mcp: false (the default) can still call your tool in-product; it simply never appears outside the platform.

So "is my tool on MCP?" always reduces to one question: is there at least one active, non-deleted agent with expose_to_mcp: true whose tool_codes (after glob expansion) include my tool's code? If yes, it is advertised — subject to the withholding rules below. If no, publishing your manifest alone does nothing for MCP.

Which agent a call is attributed to — and why it matters to you

More than one MCP-exposed agent can claim the same tool code — most of the platform's egav.* and automation.* tools are held by the broad platform.builder agent as well as by narrower domain agents. egav-ai has to pick exactly one owner per call, because the owner decides two things that affect the caller directly:

  • Whose grant is checked. A tenant enables an agent, not a tool in

isolation, and a user opts into that agent. The owner is whose organization-level and per-user grant gets evaluated.

  • Whose commit_mode applies. If the owning agent declares

commit_mode: hitl, a write or destructive call stops for a human approval; auto does not.

egav-ai resolves the owner per caller, not once globally: it checks which of the candidate agents the calling org and user have actually enabled, and among those that pass, a hitl agent is preferred over an auto one. That means enabling a second, more permissive assistant for the same tool never lets a call skip an approval a stricter assistant the caller also holds would require — and a caller who has enabled only a narrow, single-purpose agent for a tool is charged to that agent, not to whichever one is broadest.

Practically: if you want a tool of yours to run without a human-in-the-loop approval over MCP, publish and expose your own agent with commit_mode: auto and grant it explicitly — do not rely on it being caught by someone else's hitl agent's broader glob, because when both are enabled for a caller, the hitl one wins.

Four gates stand between an MCP call and your endpoint

Every tools/call runs the same permission preflight the in-process runtime uses — there is no weaker path for external clients. In order, each layer can independently deny:

  1. Org grant — the org admin (or CP operator) has enabled the owning

agent and listed your tool in its allowed_tool_codes.

  1. User grant — the seated user has opted into that agent and (if their

own allow-list is scoped) listed your tool.

  1. Manifest allow-list — the owning agent's tool_codes includes your

tool's code. Enforced up front and re-checked here as defence in depth.

  1. AuthZ floor — the seated user's portal role on your service grants

every code in your tool's requires_permissions. This is the one gate you control directly: declare requires_permissions accurately, and a portal_bearer call can never do more than the seated user could do by hand.

An AuthZ outage fails this closed (503), never open. A missing grant is treated as disabled, never as "not applicable" — so an under-specified grant denies rather than leaking access.

Tools that are never offered, even when an agent claims them

A tool can be technically claimed and still withheld from tools/list. This is deliberate — the model is never advertised a capability it cannot complete — and it applies before your endpoint is ever reached:

  • Tools that target the tool-approval surface itself. Approving or

rejecting a pending action is a decision a human makes in the portal; a tool cannot approve its own or another tool's pending action.

  • Tools that need a chat thread. An MCP session has no portal

conversation behind it, so anything whose contract assumes one (memory scoped to a thread, a reply that continues an existing exchange) is withheld rather than offered and failed.

  • Writes an MCP client cannot get approved. Before MCP clients could

surface a native approval prompt, a write or destructive call behind a hitl agent would create a pending approval nobody could ever see or action, and it would expire silently. egav-ai now withholds those calls from clients that have not negotiated approval support, and completes them for clients that have (see Approvals and retries, below).

None of this is a denylist you maintain. It is recomputed from your tool's declared side_effects, the owning agent's commit_mode, and the operator's approval policy on every listing — so turning a tool from hitl to auto, or an operator loosening the approval policy, makes it appear on the very next call with no manifest change.

Approvals and retries

  • commit_mode: hitl on the owning agent means every call whose

effective side effect is write or destructive stops for a human approval before your endpoint runs. commit_mode: auto runs it immediately. Set hitl on any agent whose writes should be reviewed before they happen — that includes any agent you expose broadly.

  • A client that supports native MCP approvals gets an inline

approve/reject prompt and can retry the same call once a human decides, without your tool doing anything special. Declare approval_retry on the tool only if a bare retry of your endpoint is unsafe to repeat:

approval_retry.modeMeaning
unsafe (default)A retry may re-run the side effect. Fine for reads; risky for anything that creates or charges something twice.
inherentYour endpoint is naturally idempotent (e.g. it upserts by a caller-supplied key already in the arguments) — a retry is safe as-is.
idempotency_keyegav-ai injects a server-owned, stable key derived from the approval itself, either as a request header or a request argument you name (key_location + key_name). Use this when your endpoint honours an idempotency key but the model does not, and should not, supply one.

An idempotency_key field is hidden from the schema the model sees and rejected outright if a caller tries to send it — the key is server-owned, never model-owned.

Writing tools models actually use well

An MCP client hands your tool's title, description and inputSchema straight to its model with no editorial pass of yours in between. Treat them as the only documentation the model gets:

  • Title and description say what it does and to what, not how. "Post a

reply on a support ticket" beats "Calls POST /replies". Name the entity the call acts on.

  • side_effects must be honest, not optimistic. read, write, or

destructive drives both the approval gate and the readOnlyHint / destructiveHint annotations a client may show the user before it calls anything. Marking a delete as write to dodge an approval is a security regression, not a convenience.

  • input_schema_ref should describe every field's purpose, not just its

type — a model fills in a field it does not understand with something plausible-looking rather than leaving it out. Point it at a typed schema (app://<svc>/schemas/<Name>) with field descriptions, not a bare shape.

  • Keep required arguments to what the model can actually know. An

argument only your service can supply (the seated user's org, an injected idempotency key) is never something a caller passes — it is resolved server-side, exactly like auth: portal_bearer already resolves identity from the JWT rather than an argument.

  • Prefer portal_bearer over internal_api_key. A portal_bearer tool

can only ever do what the seated user could already do by hand, because the call carries their JWT into your normal AuthZ check. internal_api_key bypasses user authorization entirely and is reserved for genuinely system-only tools.

  • Cache only idempotent reads. cache.strategy: per_call_args on a

write tool serves a stale result to a second, distinct call with the same arguments — reserve caching for tools whose side_effects is read.

Structured results the model can act on

A tool's result is returned to the calling client as a single text block; for a dict result, that block is JSON. Return a small, flat, well-typed object rather than the raw shape of your database row — every field you include is something the model may quote back to the user or feed into its next call. Declare output_schema_ref so downstream tooling (and, once shipped, the protocol's own structured-output support) can validate what your tool actually returns, the same way input_schema_ref documents what it accepts.

A complete example

app.manifest.yamlyaml
spec:
  ai:
    tools:
      - code: support.reply_to_ticket
        kind: http
        title: Post a reply on a support ticket
        description: >-
          Appends a reply to a support ticket's thread as the seated user.
          The ticket must belong to the caller's organization.
        endpoint:
          method: POST
          path: /v1/egav-support/portal/tickets/{ticket_id}/replies
        auth: portal_bearer
        input_schema_ref: app://egav-support/schemas/TicketReplyInput
        output_schema_ref: app://egav-support/schemas/TicketReplyOutput
        side_effects: write
        cost_hint: low
        requires_permissions:
          - egav-support.ticket.reply
        approval_retry:
          mode: idempotency_key
          key_location: header
          key_name: Idempotency-Key

    agents:
      - code: support.assistant
        name: Support Assistant
        description: Reads and replies to your organization's support tickets.
        kind: assistant
        product: support
        provider_binding: anthropic_default
        model: claude-opus-4-7
        temperature: 0.2
        commit_mode: hitl          # every reply is approved before it posts
        visibility: platform
        system_prompt: |
          You help a support agent read and respond to tickets. Never invent
          a resolution the user did not describe; ask before you assume one.
        tool_codes:
          - support.reply_to_ticket
        expose_to_workflow: false
        expose_to_mcp: true        # this is what puts support.reply_to_ticket
                                   # on the SynaptaGrid AI MCP surface
        gated_by_feature: egav.ai_assistant
        budget:
          max_cost_usd_per_run: 0.10
          max_tokens_per_run: 20000

Publish this and, once an org enables and a user opts into support.assistant, support_reply_to_ticket (dots become underscores in MCP tool names) appears in that user's tools/list from any MCP client connected to the SynaptaGrid AI connector — gated by egav-support.ticket.reply on every call, and stopping for a human approval before it ever posts.

Testing locally before you publish

Point any MCP-Streamable-HTTP client at your local egav-ai instance — @modelcontextprotocol/inspector is the reference client the server itself is built against:

bash
npx @modelcontextprotocol/inspector

Connect it to https://egav-ai-api.your-domain.example:5223/v1/egav-ai/mcp with Authorization: Bearer <a portal JWT for your test org>. Once connected:

  • tools/list shows exactly what an external client would see — with the

same withholding rules applied, so a tool missing from the list here is missing everywhere.

  • tools/call runs your tool through the identical four-gate preflight

production traffic uses; a 403-shaped refusal here means an AuthZ, grant, or manifest allow-list problem, not an MCP-specific one.

  • The server also publishes OAuth 2.1 discovery documents

(/.well-known/oauth-protected-resource, /.well-known/oauth-authorization-server) and dynamic client registration, so a client that supports MCP's authorization flow (Claude Desktop, Cursor) can connect and refresh tokens without you handling the JWT by hand — useful once you are testing the connector experience rather than a single tool.

Walkthrough: one allowed read and one refusal, on a customer-built app

This section follows one tool of a small app built from the scaffold (e2ref.list_examples, a read of the organization's example records) from declaration to an MCP call. It is a recorded run on a local stack with two organizations. It shows what the platform did in that run, not how long setup takes or how a production workspace behaves.

What the app developer implements. One portal-tier route, GET /v1/e2ref/portal/examples, gated by RequireAuthz("e2ref_example", "read"). Every row carries the organization it belongs to, stamped from the caller's token, and the read is scoped to that organization. The route knows nothing about MCP.

What the app developer declares in app.manifest.yaml:

  • the permission e2ref_example.read and the portal roles that hold it

(admin, super_admin, and user for read);

  • the tool e2ref.list_examples with auth: portal_bearer,

side_effects: read and requires_permissions: [e2ref_example.read];

  • the agent e2ref.assistant with tool_codes naming the tool and

expose_to_mcp: true.

What the workspace does. The workspace admin enables the agent for the organization and lists the tool, and each user opts in. Both grants in this run were made through the portal API; the same calls sit behind the agent settings page.

The run. One MCP client (a small script speaking MCP Streamable HTTP, not a product client) signed in as a workspace owner and as a workspace member whose role is user, in two separate organizations, each with its own copy of the app and one row of its own.

CheckResult
DiscoveryThe owner's tools/list named e2ref_list_examples. Nothing named it before the agent was enabled for the organization.
InvocationThe owner's tools/call reached the app's own route and returned the organization's row. The app log shows the same GET /v1/e2ref/portal/examples.
Tenant scopeOwner of organization A received only A's row, and owner of organization B received only B's row.
RefusalThe member's tools/call was refused at the permission check: "your role does not grant e2ref_example.read on the receiving service". The call never reached the app.

The write tool declared next to it was not listed to this client: a write behind a hitl agent is withheld from clients that have not negotiated approval support (see Tools that are never offered).

Two things the run showed that you should plan for.

  • The member whose access comes from a role on one app instance read the app's

own API successfully when the request named that instance, and was still refused through MCP. The permission check an MCP call runs asks about the member's organization-wide role, so a permission granted only through an instance role does not satisfy it today. Give such a member the permission through an organization-wide role, or expect the refusal.

  • An app code the Control Plane does not know has no setting for its outbound

address, so provisioning a tenant instance of a newly registered app needs an operator to record the address first. On the local stack that is two lines in the Control Plane's environment.

Three different ways into the same app. An MCP call acts as the signed-in user, so the app sees that user's organization and role, as in the run above. A script or pipeline calls the same route as a workspace service account, a named non-human member with its own role and key (see Auth). A receiver that wants to be told when something happens subscribes to signed events instead of polling (see Getting changes out). The tool declaration serves the first; the in-product agent and an enabled external MCP client both read that one declaration, so a tool is declared once.

One claim this run supports. An app's own endpoint, declared as a tool, was called through the platform's MCP server by a signed-in user, returned only that user's organization's data, and was refused for a member whose role did not grant the permission. It does not support a claim about how long this takes to build, that approval is never needed, or that other clients behave the same way.

Pre-publish checklist

  • Your tool's title and description say what it does to a reader with

no other context.

  • side_effects reflects what the call actually does, not what would be

convenient for the approval gate.

  • input_schema_ref documents every field's purpose; nothing required is

something only your service can supply.

  • requires_permissions lists every AuthZ code the call needs — this is

the floor a portal_bearer call can never exceed.

  • auth: portal_bearer unless the tool is genuinely system-only.
  • If the call is not safely repeatable, approval_retry is set to

inherent or idempotency_key — not left at the unsafe default.

  • At least one agent's tool_codes names this tool (literally or by

glob), and that agent is published.

  • expose_to_mcp: true is set on that agent only if you want the tool

reachable from Claude, ChatGPT and other external MCP clients — leave it false for an in-product-only tool.

  • You have called tools/list and tools/call locally against the

manifest you are about to publish, as the seated-user identity you expect real callers to have.