Skip to content

Public MCP Service

Intended public endpoint

The first deployable HeyAira service is a real remote MCP server, not only a local development process.

Public URL, one per project:

https://mcp.heyaira.eu/p/<project-slug>/mcp

The mcp subdomain keeps the service boundary separate from a future product website at heyaira.eu.

Public surface

Every project has its own MCP address, /p/<slug>/mcp (for example https://mcp.heyaira.eu/p/voyana/mcp). The instance also keeps the deprecated shared /mcp, which still issues new OAuth grants (see the endpoint reference). Both serve the same protocol:

  • POST /p/<slug>/mcp, POST /mcp — MCP Streamable HTTP requests;
  • GET — protocol session/event behavior as required by the official SDK;
  • DELETE — session termination when supported by the transport.

An address no project has answers exactly like one a project has (401 with the same challenge, the same metadata), so an unauthenticated caller cannot tell which slugs exist; no token is accepted there and consent refuses it as "Wrong project". Only a malformed slug returns 404. Per-project paths were chosen over per-project hosts (<slug>.mcp.heyaira.eu): they need no wildcard DNS or certificate, the existing edge and reverse proxy already forward every path, and RFC 9728 path-based protected-resource metadata is what MCP clients (claude.ai, ChatGPT, Codex) discover from the WWW-Authenticate challenge. Each address is a separate OAuth resource, so a client keyed by URL can hold one connector per project.

Operational and human-facing endpoints are separate from MCP:

  • GET /healthz — liveness and database readiness (200 ok or 503 not_ready, one bounded SELECT 1) with the running release's commit as revision; no secrets or database contents;
  • GET /docs — human-readable setup and tool documentation;
  • GET / — short service identity page linking to /docs.

MCP tools are not separate REST endpoints. Clients discover them through the MCP protocol using tools/list and call them through the project's /p/<slug>/mcp.

Authenticated clients should call server_identity before any mutation. It returns the configured resource, logical instance, environment, service revision, and authenticated project without exposing tokens, DSNs, or internal hostnames.

Authentication

The public registration flow uses MCP-standard OAuth 2.1 Authorization Code with PKCE. The authorization server must publish protected-resource metadata and authorization-server metadata, accept the resource parameter, validate exact redirect URIs and issue access tokens audience-bound to the HeyAira MCP resource.

The existing project-scoped static Bearer token remains available for internal Codex and CLI compatibility:

Authorization: Bearer <project-token>

The server must:

  • require a valid OAuth access token or approved internal Bearer credential for every MCP request;
  • map the credential to exactly one HeyAira project and permitted scopes;
  • reject missing, malformed, expired/revoked, wrong-audience, or unknown tokens;
  • never accept project_id from model input as an authorization decision;
  • avoid logging the header or token value;
  • return a generic unauthorized response without revealing whether a token or project exists.

OAuth is implemented as a standards-based adapter around the same project authorization boundary. GitHub tokens are never accepted as HeyAira MCP tokens and are never passed through to downstream services.

The metadata and authorization endpoints are:

  • GET /.well-known/oauth-protected-resource/p/<slug>/mcp (per project; its resource_name is HeyAira – <slug>: public metadata never names a project);
  • GET /.well-known/oauth-protected-resource/mcp (deprecated shared resource; it still accepts new grants);
  • GET /.well-known/oauth-authorization-server;
  • GET /authorize;
  • POST /token;
  • POST /register when dynamic client registration is enabled;
  • POST /revoke when token revocation is enabled.

/register and /authorize need no credential, so what they store is bounded: a registration body is at most 16 KiB, its stored metadata at most 8 KiB, with at most 5 redirect URIs of up to 2000 characters and a client_name of up to 200. A dynamically registered client that never receives a code or token is deleted 24 hours after registration, and at most 500 may wait unused at once (/register then answers "try again later"; connected clients are unaffected). A consent link expires after ten minutes and is deleted afterwards; a client may hold at most 20 unexpired links, whether pending, used or denied (2000 in total), and state is at most 1024 characters.

The authorization page is an MVP HeyAira consent boundary at /oauth/consent. It accepts an existing project access token only to select the project and derive the identity's role. The token is not returned to the OAuth client. Only the static project token is accepted there; an OAuth access token is refused, so a connected client cannot approve a new grant for itself. The token must also be an active member of the project: a token whose membership was removed or revoked is refused, not treated as a viewer.

The role is checked again every time a credential is used, not only at consent. A static project token or an OAuth access token carries only the scopes its subject's current role allows (a viewer reads, it does not write); a token whose membership is revoked or absent is not accepted at all (HTTP 401). A refresh issues only the intersection of the original grant and the current role, so a demotion is not undone by refreshing, and promoting the token again does not widen a narrowed grant: the client asks again. memory_add, task_create and task_update repeat the role check inside the write's own transaction.

Dynamic client registration is open, so a client's name is only what it chose for itself. The consent page names a client by where the authorization code goes, and always shows that destination:

  • verified: a hosted connector recognised by its exact callback (https://claude.ai/api/mcp/auth_callback is Claude, https://chatgpt.com/connector/oauth/… is ChatGPT), judged by the callback this authorization actually uses, and only when every callback the client registered is that connector's; a registration that mixes a connector callback with any other gets no connector treatment at all (it is unverified and keeps secret authentication at /token). The registration must also carry the connector's exact name (Claude, ChatGPT), the rule /token uses to treat it as a public client; under any other name the shared callback is unverified, shown under the name the client chose. This is destination recognition, not proof of who registered the client: registration is open, so anyone can register Claude with claude.ai's real callback and get the same label. What it does establish is that the code is delivered only to that connector's host. The page therefore names the host and says to allow only a connection the person started from their own account, because a link someone else started would connect that person's Claude or ChatGPT account to the project. It receives what it asked for.
  • local: the code stays on the person's device (a loopback http:// callback such as Codex or Claude Code, or the HeyAira app's own callback), and only when every callback the client registered is of that same kind; a loopback or app callback registered next to any other is unverified. It receives what it asked for.
  • unverified: any other callback. The page says so, quotes the name the client gave itself and shows the destination host. Read scopes start ticked; every write, Bridge and catalog scope and offline_access start unticked and are granted only if the person ticks them. Without offline_access an unverified client gets no refresh token.

Whatever the class, the role still caps the grant. A consent link is used at most once: a second Allow, even one sent at the same moment, gets "Link expired" and no second code. The page and its redirect are sent with Cache-Control: no-store, Referrer-Policy: no-referrer and frame-ancestors 'none' / X-Frame-Options: DENY. User and organization sign-in, GitHub App installation, and repository API operations are separate follow-up integrations.

Tool catalogue

Every registered tool, with the scopes it needs, is listed in the tool catalogue; GET /docs on the server lists the same tools with their arguments. The groups:

Identity and project

  • server_identity — the connection's resource, instance, environment, revision and authenticated project;
  • project_update — set the project's description;
  • project_repositories — list the project's active repository bindings.

Startup recovery

  • continuity_context — return a bounded read-only snapshot of project identity, recent memory, open tasks, and recent work-session receipts.
  • runtime_bootstrap_get — return pinned, allowlisted project document references for continuity bootstrap; requires documents:read.
  • git_document_get — read one bounded UTF-8 fragment from an allowlisted commit-pinned GitHub document; requires documents:read.

Memory

  • memory_add — append a note or decision to the authenticated project; optional idempotency_key makes safe retries return the original result;
  • memory_get — retrieve one memory entry by ID within the authenticated project;
  • memory_search — search active project memory, or list recent active entries when no query is supplied.

Tasks

  • task_create — create a project task; optional idempotency_key makes safe retries return the original result;
  • task_get — retrieve one task by ID within the authenticated project;
  • task_list — list project tasks with a bounded result set;
  • task_update — update a task using mandatory expected_version concurrency control.

Agent catalog

The account-scoped catalog is separate from project continuity state:

  • agent_list / agent_get — read versioned agent profiles containing the provider, model, reasoning level, soul reference, role reference and pinned skills;
  • role_list / role_get — read role responsibilities and the heartbeat procedure reference assigned to that role;
  • skill_list / skill_get — read reusable, versioned skill definitions;
  • agent_put / role_put / skill_put — append an immutable version; these need agents:write and an account catalog editor grant (see catalog provisioning).

The read tools require agents:read. An agent is not owned by a project; the authenticated project only determines the account whose catalog is visible. Harnesses, execution sessions, tasks, work receipts and provider credentials remain outside the agent definition.

Mutations also require write_context containing the expected resource, instance_id, and project_id. The server compares these values with trusted configuration and the authenticated project before changing state. Missing or mismatched context is rejected without changing domain records and produces a redacted audit event. Clients must stop after task_get returns task not found; they must not recreate, migrate, or search other projects.

Agent work

  • task_execution_configure — set a task's repository, agent, verification and time limit;
  • workflow_send — send a configured task version to an online Bridge; workflow_dispatch_catalog is the lower-level form with explicit pins;
  • workflow_get — read workflow state and phase history;
  • workflow_control — cancel a workflow or reconcile an expired execution;
  • task_result — append a RESULT note and receipt; only the execution's own lease-bound token can call it;
  • github_ci_status — read recent GitHub Actions results.

Work state

Work-session receipts are part of the durable state model. The initial implementation may expose them through the smallest possible additional operation or attach them to task updates, but they must record the logical worker/thread and Git checkpoint needed for a code handoff.

Receipt identity is unique per (task_id, session_ref). Task updates remain atomic with their receipt insert and require expected_version; a stale retry is rejected without changing the task or adding a receipt.

See the continuity protocol for startup recovery, persisted event guidance, and duplicate-prevention rules.

Every tool description must explain its purpose, required parameters, project boundary, result shape, and failure behavior. Examples on /docs use synthetic data only.

Client registration information

The public documentation must provide:

  1. server URL: the project's https://mcp.heyaira.eu/p/<slug>/mcp;
  2. transport: Streamable HTTP;
  3. authentication: OAuth 2.1 for public clients; internal HTTP Bearer compatibility for Codex/CLI;
  4. resource identifier: the same per-project URL;
  5. token placement: protected client configuration, never repository files;
  6. available tools and schemas;
  7. the server_identity and write_context safety contract;
  8. a synthetic smoke-test sequence;
  9. limitations of the private pilot;
  10. support/contact and version information when those are decided.

The documentation must not contain a real token, database password, private key, memory entry, or production repository content.

Deployment boundary

  • PostgreSQL is reachable only inside the private container network.
  • The MCP service listens on a controlled internal or loopback port.
  • TLS terminates at an approved HTTPS edge or reverse proxy for mcp.heyaira.eu.
  • All application containers and volumes are owned by heyaira and run rootless.
  • Voyana users, containers, volumes, databases, and credentials are out of scope.
  • Cloudflare DNS/proxy/tunnel configuration is a separate infrastructure step and must be explicitly verified before public registration.

Synthetic smoke test

After creating a project token, run:

.venv/bin/python scripts/smoke_mcp.py \
  --url https://mcp.heyaira.eu/p/<slug>/mcp \
  --token-file /secure/local/path/heyaira-token

The default smoke test checks /healthz, rejects an unauthenticated MCP request, discovers every current tool (the list in contracts/mcp-tools.json), writes and reads memory, creates and updates a task, records a work-session receipt, and rejects a stale task version. F1 document tools are discovered but are not exercised by that state-only smoke test. The opt-in tests/test_f1_mcp_e2e.py test covers the full isolated runtime_bootstrap_get -> git_document_get path against PostgreSQL with a synthetic provider; it does not claim public GitHub or production deployment.

Public acceptance

The public infrastructure portion is complete only when an external MCP client can:

  • reach the domain over HTTPS;
  • complete MCP initialization and tool discovery;
  • call a memory write and a memory read with a valid project token;
  • receive a safe failure without a token;
  • confirm that PostgreSQL is not publicly reachable;
  • read the human documentation and reproduce the synthetic smoke test.