Skip to content

Data Model — 001 MCP Gateway

Entities, fields, relationships, and the SQLite schema for the MCP gateway. ORM models follow these names exactly; OpenAPI schemas match the same field names. Migrations are split across three Alembic revisions:

RevisionFileWhat it creates
000120260520_0001_initial.pyresources, audit_log, retention_policies
000220260521_0002_mcp_tables.pymcp_capability_preferences, mcp_invocations
000320260522_0003_mcp_server_health.pymcp_server_health

Domain entities (backend/coffer/domain/)

ResourceRef (domain/resource.py)

Frozen dataclass / Pydantic value object. The external identifier for a Resource.

FieldTypeNotes
kindstrmatches a registered Kind.name, e.g. "mcp_server"
namestrkind-internally unique; matches ^[a-zA-Z0-9_.-]+$; max 64 chars

Behaviour:

  • __str__f"{kind}:{name}"
  • ResourceRef.parse("mcp_server:filesystem")ResourceRef(kind="mcp_server", name="filesystem")
  • parse raises ValueError if input lacks a single : separator or either side is empty
  • Equality and hashing implied by frozen=True

Resource (domain/resource.py)

Plain Python dataclass; not a Pydantic model (domain stays pure).

FieldTypeNotes
idintDB surrogate; internal only, never serialised externally
kindstrmatches Kind.name
namestrkind-internally unique
descriptionstr | Noneoptional free text
configdict[str, Any]kind-specific config, already validated against the kind's config_schema
enabledbooluser-controlled enable/disable flag
created_atdatetimeUTC, set on insert, never updated
updated_atdatetimeUTC, updated on every mutation

Derived: Resource.ref returns ResourceRef(self.kind, self.name).

Kind (domain/resource.py)

Frozen dataclass. Pure descriptor of a resource kind. Domain only — does not hold references to routers, services, or any framework-level adapters.

FieldTypeNotes
namestrunique within process, e.g. "mcp_server"
display_namestrUI label
config_schematype[pydantic.BaseModel]Pydantic schema used to validate Resource.config
on_deleteCallable[[ResourceRef], None] | Noneoptional sync cleanup hook; raise to abort delete

AuditEntry (domain/audit.py)

Plain dataclass.

FieldTypeNotes
idint | NoneDB surrogate, None before insert
timestampdatetimeUTC, default utcnow()
event_typestrone of the enumerated AuditEventType strings (below)
resource_kindstr | Nonenullable; daemon-lifecycle events have no resource
resource_namestr | Nonenullable; daemon-lifecycle events have no resource
actorstr"cli" | "api" | "ui" | "system"
detailsdict[str, Any]JSON-serialisable payload

AuditEventType (domain/audit.py)

String-valued enum (use StrEnum):

ValueWhen emitted
"resource_created"After ResourceService.register
"resource_updated"After config or description change
"resource_enabled"After set_enabled(True) when state flipped
"resource_disabled"After set_enabled(False) when state flipped
"resource_deleted"After delete (includes pre-delete snapshot in details)
"capability_first_seen"When discovery sees a capability for the first time
"capability_enabled"When a user enables a capability that was disabled
"capability_disabled"When a user disables a capability
"daemon_started"At daemon startup post-ready
"daemon_stopped"At daemon graceful shutdown
"token_rotated"After POST /api/v1/daemon/rotate-token
"retention_updated"When a retention policy is changed
"backup_created"After POST /api/v1/vault/backup
"credential_set"After POST /api/v1/credentials stores a secret
"credential_read"After GET /api/v1/credentials/{ref} reads a secret
"credential_deleted"After DELETE /api/v1/credentials/{ref} removes a secret
"credential_migrated"Per ref, when a legacy keychain secret migrates into the store
"master_key_relocated"After PUT /api/v1/settings/credentials moves the master key
"keychain_set" / "keychain_read" / "keychain_deleted"Legacy (pre-envelope-encryption); kept renderable for historical rows

RetentionPolicy (domain/retention.py)

Plain dataclass.

FieldTypeNotes
table_namestrPK; must match a registered PrunableTable.name
retention_daysint | NoneNone = keep forever; >0 = days; 0 forbidden
last_pruned_atdatetime | Nonelast successful prune
last_pruned_rowsintrows deleted in last prune
updated_atdatetimelast policy mutation

PrunableTable (infrastructure/persistence/retention.py)

This is the registry entry used at composition root — declared here for completeness even though the type lives in infrastructure/ (it parameterises SQL execution).

FieldTypeNotes
namestrDB table name; must appear in the SQL allowlist set
timestamp_columnstrcolumn name to compare against the cutoff; must appear in the column allowlist
default_retention_daysint | Noneseeded into retention_policies on first daemon boot
display_namestrUI label
descriptionstrUI tooltip

MCP kind value objects (backend/coffer/domain/mcp/)

StdioTransport (domain/mcp/server_config.py)

