generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // ── Users ── model User { id String @id @default(cuid()) email String @unique name String? passwordHash String role Role @default(USER) provider String? externalId String? version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt sessions Session[] auditLogs AuditLog[] ownedProjects Project[] groupMemberships GroupMember[] mcpTokens McpToken[] ownedAgents Agent[] chatThreads ChatThread[] @@index([email]) } enum Role { USER ADMIN } // ── Sessions ── model Session { id String @id @default(cuid()) token String @unique userId String expiresAt DateTime createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([token]) @@index([userId]) @@index([expiresAt]) } // ── MCP Servers ── model McpServer { id String @id @default(cuid()) name String @unique description String @default("") packageName String? runtime String? dockerImage String? transport Transport @default(STDIO) repositoryUrl String? externalUrl String? command Json? containerPort Int? replicas Int @default(1) env Json @default("[]") healthCheck Json? version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt templateName String? templateVersion String? instances McpInstance[] projects ProjectServer[] @@index([name]) } enum Transport { STDIO SSE STREAMABLE_HTTP } // ── MCP Templates ── model McpTemplate { id String @id @default(cuid()) name String @unique version String @default("1.0.0") description String @default("") packageName String? runtime String? dockerImage String? transport Transport @default(STDIO) repositoryUrl String? externalUrl String? command Json? containerPort Int? replicas Int @default(1) env Json @default("[]") healthCheck Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([name]) } // ── Secret Backends ── // // Pluggable storage for Secret.data. Default is `plaintext` (data stored in // Secret.data JSON). Other drivers (e.g. `openbao`) store only a reference in // Secret.externalRef and fetch actual values from the external system at read // time. A `plaintext` row is seeded on first startup so the system always has // a viable backend; additional backends are user-managed via // `mcpctl create secretbackend`. model SecretBackend { id String @id @default(cuid()) name String @unique type String // plaintext | openbao | (future: vault, aws-sm, ...) config Json @default("{}") // type-specific: url, mount, namespace, tokenSecretRef // Runtime metadata for auto-rotating backend credentials (openbao token // auth). Fields: generatedAt, nextRenewalAt, validUntil, lastRotationAt, // lastRotationError, rotatable (true only for wizard-provisioned tokens). // Empty object for backends that don't use rotation (plaintext, kubernetes // auth, or static tokens). Managed entirely by the rotator service. tokenMeta Json @default("{}") isDefault Boolean @default(false) // exactly one row has isDefault=true description String @default("") version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt secrets Secret[] @@index([name]) @@index([isDefault]) } // ── Secrets ── model Secret { id String @id @default(cuid()) name String @unique // FK to SecretBackend. Default empty string lets `prisma db push` add the // column to pre-existing rows without a data-loss reset; `bootstrapSecretBackends` // then points any empty-string values at the seeded `default` plaintext backend // on next mcpd startup. New rows written by SecretService always carry a // valid FK immediately. backendId String @default("") data Json @default("{}") // populated by plaintext backend only externalRef String @default("") // populated by non-plaintext backends (e.g. "mount/path#v3") // Sorted list of the secret's data keys WITHOUT their values. Populated on // every create/update/migrate so list views and describe-without-reveal can // show "this secret has GRAFANA_URL + GRAFANA_TOKEN" without fetching the // backing data. For pre-existing rows the field is empty until the next // write or a lazy resolve in getById fills it in. keyNames Json @default("[]") version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt backend SecretBackend @relation(fields: [backendId], references: [id]) llms Llm[] @@index([name]) @@index([backendId]) } // ── LLMs ── // // Server-managed LLM providers. Clients (agent, HTTP-mode mcplocal) send // OpenAI-format requests to `mcpd /api/v1/llms/:name/infer` — mcpd attaches the // provider API key server-side so credentials never leave the cluster. // Credentials are stored by reference: `apiKeySecret` points at a Secret, and // `apiKeySecretKey` names the key within that secret's data. // // `kind=virtual` rows are *registered by an mcplocal client* (rather than a // human via `mcpctl create llm`). Their inference is relayed back through // the SSE control channel to the publishing mcplocal session. The lifecycle // fields (lastHeartbeatAt, status, inactiveSince) belong to virtual rows; // public rows ignore them. enum LlmKind { public // upstream-URL row, mcpd calls directly virtual // mcplocal-registered, inference relayed via SSE control channel } enum LlmStatus { active // healthy, accepting requests inactive // publisher went away; row pending 4-h GC hibernating // publisher present but backend asleep — wakes on demand (v2) } model Llm { id String @id @default(cuid()) name String @unique type String // anthropic | openai | deepseek | vllm | ollama | gemini-cli model String // e.g. claude-3-5-sonnet-20241022 url String @default("") // endpoint (empty for provider default) tier String @default("fast") // fast | heavy description String @default("") apiKeySecretId String? // FK to Secret apiKeySecretKey String? // key inside the Secret's data extraConfig Json @default("{}") // per-type extras // ── LB pool by name (v4) ── // When set, this Llm is part of a load-balanced pool. Multiple Llms // sharing the same `poolName` stack into a pool that the chat dispatcher // expands at request time and picks an active member from. When NULL, // the effective pool key is the row's own `name` (i.e. "pool of 1", // identical to pre-v4 behavior). Agents pin to a single Llm by id; the // dispatcher transparently widens to the pool when this field is set. poolName String? // ── Virtual-provider lifecycle (NULL/default for kind=public) ── kind LlmKind @default(public) providerSessionId String? // mcplocal session that owns this row when virtual lastHeartbeatAt DateTime? // bumped on every publisher heartbeat status LlmStatus @default(active) inactiveSince DateTime? // when status flipped from active; used for 4-h GC version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt apiKeySecret Secret? @relation(fields: [apiKeySecretId], references: [id], onDelete: SetNull) agents Agent[] @@index([name]) @@index([tier]) @@index([apiKeySecretId]) @@index([kind, status]) @@index([providerSessionId]) @@index([poolName]) } // ── Groups ── model Group { id String @id @default(cuid()) name String @unique description String @default("") version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt members GroupMember[] @@index([name]) } model GroupMember { id String @id @default(cuid()) groupId String userId String createdAt DateTime @default(now()) group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([groupId, userId]) @@index([groupId]) @@index([userId]) } // ── RBAC Definitions ── model RbacDefinition { id String @id @default(cuid()) name String @unique subjects Json @default("[]") roleBindings Json @default("[]") version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([name]) } // ── Projects ── model Project { id String @id @default(cuid()) name String @unique description String @default("") prompt String @default("") proxyModel String @default("") gated Boolean @default(true) llmProvider String? llmModel String? serverOverrides Json? ownerId String version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) servers ProjectServer[] prompts Prompt[] promptRequests PromptRequest[] mcpTokens McpToken[] agents Agent[] @@index([name]) @@index([ownerId]) } model ProjectServer { id String @id @default(cuid()) projectId String serverId String createdAt DateTime @default(now()) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade) @@unique([projectId, serverId]) } // ── MCP Tokens (bearer credentials for HTTP-mode mcplocal) ── // // Raw value format: `mcpctl_pat_<32 base62 chars>`. The raw value is shown // exactly once at create time; only the SHA-256 hash is persisted. Tokens are // scoped to exactly one project — they're only valid at // `/projects//mcp`. Creator's RBAC is the ceiling; the service // rejects bindings that exceed what the creator themselves can do. model McpToken { id String @id @default(cuid()) name String projectId String tokenHash String @unique tokenPrefix String ownerId String description String @default("") createdAt DateTime @default(now()) expiresAt DateTime? lastUsedAt DateTime? revokedAt DateTime? project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) @@unique([name, projectId]) @@index([tokenHash]) @@index([projectId]) @@index([ownerId]) } // ── MCP Instances (running containers) ── model McpInstance { id String @id @default(cuid()) serverId String containerId String? status InstanceStatus @default(STOPPED) port Int? metadata Json @default("{}") healthStatus String? lastHealthCheck DateTime? events Json @default("[]") version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade) @@index([serverId]) @@index([status]) } enum InstanceStatus { STARTING RUNNING STOPPING STOPPED ERROR } // ── Prompts (approved content resources) ── model Prompt { id String @id @default(cuid()) name String content String @db.Text projectId String? agentId String? priority Int @default(5) summary String? @db.Text chapters Json? linkTarget String? version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade) personalities PersonalityPrompt[] @@unique([name, projectId]) @@unique([name, agentId]) @@index([projectId]) @@index([agentId]) } // ── Prompt Requests (pending proposals from LLM sessions) ── model PromptRequest { id String @id @default(cuid()) name String content String @db.Text projectId String? priority Int @default(5) createdBySession String? createdByUserId String? createdAt DateTime @default(now()) project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) @@unique([name, projectId]) @@index([projectId]) @@index([createdBySession]) } // ── Audit Events (pipeline/gate/tool trace from mcplocal) ── model AuditEvent { id String @id @default(cuid()) timestamp DateTime sessionId String projectName String eventKind String source String verified Boolean @default(false) serverName String? correlationId String? parentEventId String? userName String? tokenName String? tokenSha String? payload Json createdAt DateTime @default(now()) @@index([sessionId]) @@index([projectName]) @@index([correlationId]) @@index([timestamp]) @@index([eventKind]) @@index([userName]) @@index([tokenSha]) } // ── Backup Pending Queue ── model BackupPending { id String @id @default(cuid()) resourceKind String resourceName String action String // 'create' | 'update' | 'delete' userName String yamlContent String? @db.Text createdAt DateTime @default(now()) @@index([createdAt]) } // ── Agents (LLM personas pinned to a specific Llm) ── // // Agents combine a system prompt, a pinned LLM, and (optionally) a project to // inherit Prompts from. Each Agent is also exposed by mcplocal as a virtual // MCP server (`agent-/chat`), so other clients can consult it as a tool. // Per-call LiteLLM-style overrides stack on top of `defaultParams`. model Agent { id String @id @default(cuid()) name String @unique description String @default("") // shown in MCP tools/list systemPrompt String @default("") @db.Text // agent persona llmId String projectId String? defaultPersonalityId String? // applied at chat time when no --personality flag proxyModelName String? // optional informational override defaultParams Json @default("{}") // LiteLLM-style: temperature, top_p, top_k, max_tokens, stop, ... extras Json @default("{}") // future LoRA / tool-allowlist // ── Virtual-agent lifecycle (NULL/default for kind=public, mirrors Llm) ── kind LlmKind @default(public) providerSessionId String? // mcplocal session that owns this row when virtual lastHeartbeatAt DateTime? status LlmStatus @default(active) inactiveSince DateTime? ownerId String version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt llm Llm @relation(fields: [llmId], references: [id], onDelete: Restrict) project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) threads ChatThread[] prompts Prompt[] personalities Personality[] @relation("AgentPersonalities") defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull) @@index([name]) @@index([llmId]) @@index([projectId]) @@index([ownerId]) @@index([defaultPersonalityId]) @@index([kind, status]) @@index([providerSessionId]) } // ── Personalities (named overlay bundles of prompts on top of an Agent) ── // // VLAN-on-ethernet semantics: an Agent works without a Personality (today's // flow). Selecting a Personality adds its bound Prompts to the system block // after the Agent's own systemPrompt, agent-direct prompts, and project // prompts — additive, never replacing. model Personality { id String @id @default(cuid()) name String description String @default("") agentId String priority Int @default(5) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt agent Agent @relation("AgentPersonalities", fields: [agentId], references: [id], onDelete: Cascade) prompts PersonalityPrompt[] defaultForAgent Agent[] @relation("AgentDefaultPersonality") @@unique([name, agentId]) @@index([agentId]) } // ── Personality ↔ Prompt join table ── // // A Prompt can be bound to many Personalities; a Personality can bind many // Prompts. The `priority` here overrides the Prompt's own priority *within // this Personality's overlay slice* (so the same prompt can be high-priority // in one personality and low in another). model PersonalityPrompt { id String @id @default(cuid()) personalityId String promptId String priority Int @default(5) createdAt DateTime @default(now()) personality Personality @relation(fields: [personalityId], references: [id], onDelete: Cascade) prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) @@unique([personalityId, promptId]) @@index([personalityId]) @@index([promptId]) } // ── Chat Threads (persisted conversation per Agent) ── model ChatThread { id String @id @default(cuid()) agentId String ownerId String title String @default("") lastTurnAt DateTime @default(now()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) messages ChatMessage[] @@index([agentId, lastTurnAt(sort: Desc)]) @@index([ownerId]) } // ── Chat Messages ── // // `turnIndex` is monotonic per thread; the @@unique enforces ordering even // under racing appends (callers retry on collision). `status` stays `pending` // until the orchestrator confirms the turn completed successfully. model ChatMessage { id String @id @default(cuid()) threadId String turnIndex Int role String // 'system' | 'user' | 'assistant' | 'tool' content String @db.Text toolCalls Json? // assistant turn: [{id,name,arguments}] toolCallId String? // tool turn: which call this answers status String @default("complete") // 'pending' | 'complete' | 'error' createdAt DateTime @default(now()) thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade) @@unique([threadId, turnIndex]) @@index([threadId, createdAt]) } // ── Inference Tasks (v5) ── // // Every inference call (sync infer, agent chat, async POST /inference-tasks) // creates a row here. The DB is the source of truth; mcpd's previous // in-memory `pendingTasks` map is gone — the result-handler updates the row // and an in-process EventEmitter wakes any blocked HTTP handlers (single- // instance for now; multi-instance scaling is a v6 concern that will swap // the emitter for pg LISTEN/NOTIFY without changing the data model). // // Routing: `poolName` is the effective pool key at enqueue time // (`Llm.poolName ?? Llm.name`). Workers (mcplocal sessions) drain pending // rows whose `poolName` matches the pool keys they own when they bind their // SSE channel — that's how queued tasks survive worker offline windows. enum InferenceTaskStatus { pending // in queue, no worker has it yet (or claim was reverted) claimed // a worker has it (SSE frame sent), no chunks back yet running // worker started streaming chunks back (streaming tasks only) completed // worker POSTed the final result error // permanent failure (auth, bad request, queue timeout) cancelled // caller said never mind via DELETE } model InferenceTask { id String @id @default(cuid()) status InferenceTaskStatus @default(pending) // Routing — pool key drives worker matching at claim time. Stored at // enqueue time so a later rename of Llm.poolName doesn't reroute // already-queued work. poolName String llmName String // pinned target Llm name (for audit + agent backref) model String tier String? // Worker tracking. NULL while pending; set on claim; cleared on // unbindSession-driven revert (worker disconnect mid-task). claimedBy String? // Body + result. Both are Json so streaming chunks can be reconstructed // (see TaskService.complete) and async pollers get a structured payload. // requestBody is required (the OpenAI chat-completion request body the // worker should run); responseBody is null until status=completed. requestBody Json responseBody Json? errorMessage String? /** * Whether the original request asked for streaming. Drives the chunk-vs- * final-body protocol on the result POST and tells async API callers * whether `/stream` will yield chunks or just a single completion event. */ streaming Boolean @default(false) // Timestamps for observability + GC: // pending → claimed: claimedAt set // claimed → running: streamStartedAt set (first chunk received) // running/claimed → completed/error/cancelled: completedAt set createdAt DateTime @default(now()) claimedAt DateTime? streamStartedAt DateTime? completedAt DateTime? // Caller tracking — RBAC + observability. ownerId references User.id; // agentId is set when the task came in via /agents//chat (null // for direct /llms//infer or async POST /inference-tasks calls // that don't pin an agent). ownerId String agentId String? @@index([status, poolName]) @@index([claimedBy]) @@index([ownerId]) @@index([agentId]) // GC sweep predicate: completedAt < 7d ago. Index speeds up the daily // cleanup without scanning the whole table. @@index([completedAt]) } // ── Audit Logs ── model AuditLog { id String @id @default(cuid()) userId String action String resource String resourceId String? details Json @default("{}") createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([action]) @@index([resource]) @@index([createdAt]) }