First step of the virtual-LLM feature. A virtual Llm row is one that gets *registered by an mcplocal client* rather than created via \`mcpctl create llm\`. Its inference is relayed back through an SSE control channel to the publishing session (mcpd routes added in Stage 3). The lifecycle fields below let mcpd reap stale rows when the publisher goes away. Schema additions: - enum LlmKind (public | virtual). Default public. - enum LlmStatus (active | inactive | hibernating). Default active. hibernating is reserved for v2 wake-on-demand. - Llm.kind, providerSessionId, lastHeartbeatAt, status, inactiveSince. - @@index([kind, status]) for the GC sweep. - @@index([providerSessionId]) for the reconnect lookup. All existing rows backfill with kind=public/status=active so v1 is purely additive — public LLMs ignore the lifecycle columns entirely. 7 new prisma-level assertions in tests/llm-virtual-schema.test.ts cover: defaults, persisting kind=virtual + lifecycle together, the active→inactive flip, hibernating value, enum rejection, the (kind,status) GC index, the providerSessionId reconnect index. mcpd suite still 801/801 (regenerated client) and typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
608 lines
19 KiB
Plaintext
608 lines
19 KiB
Plaintext
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
|
|
// ── 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])
|
|
}
|
|
|
|
// ── 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/<that-project>/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-<name>/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])
|
|
}
|