Skip to content

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.

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

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, POST and PATCH — a DELETE call 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.

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:

  1. 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.
  2. 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.
  3. “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=1 disables that mode entirely.
  4. 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.

Every successful write is attributed and notified:

  • the created job carries the labels graal.systems/created-by: agent and graal.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).

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.

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.

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.

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

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 project
upload_code → drop the script into the project's bucket
create_job → create the job (python, spark, bash or lowcode — nothing else)
run_job → start a first execution
get_run · get_run_logs → follow it, and read the logs by cursor
schedule_job → set the cron

Each 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.

  • No deletion. No deletion tool is enabled, and a test refuses to let one be added: no tool may back a DELETE operation nor be named delete_*.
  • A known job type. create_job only accepts python, spark, bash and lowcode, 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.