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. 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 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]) } // ── 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 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]) } // ── 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]) } // ── 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]) }