Skip to content

ADR-0003: One project = one address

  • Status: accepted
  • Date: 2026-09-26
  • Task: 7d466a59-9d1c-419d-bdbd-385972c0b7bd

Context

The MCP resource was one URL for the whole instance, https://mcp.heyaira.eu/mcp. Clients that key a connector by URL (claude.ai refuses a second connector with the same URL in one organization; ChatGPT behaves similarly) can hold only one grant per URL, so a person working on two projects could not connect both in the same client. The project was also invisible at connection time: nothing in the address or the consent step said which project was being connected.

Decision

  1. Every project has a stable, instance-unique slug and its own MCP resource, https://<host>/p/<slug>/mcp, with its own RFC 9728 protected-resource metadata at /.well-known/oauth-protected-resource/p/<slug>/mcp (resource_name: HeyAira – <Project name>). Connectors are named HeyAira – <Project name>.
  2. Per-project paths, not per-project hosts. Paths need no wildcard DNS or certificates, the existing edge and reverse proxy already forward every path, and MCP clients discover path-based metadata from the WWW-Authenticate challenge. The authorization server (issuer, /authorize, /token, /register) stays single and instance-wide.
  3. A grant is bound to the address it was issued for. On an MCP request an OAuth token is accepted only on its stored resource, and only while that address still belongs to the token's project. A static project token is accepted on the legacy /mcp and on its own project's address, never on another project's. Consent names the project from the address and refuses a project access token of any other project.
  4. write_context.resource is the address in use; server_identity returns it as identity.resource, together with the project's name, slug, description and mcp_url.
  5. The shared /mcp stays for grants already issued for it (deprecated). The Bridge and iOS app keep using it.
  6. Projects gain a free-text description, set by a project owner or admin through the project_update tool, so every session sees what the project is about. Name and slug remain operator settings (heyaira-admin).

Implementation

The MCP SDK checks one resource_server_url. HeyAira keeps a single MCP app and adds ProjectResourceRouter as the outermost ASGI middleware: it resolves /p/<slug>/mcp to the project, forwards to the shared handler, records the address in a request context variable and rewrites the metadata URL in the WWW-Authenticate challenge. OAuthProvider.load_access_token enforces the binding against that address, so the SDK's own single-resource check is disabled (validate_token_resource=False). Migration 024 adds projects.slug and projects.description.

Consequences

  • One client can hold one connector per project; the URL and the consent page show the project.
  • A slug is part of every grant's binding. Changing it (--replace-slug) invalidates grants for the old address; clients must reconnect.
  • The router must stay the outermost layer: without it MCP requests would not carry an address and the binding check would not apply.

Amendment 2026-10-05: no slug enumeration

Audit 2026-10-05 (P3) found that an unauthenticated caller could list slugs and read project names: an address no project had answered 404, the metadata and the consent page named the project. Now every well-formed address answers the same before a project token is checked: the router and the metadata no longer look the slug up, resource_name is HeyAira – <slug>, /authorize accepts any well-formed address of this instance, and the consent page shows the address, not the name. With a project token, an address no project has is refused like another project's address ("Wrong project"), and no code is issued. Connectors are still named HeyAira – <Project name> by the person adding them (the getting-started listing values).

The same review clarified what the consent page says about the client. A hosted connector (Claude, ChatGPT) is recognised by its destination: its exact callback host, every registered callback being that connector's, and its exact registration name. That is not proof of ownership. Registration is open, so anyone can register that name with the real callback and get the same label. The page says only where access goes and asks the person to allow only a connection they started from their own account. The host match stays strict (_known_connector).