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 (200okor503not_ready, one boundedSELECT 1) with the running release's commit asrevision; 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_idfrom 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; itsresource_nameisHeyAira – <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 /registerwhen dynamic client registration is enabled;POST /revokewhen 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_callbackis 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 registerClaudewith 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_accessstart unticked and are granted only if the person ticks them. Withoutoffline_accessan 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; requiresdocuments:read.git_document_get— read one bounded UTF-8 fragment from an allowlisted commit-pinned GitHub document; requiresdocuments:read.
Memory¶
memory_add— append a note or decision to the authenticated project; optionalidempotency_keymakes 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; optionalidempotency_keymakes 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 mandatoryexpected_versionconcurrency 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 needagents:writeand 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_catalogis 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:
- server URL: the project's
https://mcp.heyaira.eu/p/<slug>/mcp; - transport: Streamable HTTP;
- authentication: OAuth 2.1 for public clients; internal HTTP Bearer compatibility for Codex/CLI;
- resource identifier: the same per-project URL;
- token placement: protected client configuration, never repository files;
- available tools and schemas;
- the
server_identityandwrite_contextsafety contract; - a synthetic smoke-test sequence;
- limitations of the private pilot;
- 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
heyairaand 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.