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
- You declare a tool under
spec.ai.tools[]—kind: httpwrapping one
of your own endpoints is the common case; see Tools for the full field list.
- You publish the manifest. The Control Plane validates it and emits
cp.manifest.published.
- 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.
- 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.
- 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_modeapplies. 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:
- Org grant — the org admin (or CP operator) has enabled the owning
agent and listed your tool in its allowed_tool_codes.
- User grant — the seated user has opted into that agent and (if their
own allow-list is scoped) listed your tool.
- Manifest allow-list — the owning agent's
tool_codesincludes your
tool's code. Enforced up front and re-checked here as defence in depth.
- 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: hitlon 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.mode | Meaning | |
|---|---|---|
unsafe (default) | A retry may re-run the side effect. Fine for reads; risky for anything that creates or charges something twice. | |
inherent | Your endpoint is naturally idempotent (e.g. it upserts by a caller-supplied key already in the arguments) — a retry is safe as-is. | |
idempotency_key | egav-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_effectsmust 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_refshould 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_beareroverinternal_api_key. Aportal_bearertool
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_argson 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
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: 20000Publish 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:
npx @modelcontextprotocol/inspectorConnect 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/listshows 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/callruns 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.readand the portal roles that hold it
(admin, super_admin, and user for read);
- the tool
e2ref.list_exampleswithauth: portal_bearer,
side_effects: read and requires_permissions: [e2ref_example.read];
- the agent
e2ref.assistantwithtool_codesnaming 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.
| Check | Result |
|---|---|
| Discovery | The owner's tools/list named e2ref_list_examples. Nothing named it before the agent was enabled for the organization. |
| Invocation | The 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 scope | Owner of organization A received only A's row, and owner of organization B received only B's row. |
| Refusal | The 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
titleanddescriptionsay what it does to a reader with
no other context.
side_effectsreflects what the call actually does, not what would be
convenient for the approval gate.
input_schema_refdocuments every field's purpose; nothing required is
something only your service can supply.
requires_permissionslists every AuthZ code the call needs — this is
the floor a portal_bearer call can never exceed.
auth: portal_bearerunless the tool is genuinely system-only.- If the call is not safely repeatable,
approval_retryis set to
inherent or idempotency_key — not left at the unsafe default.
- At least one agent's
tool_codesnames this tool (literally or by
glob), and that agent is published.
expose_to_mcp: trueis 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/listandtools/calllocally against the
manifest you are about to publish, as the seated-user identity you expect real callers to have.