Pydantic BaseModel. Discriminator value: "stdio".

FieldTypeNotes
typeLiteral["stdio"]discriminator
commandstrexecutable, e.g. "npx"
argslist[str]default []
envdict[str, str]static env, never contains secrets; rejected if a value looks like a token (regex check)
credential_refsdict[str, str]maps env_var_name → ref into the encrypted credential store; resolved (decrypted) at spawn
cwdstr | Noneoptional working directory

HttpTransport (domain/mcp/server_config.py)

Pydantic BaseModel. Discriminator value: "http".

FieldTypeNotes
typeLiteral["http"]discriminator
urlpydantic.HttpUrlupstream MCP HTTP/SSE endpoint
headersdict[str, str]static headers; same secret regex as env
credential_refsdict[str, str]maps header_name → ref into the encrypted credential store

MCPServerConfig (domain/mcp/server_config.py)

Pydantic BaseModel — this is what Resource.config holds for an mcp_server.

FieldTypeNotes
transportAnnotated[StdioTransport | HttpTransport, Field(discriminator="type")]tagged union
auto_enable_new_capabilitiesbooldefault True
spawn_timeout_secondsintdefault 30; range 5–120
request_timeout_secondsintdefault 120; range 5–1800; reset on progress
idle_timeout_secondsintdefault 600; range 60–86400; subprocess GC after this idle period

MCPTool / MCPResource / MCPPrompt (domain/mcp/capability.py)

Pydantic BaseModels. Live representations returned by upstream queries; never persisted (per ADR-004).

MCPTool:

FieldType
namestr (original, no prefix)
descriptionstr | None
input_schemadict[str, Any] (JSON schema)

MCPResource:

FieldType
uristr (original)
namestr | None
descriptionstr | None
mime_typestr | None

MCPPrompt:

FieldType
namestr (original)
descriptionstr | None
argumentslist[MCPPromptArgument]

MCPCapabilityPreference (domain/mcp/capability.py)

FieldTypeNotes
idint | NoneDB surrogate
resource_idintFK to resources.id
capability_typeLiteral["tool", "resource", "prompt"]
capability_keystroriginal (unprefixed) name; for resources, the original URI
enabledbooldefault depends on server's auto_enable_new_capabilities
first_seen_atdatetime
last_seen_atdatetimeupdated every successful discovery

MCPInvocation (domain/mcp/capability.py)

FieldTypeNotes
idint | NoneDB surrogate
timestampdatetime
resource_namestrMCP server name
capability_typeLiteral["tool", "resource", "prompt"]
capability_keystroriginal (unprefixed) name
duration_msintwall-clock milliseconds
statusLiteral["ok", "error", "timeout", "denied"]
error_messagestr | Nonepopulated when status != "ok"
session_idstr | Noneper-session correlation

Never store args or results — schema cannot hold them.

SQLite schema (across three Alembic revisions)

Tables are created across three migrations; the schema below represents the combined result after all three revisions are applied.

sql
-- Resources: kind-agnostic core
CREATE TABLE resources (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    kind          TEXT      NOT NULL,
    name          TEXT      NOT NULL,
    description   TEXT,
    config_json   TEXT      NOT NULL,                       -- validated JSON
    enabled       BOOLEAN   NOT NULL DEFAULT 1,
    created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE (kind, name)
);
CREATE INDEX idx_resources_kind_enabled ON resources(kind, enabled);

-- Audit log: kind-agnostic
CREATE TABLE audit_log (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    timestamp       TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    event_type      TEXT      NOT NULL,
    resource_kind   TEXT,                                   -- nullable
    resource_name   TEXT,
    actor           TEXT      NOT NULL,
    details_json    TEXT                                    -- nullable JSON payload
);
CREATE INDEX idx_audit_resource  ON audit_log(resource_kind, resource_name, timestamp DESC);
CREATE INDEX idx_audit_time      ON audit_log(timestamp DESC);
CREATE INDEX idx_audit_eventtype ON audit_log(event_type, timestamp DESC);

-- Retention policy: kind-agnostic
CREATE TABLE retention_policies (
    table_name        TEXT PRIMARY KEY,
    retention_days    INTEGER,                              -- NULL = forever; >0 = days
    last_pruned_at    TIMESTAMP,
    last_pruned_rows  INTEGER NOT NULL DEFAULT 0,
    updated_at        TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CHECK (retention_days IS NULL OR retention_days > 0)
);

-- MCP-specific: user's capability preferences
CREATE TABLE mcp_capability_preferences (
    id               INTEGER PRIMARY KEY AUTOINCREMENT,
    resource_id      INTEGER NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
    capability_type  TEXT    NOT NULL,                      -- 'tool' | 'resource' | 'prompt'
    capability_key   TEXT    NOT NULL,
    enabled          BOOLEAN NOT NULL DEFAULT 1,
    first_seen_at    TIMESTAMP NOT NULL,
    last_seen_at     TIMESTAMP NOT NULL,
    UNIQUE (resource_id, capability_type, capability_key)
);
CREATE INDEX idx_prefs_resource ON mcp_capability_preferences(resource_id, capability_type, enabled);

