AI agents (MCP)
graal ships an MCP (Model Context Protocol) server: an AI agent connects to it and drives the platform with the rights of the account it carries, not with its own. The server never talks to the database — it translates MCP into graal’s REST API, carrying the caller’s identity.
One clarification that matters before anything else: the platform and the data stay with you, but whatever the tools return to the agent goes through the language model you chose. If that model is hosted by a third party, data leaving is your decision, not a side effect. See Sovereignty and architecture.
The tools
Section titled “The tools”Read tools, write tools, and no deletion tool enabled by default.
| Tool | What it returns |
|---|---|
list_projects |
The tenant’s projects |
list_jobs |
A project’s or the tenant’s jobs (filter by type, pagination) |
get_job |
A job’s full definition |
get_run |
The state of a run — without an id, the job’s most recent one |
get_run_logs |
A run’s log, followed incrementally through a cursor |
list_catalog |
The catalog, layer by layer: layers → databases → tables → fields |
list_bucket_files |
The buckets, or the contents of a path |
get_costs |
The tenant’s or a project’s cost over a period |
| Tool | What it does |
|---|---|
upload_code |
Uploads a code file into the tenant’s libraries |
create_job |
Creates a job in a project (Python, Spark, low-code or bash) |
run_job |
Starts a run, without waiting for its result |
schedule_job |
Sets or replaces a job’s cron schedule |
Why no tool deletes anything
Section titled “Why no tool deletes anything”This is an architectural decision, not an omission, and it is enforced in three places in the code rather than by convention:
- the type of the server’s operations only admits
GET,POSTandPATCH— aDELETEcall does not compile; - all tools are advertised to the protocol with
destructiveHint: false; - removing a schedule, deleting a job or a file stays a human gesture, in the console.
So an agent can create, run and schedule work, and you can always undo it yourself. An agent that gets it wrong creates clutter; it does not destroy.
The two authentication modes
Section titled “The two authentication modes”| Mode | Who holds the secret | Where the tenant comes from | What for |
|---|---|---|---|
| Presented token | nobody: the agent brings its own token, the server relays it | the realm carried by the token | normal use, and the only mode allowed off the local machine |
| graal Application | the server, with the credentials of one “graal Application” per tenant | a header or its own configuration | local machine: demo, troubleshooting |
Four guard rails, in this order:
- A token never leaves its tenant. The token’s issuer (
iss) must be exactly<Keycloak URL>/realms/<tenant realm>, checked before the first API call. - The tenant is never a tool parameter. It comes from the session and travels as a header; an agent cannot switch tenants by changing an argument.
- “graal Application” mode refuses to start anywhere but the local interface (
127.0.0.1,::1,localhost): on an exposed port, anyone reaching it would obtain the agent’s rights.GRAAL_MCP_REQUIRE_BEARER=1disables that mode entirely. - The security boundary is the API’s per-project RBAC, not the MCP server. Restricting an agent
to one project through configuration (
GRAAL_MCP_PROJECT_ID) is a convenience; the real rights are those of the account behind the token.
The trail an agent leaves
Section titled “The trail an agent leaves”Every successful write is attributed and notified:
- the created job carries the labels
graal.systems/created-by: agentandgraal.systems/agent: agent-mcp; - the started run carries the initiator
agent-mcp; - a notification goes to the owner of the project touched, plus any configured recipients. A failed notification does not cancel the action: the result handed back to the agent states explicitly whether it went out.
In other words, “who started this run?” has an answer, carried by the run itself.
The platform’s audit log additionally ties every action to its actor, human or agent (see security and governance).
Connecting an agent
Section titled “Connecting an agent”The server listens over Streamable HTTP on 127.0.0.1:7332 by default: entry point
POST /mcp, liveness GET /healthz (which never touches the API). A stdio transport is also
available, for an agent started as a subprocess.
On the MCP client side, the configuration is a few lines:
{ "mcpServers": { "graal": { "type": "http", "url": "http://127.0.0.1:7332/mcp", "headers": { "X-Graal-Tenant": "my-tenant" } } }}In “presented token” mode, add an Authorization: Bearer <token> header: the tenant is then derived
from the token’s realm and X-Graal-Tenant becomes unnecessary.
To list the tools and the REST operation behind each one without starting anything:
graal-mcp --tools.
Configuration
Section titled “Configuration”| Variable | Role |
|---|---|
GRAAL_API_URL |
API root, including the /api/v1 prefix |
GRAAL_KEYCLOAK_URL |
Keycloak’s public origin, used to compute the expected issuer |
GRAAL_MCP_TENANT, GRAAL_MCP_REALM |
The tenant served and its realm |
GRAAL_MCP_CLIENT_ID, GRAAL_MCP_CLIENT_SECRET |
The tenant’s “graal Application” (Application mode) |
GRAAL_MCP_TENANTS |
The same thing as JSON, to serve several tenants |
GRAAL_MCP_PROJECT_ID |
Restricts the agent to one project (convenience, not isolation) |
GRAAL_MCP_NOTIFY_TO |
Extra notification recipients |
GRAAL_MCP_REQUIRE_BEARER |
At 1, only “presented token” mode is accepted |
GRAAL_MCP_HOST, GRAAL_MCP_PORT |
Listening interface and port (default 127.0.0.1:7332) |
None of these variables holds a user secret: the server stores none, and keeps a token in memory only for as long as it is valid.
Errors, and how an agent should read them
Section titled “Errors, and how an agent should read them”Every error is returned in the same shape, with a stable category an agent can act on:
feature_disabled, unauthorized, forbidden, not_found, api_error, auth_error,
config_error, unknown_tenant, out_of_scope, precondition_failed, transport_error.
The one to know is the first: a feature disabled on the installation answers 501 feature_disabled,
never 404, and the message tells the agent not to retry. A 404 therefore really means “this resource
does not exist”, which makes the two cases distinguishable without guessing.
Further reading
Section titled “Further reading”- Installation — what it takes to run the platform
- Sovereignty and architecture
- Security and governance
- Public page: AI agents
Wiring a client
Section titled “Wiring a client”The server speaks MCP over HTTP. In a compatible client (Claude Code, Claude Desktop, or any other MCP client), the declaration is a few lines:
{ "mcpServers": { "graal": { "type": "http", "url": "https://mcp.example.com/mcp", "headers": { "X-Graal-Tenant": "your-tenant" } } }}Two authentication modes, and the choice decides who acts:
| Mode | How | The tenant comes from | When to use it |
|---|---|---|---|
| Service account | The server carries an application’s credentials, and the client names the tenant with X-Graal-Tenant |
the header | An agent working for a team, on a given tenant |
| Presented token | The client adds "Authorization": "Bearer <token>" to headers |
the token’s realm | An agent acting on behalf of a person, with exactly their rights |
What an agent can do, concretely
Section titled “What an agent can do, concretely”Once wired, the agent has all the tools. A request such as “prepare a job that aggregates regional consumption and schedule it every morning at 6” turns, on its side, into:
list_projects → find the projectupload_code → drop the script into the project's bucketcreate_job → create the job (python, spark, bash or lowcode — nothing else)run_job → start a first executionget_run · get_run_logs → follow it, and read the logs by cursorschedule_job → set the cronEach write pushes a notification to the project owner and lights the « Agent » badge in the console. You do not have to ask the agent what it did: it is written down.
The agent’s guard rails
Section titled “The agent’s guard rails”- No deletion. No deletion tool is enabled, and a test refuses to let one be added: no tool may
back a
DELETEoperation nor be nameddelete_*. - A known job type.
create_jobonly acceptspython,spark,bashandlowcode, and refuses before any network call. - No invented value. If the instance type or the identity cannot be derived from what the platform knows, the tool stops and names the human gesture expected rather than forcing the write.