-- MCP-specific: invocation log
CREATE TABLE mcp_invocations (
    id               INTEGER PRIMARY KEY AUTOINCREMENT,
    timestamp        TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    resource_name    TEXT      NOT NULL,
    capability_type  TEXT      NOT NULL,
    capability_key   TEXT      NOT NULL,
    duration_ms      INTEGER   NOT NULL,
    status           TEXT      NOT NULL,                    -- 'ok' | 'error' | 'timeout' | 'denied'
    error_message    TEXT,
    session_id       TEXT
);
CREATE INDEX idx_invocations_resource ON mcp_invocations(resource_name, timestamp DESC);
CREATE INDEX idx_invocations_time     ON mcp_invocations(timestamp DESC);
CREATE INDEX idx_invocations_session  ON mcp_invocations(session_id, timestamp);

-- MCP-specific: persisted upstream health (revision 0003)
CREATE TABLE mcp_server_health (
    resource_name  TEXT      PRIMARY KEY,
    status         TEXT      NOT NULL,                    -- 'healthy' | 'failing' | 'unknown'
    checked_at     TIMESTAMP NOT NULL
);

SQLAlchemy mapping (summary)

ORM models live under backend/coffer/infrastructure/persistence/models.py (kind-agnostic) and backend/coffer/infrastructure/mcp/persistence.py (MCP-specific), all registered against the same Base.metadata:

ORM classTableLives in
ResourceModelresourcesinfrastructure/persistence/models.py
AuditLogModelaudit_loginfrastructure/persistence/models.py
RetentionPolicyModelretention_policiesinfrastructure/persistence/models.py
MCPCapabilityPreferenceModelmcp_capability_preferencesinfrastructure/mcp/persistence.py
MCPInvocationModelmcp_invocationsinfrastructure/mcp/persistence.py
McpServerHealthModelmcp_server_healthinfrastructure/mcp/persistence.py
Each ORM model provides:
  • to_domain() -> <DomainEntity> for conversion outward
  • A module-level from_domain(entity) -> <Model> helper for inward conversion

Cascade and integrity rules

ActionEffect
DELETE FROM resources WHERE id=?cascades to mcp_capability_preferences (via FK). Does not cascade to audit_log or mcp_invocations (history preserved).
UPDATE resources SET kind=?forbidden — application layer never updates kind.
UPDATE resources SET name=?forbidden — rename = delete + register.
DELETE FROM retention_policiesforbidden — policies are upserted at startup, never deleted.

Default retention policy seed (run on first daemon startup)

These defaults are seeded at the composition root (in surfaces/http/app.py, when RetentionService.initialize_defaults() is invoked at daemon startup) — not in Alembic migrations. Migrations create the retention_policies table but leave it empty; the daemon upserts the per-table defaults at boot so that new prunable tables introduced by later specs can register their own defaults without requiring a new migration.

python
defaults = [
    ("audit_log",         365),
    ("mcp_invocations",    30),
]
for table_name, days in defaults:
    if not exists(table_name):
        upsert(table_name=table_name, retention_days=days, updated_at=utcnow())

API authentication

Every route under /api/v1/* requires the X-Coffer-Token header. The only intentional exception is:

EndpointWhy unauthenticated
GET /api/v1/daemon/statusUsed by the CLI and the coffer-mcp-shim as a cheap readiness probe before any token has been read from ~/.coffer/daemon.json. Returns only lifecycle phase, version, port, started-at, and an aggregate upstream summary — no secrets, no per-resource details, no audit data.

All mutating endpoints (including /vault/backup, /daemon/rotate-token, /daemon/shutdown) require the token. The /mcp JSON-RPC surface also requires the token. Clients SHOULD set the optional X-Coffer-Actor header (cli | api | ui | system) so audit entries carry the originating surface; absent header defaults to "api".

Invariants enforced by importlinter

These re-state existing tool.importlinter.contracts in backend/pyproject.toml, plus two contracts to be added (Contract 5, Contract 6) as part of this spec's setup phase:

ContractSubject
1surfaces → application → domain layered direction
2infrastructure does not import surfaces
3domain is pure (no infra/surfaces/sdks)
4keyring confined to infrastructure
5cross-kind imports forbidden: coffer.{domain,application,infrastructure,surfaces}.mcp.* does not import coffer.{domain,application,infrastructure,surfaces}.<other_kind>.* (vacuously true with one kind, but the contract is in place from day one so it bites the first time the second kind lands)
6kind-agnostic core does not import kind-specific code: coffer.application.resource_service does not import coffer.domain.mcp or coffer.application.mcp

Both new contracts are added in Task T-0010 (see tasks.md).