Browse Source

feat: MCP 凭据——多个服务共用请求头 / 环境变量(服务引用而不是复制,修改后按新值重新连接);可限定适用地址(前缀匹配,添加服务时自动选择,服务端强制);已有服务可一键保存为凭据

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 17 hours ago
parent
commit
67e21e403a

+ 5 - 1
apps/admin/src/lib/api.ts

@@ -1,7 +1,7 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 import { AgentClient, BotClient } from '@agent-server/client'
 import type {
-  ApiKeyDto, ApiKeyProfiles, BotDto, BotInput, BotKeyDto, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, ServerDirListDto, SkillApproveInput, SkillDeviceLoginDto, SkillPublishKeyDto, SkillPublishKeyInput, SkillSubmissionDiffDto, SkillSubmissionItemDetailDto, SkillSubmissionItemDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolHints, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
+  ApiKeyDto, ApiKeyProfiles, BotDto, BotInput, BotKeyDto, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, ServerDirListDto, SkillApproveInput, SkillDeviceLoginDto, SkillPublishKeyDto, SkillPublishKeyInput, SkillSubmissionDiffDto, SkillSubmissionItemDetailDto, SkillSubmissionItemDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto, McpCredentialDto, McpCredentialInput, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolHints, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
   ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto, TeamCaseDto, TeamCaseRunDto, TeamDto, ScheduleOverviewDto, ScheduleRunDto, ScheduleDto, BackgroundCallDto, BackgroundCallOverviewDto,
 } from '@agent-server/shared'
 
@@ -139,6 +139,10 @@ export const admin = {
     setToolHints: (id: string, tool: string, hints: McpToolHints) => req<McpServerDto>('PUT', `/mcp/servers/${id}/tools/${encodeURIComponent(tool)}/hints`, hints),
     setToolBackground: (id: string, tool: string, background: boolean) => req<McpServerDto>('PUT', `/mcp/servers/${id}/tools/${encodeURIComponent(tool)}/background`, { background }),
     test: (input: McpServerInput, id: string | null) => req<McpTestResultDto>('POST', '/mcp/test', { input, id }),
+    createCredential: (input: McpCredentialInput) => req<McpCredentialDto>('POST', '/mcp/credentials', input),
+    updateCredential: (id: string, input: McpCredentialInput) => req<McpCredentialDto>('PUT', `/mcp/credentials/${id}`, input),
+    removeCredential: (id: string) => req<void>('DELETE', `/mcp/credentials/${id}`),
+    saveAsCredential: (serverId: string, input: { name: string; urlPrefixes: string[] }) => req<McpCredentialDto>('POST', `/mcp/servers/${serverId}/save-credential`, input),
     oauthStart: (id: string) => req<{ authorizationUrl: string | null }>('POST', `/mcp/servers/${id}/oauth/start`),
     oauthRevoke: (id: string) => req<void>('DELETE', `/mcp/servers/${id}/oauth`),
   },

+ 227 - 4
apps/admin/src/views/McpView.vue

@@ -1,7 +1,8 @@
 <script setup lang="ts">
-// MCP 服务:内置工具(MCP Gateway 自带)与第三方服务(远程 HTTP / 本地进程,经网关转接,ADR-0021)
-import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
-import type { McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolDto, McpToolHints, McpToolPolicy } from '@agent-server/shared'
+// MCP 服务:内置工具(MCP Gateway 自带)与第三方服务(远程 HTTP / 本地进程,经网关转接,ADR-0021);
+// 凭据:多个服务共用的请求头或环境变量,按适用地址自动选择(docs/design/20-mcp-credentials.md)
+import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
+import type { McpCredentialDto, McpCredentialInput, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolDto, McpToolHints, McpToolPolicy } from '@agent-server/shared'
 import PageHeader from '../components/PageHeader.vue'
 import { admin, errorMessage } from '../lib/api'
 import { formatTime, toast } from '../lib/ui'
@@ -116,7 +117,7 @@ const saving = ref(false)
 
 const blank = (): McpServerInput => ({
   name: '', title: '', description: '', transport: 'http', enabled: true, url: '', headers: [], oauth: { enabled: false, clientId: '', clientSecret: null, scope: '' }, command: '', args: [], env: [],
-  allowNetwork: false, readPaths: [], writePaths: [],
+  allowNetwork: false, readPaths: [], writePaths: [], credentialId: null,
 })
 function toInput(s: McpServerDto): McpServerInput {
   return {
@@ -124,12 +125,16 @@ function toInput(s: McpServerDto): McpServerInput {
     headers: s.headers.map(h => ({ name: h.name, value: null, isSet: h.isSet })), oauth: { ...(s.oauth ?? { enabled: false, clientId: '', scope: '' }), clientSecret: null },
     command: s.command, args: [...s.args],
     env: s.env.map(e => ({ name: e.name, value: null, isSet: e.isSet })), allowNetwork: s.allowNetwork, readPaths: [...s.readPaths], writePaths: [...s.writePaths],
+    credentialId: s.credentialId ?? null,
   }
 }
 function openEditor(s?: McpServerDto) {
   const input = s ? toInput(s) : blank()
   draft.value = { id: s?.id ?? null, input, argsText: input.args.join('\n'), readText: input.readPaths.join('\n'), writeText: input.writePaths.join('\n') }
   testResult.value = null
+  credTouched.value = false
+  autoPicked.value = null
+  saveCred.value = null
 }
 const lines = (t: string) => t.split('\n').map(x => x.trim()).filter(Boolean)
 /** 参数每行一个(保留行内空格,只去掉空行) */
@@ -171,6 +176,124 @@ async function save() {
   }
 }
 
+// ---- 凭据 ----
+/** 地址是否在前缀之下(与服务端一致):协议、主机、端口相同,路径按段匹配 */
+function matchesPrefix(url: string, prefix: string): boolean {
+  try {
+    const u = new URL(url)
+    const p = new URL(prefix)
+    if (u.origin !== p.origin) return false
+    const pp = p.pathname
+    return pp === '/' || u.pathname === pp || u.pathname.startsWith(pp.endsWith('/') ? pp : `${pp}/`)
+  } catch {
+    return false
+  }
+}
+const allows = (c: McpCredentialDto, url: string) => !c.urlPrefixes.length || c.urlPrefixes.some(p => matchesPrefix(url, p))
+const credentialOf = (id: string | null | undefined) => state.value?.credentials.find(c => c.id === id) ?? null
+
+/** 服务编辑中可选的凭据:类型一致;http 服务标出地址是否在适用地址内 */
+const credOptions = computed(() => {
+  const d = draft.value
+  if (!d || !state.value) return []
+  return state.value.credentials.filter(c => c.kind === d.input.transport).map(c => ({ c, ok: d.input.transport === 'stdio' || allows(c, d.input.url.trim()) }))
+})
+const credTouched = ref(false)
+const autoPicked = ref<string | null>(null)
+// 新建服务时按地址自动选择:正好有一个限定了适用地址的凭据匹配时选中它;手动选择过后不再改动
+watch(() => [draft.value?.input.url, draft.value?.input.transport], () => {
+  const d = draft.value
+  if (!d || d.id || credTouched.value || !state.value) return
+  const url = d.input.url.trim()
+  const hits = d.input.transport === 'http' ? state.value.credentials.filter(c => c.kind === 'http' && c.urlPrefixes.length && allows(c, url)) : []
+  const cur = credentialOf(d.input.credentialId)
+  if (hits.length === 1) {
+    d.input.credentialId = hits[0]!.id
+    autoPicked.value = hits[0]!.name
+  } else if (cur && (cur.kind !== d.input.transport || !allows(cur, url))) {
+    d.input.credentialId = null
+    autoPicked.value = null
+  }
+})
+function pickCredential(id: string) {
+  credTouched.value = true
+  autoPicked.value = null
+  draft.value!.input.credentialId = id || null
+}
+
+// 凭据的新建与编辑
+interface CredDraft { id: string | null; input: McpCredentialInput; prefixText: string; inUse: boolean }
+const credDraft = ref<CredDraft | null>(null)
+const credSaving = ref(false)
+function openCredEditor(c?: McpCredentialDto) {
+  credDraft.value = c
+    ? { id: c.id, input: { name: c.name, description: c.description, kind: c.kind, values: c.values.map(v => ({ name: v.name, value: null, isSet: v.isSet })), urlPrefixes: [...c.urlPrefixes] }, prefixText: c.urlPrefixes.join('\n'), inUse: c.usedBy.length > 0 }
+    : { id: null, input: { name: '', description: '', kind: 'http', values: [{ name: 'Authorization', value: '' }], urlPrefixes: [] }, prefixText: '', inUse: false }
+}
+async function saveCredential() {
+  const d = credDraft.value!
+  credSaving.value = true
+  try {
+    const input: McpCredentialInput = {
+      ...d.input,
+      values: d.input.values.filter(v => v.name.trim()).map(v => ({ name: v.name.trim(), value: v.value === '' ? null : v.value })),
+      urlPrefixes: d.input.kind === 'http' ? lines(d.prefixText) : [],
+    }
+    const changed = input.values.some(v => v.value !== null)
+    const c = d.id ? await admin.mcp.updateCredential(d.id, input) : await admin.mcp.createCredential(input)
+    toast(d.id && changed && c.usedBy.length ? `已保存,${c.usedBy.map(s => s.title).join('、')} 将用新值重新连接` : '已保存', 'success')
+    credDraft.value = null
+    await load()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    credSaving.value = false
+  }
+}
+async function removeCredential(c: McpCredentialDto) {
+  if (!window.confirm(`删除凭据「${c.name}」?`)) return
+  await act(`cred:${c.id}`, async () => {
+    await admin.mcp.removeCredential(c.id)
+    await load()
+  }, '已删除')
+}
+
+// 编辑已有服务时,把它自己的请求头 / 环境变量保存为凭据(使用已保存的值)
+const saveCred = ref<{ name: string; prefixText: string } | null>(null)
+const savedServer = computed(() => (draft.value?.id ? state.value?.servers.find(x => x.id === draft.value!.id) ?? null : null))
+const canSaveAsCredential = computed(() => {
+  const s = savedServer.value
+  return !!s && !s.credentialId && !draft.value!.input.credentialId && s.transport === draft.value!.input.transport && (s.transport === 'http' ? s.headers : s.env).length > 0
+})
+function openSaveCred() {
+  const s = savedServer.value!
+  let prefix = ''
+  try {
+    prefix = s.transport === 'http' ? new URL(s.url).origin : ''
+  } catch {
+    // 地址无效时留空
+  }
+  saveCred.value = { name: `${s.title} 凭据`, prefixText: prefix }
+}
+async function doSaveCred() {
+  const d = draft.value!
+  const sc = saveCred.value!
+  credSaving.value = true
+  try {
+    const c = await admin.mcp.saveAsCredential(d.id!, { name: sc.name.trim(), urlPrefixes: lines(sc.prefixText) })
+    await load()
+    d.input.credentialId = c.id
+    if (d.input.transport === 'http') d.input.headers = []
+    else d.input.env = []
+    saveCred.value = null
+    toast(`已保存为凭据「${c.name}」,这个服务改为使用它`, 'success')
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    credSaving.value = false
+  }
+}
+
 onMounted(() => {
   void load()
   window.addEventListener('message', onOAuthMessage)
@@ -182,6 +305,7 @@ onBeforeUnmount(() => window.removeEventListener('message', onOAuthMessage))
   <div class="h-full flex flex-col">
     <PageHeader title="MCP 服务" subtitle="两个引擎通过 AgentServer 的 MCP Gateway 使用这些工具">
       <button type="button" class="btn-secondary" @click="load"><span class="codicon codicon-refresh" />刷新</button>
+      <button type="button" class="btn-secondary" @click="openCredEditor()"><span class="codicon codicon-key" />添加凭据</button>
       <button type="button" class="btn-primary" @click="openEditor()"><span class="codicon codicon-add" />添加服务</button>
     </PageHeader>
     <div v-if="state" class="flex-1 overflow-y-auto">
@@ -240,6 +364,7 @@ onBeforeUnmount(() => window.removeEventListener('message', onOAuthMessage))
             <div class="text-[12px] font-mono text-fg-muted break-all">
               <template v-if="s.transport === 'http'">{{ s.url }}<template v-if="s.headers.length"> · 请求头 {{ s.headers.map(h => h.name).join('、') }}</template></template>
               <template v-else>{{ [s.command, ...s.args].join(' ') }}<template v-if="s.env.length"> · 环境变量 {{ s.env.map(e => e.name).join('、') }}</template> · {{ s.allowNetwork ? '可联网' : '不能联网' }}</template>
+              <template v-if="s.credentialId"> · 凭据 <span class="text-fg">{{ credentialOf(s.credentialId)?.name ?? '(已删除)' }}</span></template>
             </div>
             <div class="hint">
               {{ s.profileNames.length ? `使用它的 Profile:${s.profileNames.join('、')}` : '还没有 Profile 勾选这个服务' }}
@@ -291,6 +416,30 @@ onBeforeUnmount(() => window.removeEventListener('message', onOAuthMessage))
             </div>
           </div>
         </section>
+
+        <!-- 凭据 -->
+        <section class="flex flex-col gap-3">
+          <h2 class="section-title m-0">凭据</h2>
+          <div v-if="!state.credentials.length" class="card p-4 hint">
+            同一个后端的多个服务要用同一个令牌时,把它保存为凭据,添加服务时选择即可;更换令牌只需修改凭据。凭据可以限定适用地址,添加服务时按地址自动选择。
+          </div>
+          <div v-for="c in state.credentials" :key="c.id" class="card p-4 flex flex-col gap-1.5 text-[12.5px]">
+            <div class="flex items-center gap-2 flex-wrap">
+              <span class="codicon codicon-key text-fg-muted" />
+              <span class="font-600">{{ c.name }}</span>
+              <span class="badge bg-code">{{ c.kind === 'http' ? '请求头' : '环境变量' }}</span>
+              <span class="flex-1" />
+              <button type="button" class="btn-ghost" @click="openCredEditor(c)">编辑</button>
+              <button type="button" class="icon-btn" title="删除" :disabled="!!busy || c.usedBy.length > 0" @click="removeCredential(c)"><span class="codicon codicon-trash" /></button>
+            </div>
+            <div v-if="c.description" class="hint">{{ c.description }}</div>
+            <div class="font-mono text-[12px] text-fg-muted break-all">
+              {{ c.values.map(v => v.name).join('、') }}
+              <template v-if="c.kind === 'http'"> · 适用地址 {{ c.urlPrefixes.length ? c.urlPrefixes.join('、') : '任意' }}</template>
+            </div>
+            <div class="hint">{{ c.usedBy.length ? `使用它的服务:${c.usedBy.map(s => s.title).join('、')}` : '还没有服务使用' }} · 更新于 {{ formatTime(c.updatedAt) }}</div>
+          </div>
+        </section>
       </div>
     </div>
 
@@ -391,6 +540,37 @@ onBeforeUnmount(() => window.removeEventListener('message', onOAuthMessage))
           <div class="hint">系统目录(/usr、/etc)只读可见;不能开放服务数据目录、会话工作区和全局禁止项。</div>
         </template>
 
+        <div class="flex flex-col gap-1.5">
+          <label class="label m-0" for="m-cred">凭据({{ draft.input.transport === 'http' ? '补充请求头' : '补充环境变量' }};与上面填写的合并,同名以上面的为准)</label>
+          <select id="m-cred" class="field" :value="draft.input.credentialId ?? ''" @change="pickCredential(($event.target as HTMLSelectElement).value)">
+            <option value="">不使用凭据</option>
+            <option v-for="o in credOptions" :key="o.c.id" :value="o.c.id" :disabled="!o.ok">
+              {{ o.c.name }}({{ o.c.values.map(v => v.name).join('、') }}{{ o.c.kind === 'http' ? ` · ${o.c.urlPrefixes.length ? o.c.urlPrefixes.join('、') : '任意地址'}` : '' }}){{ o.ok ? '' : ' — 地址不在适用范围内' }}
+            </option>
+          </select>
+          <div v-if="autoPicked && draft.input.credentialId" class="hint">已按服务地址自动选择「{{ autoPicked }}」</div>
+          <div v-else-if="draft.input.credentialId && !credentialOf(draft.input.credentialId)?.urlPrefixes.length && draft.input.transport === 'http'" class="text-warning text-[12px]">这个凭据没有限定适用地址,请确认服务地址可信</div>
+          <div v-if="!credOptions.length" class="hint">还没有{{ draft.input.transport === 'http' ? '请求头' : '环境变量' }}类凭据,可以在页面右上角"添加凭据"。</div>
+          <template v-if="canSaveAsCredential">
+            <button v-if="!saveCred" type="button" class="btn-ghost self-start" @click="openSaveCred"><span class="codicon codicon-key" />把这个服务的{{ draft.input.transport === 'http' ? '请求头' : '环境变量' }}保存为凭据</button>
+            <div v-else class="rounded-md border border-solid border-border p-2.5 flex flex-col gap-2">
+              <div class="hint">使用已保存的值(不包括本次未保存的修改);保存后这个服务改为使用新凭据,其他服务也可以选择它。</div>
+              <div>
+                <label class="label" for="m-sc-name">凭据名称</label>
+                <input id="m-sc-name" v-model="saveCred.name" class="field">
+              </div>
+              <div v-if="draft.input.transport === 'http'">
+                <label class="label" for="m-sc-prefix">适用地址(每行一个,前缀匹配;留空表示任意地址)</label>
+                <textarea id="m-sc-prefix" v-model="saveCred.prefixText" class="field-area h-14 font-mono" />
+              </div>
+              <div class="flex gap-2">
+                <button type="button" class="btn-primary" :disabled="credSaving || !saveCred.name.trim()" @click="doSaveCred">保存为凭据</button>
+                <button type="button" class="btn-secondary" @click="saveCred = null">取消</button>
+              </div>
+            </div>
+          </template>
+        </div>
+
         <div v-if="testResult" class="rounded-md p-2.5 flex flex-col gap-1" :class="testResult.ok ? 'bg-[color-mix(in_srgb,var(--app-success)_12%,transparent)]' : 'bg-[color-mix(in_srgb,var(--app-danger)_12%,transparent)]'">
           <div class="font-600">{{ testResult.ok ? `连接成功(${testResult.ms} ms),共 ${testResult.tools.length} 个工具` : '连接失败' }}</div>
           <div v-if="testResult.error" class="whitespace-pre-wrap break-all text-error">{{ testResult.error }}</div>
@@ -409,5 +589,48 @@ onBeforeUnmount(() => window.removeEventListener('message', onOAuthMessage))
         </div>
       </form>
     </div>
+
+    <!-- 凭据:新建 / 编辑 -->
+    <div v-if="credDraft" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="credDraft = null">
+      <form class="w-[min(620px,94vw)] max-h-[90vh] overflow-y-auto card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]" @submit.prevent="saveCredential">
+        <div class="flex items-center gap-2">
+          <span class="font-600 text-[14px] flex-1">{{ credDraft.id ? `编辑凭据「${credDraft.input.name}」` : '添加凭据' }}</span>
+          <button type="button" class="icon-btn" title="关闭" @click="credDraft = null"><span class="codicon codicon-close" /></button>
+        </div>
+        <div class="flex gap-4">
+          <label class="flex items-center gap-1.5" :class="credDraft.inUse ? 'opacity-60' : 'cursor-pointer'"><input v-model="credDraft.input.kind" type="radio" value="http" :disabled="credDraft.inUse">请求头(远程 HTTP 服务)</label>
+          <label class="flex items-center gap-1.5" :class="credDraft.inUse ? 'opacity-60' : 'cursor-pointer'"><input v-model="credDraft.input.kind" type="radio" value="stdio" :disabled="credDraft.inUse">环境变量(本地进程)</label>
+        </div>
+        <div class="grid grid-cols-2 gap-3">
+          <div>
+            <label class="label" for="c-name">名称</label>
+            <input id="c-name" v-model="credDraft.input.name" class="field" placeholder="例如:业务后台令牌" required>
+          </div>
+          <div>
+            <label class="label" for="c-desc">说明(可选)</label>
+            <input id="c-desc" v-model="credDraft.input.description" class="field">
+          </div>
+        </div>
+        <div>
+          <div class="label">{{ credDraft.input.kind === 'http' ? '请求头' : '环境变量' }}</div>
+          <div v-for="(v, i) in credDraft.input.values" :key="i" class="flex gap-2 mb-1.5">
+            <input v-model="v.name" class="field w-48 font-mono" placeholder="名称" :aria-label="`第 ${i + 1} 项名称`">
+            <input v-model="v.value" type="password" autocomplete="new-password" class="field font-mono" :placeholder="v.isSet ? '已设置,留空保持不变' : '值'" :aria-label="`第 ${i + 1} 项的值`">
+            <button type="button" class="icon-btn" title="移除" @click="credDraft.input.values.splice(i, 1)"><span class="codicon codicon-close" /></button>
+          </div>
+          <button type="button" class="btn-ghost" @click="credDraft.input.values.push({ name: '', value: '' })"><span class="codicon codicon-add" />添加一项</button>
+        </div>
+        <div v-if="credDraft.input.kind === 'http'">
+          <label class="label" for="c-prefix">适用地址(可选,每行一个)</label>
+          <textarea id="c-prefix" v-model="credDraft.prefixText" class="field-area h-16 font-mono" placeholder="https://api.example.com&#10;http://127.0.0.1:8091/mcp/" />
+          <div class="hint mt-1">前缀匹配:协议、主机、端口必须相同,路径按段匹配(<code>https://a.com/mcp</code> 包括 <code>/mcp/orders</code>,不包括 <code>/mcp2</code>)。添加服务时按地址自动选择;地址不匹配的服务不能使用。留空表示可用于任意地址。</div>
+        </div>
+        <div v-if="credDraft.inUse" class="hint">正在被使用,不能改变类型。修改值后,使用它的服务会用新值重新连接。</div>
+        <div class="flex justify-end gap-2">
+          <button type="button" class="btn-secondary" @click="credDraft = null">取消</button>
+          <button type="submit" class="btn-primary" :disabled="credSaving">{{ credSaving ? '保存中…' : '保存' }}</button>
+        </div>
+      </form>
+    </div>
   </div>
 </template>

+ 13 - 0
apps/server/drizzle/0022_mcp_credentials.sql

@@ -0,0 +1,13 @@
+CREATE TABLE `mcp_credentials` (
+	`id` text PRIMARY KEY NOT NULL,
+	`name` text NOT NULL,
+	`description` text DEFAULT '' NOT NULL,
+	`kind` text NOT NULL,
+	`values` text DEFAULT '{}' NOT NULL,
+	`url_prefixes` text DEFAULT '[]' NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `mcp_credentials_name_unique` ON `mcp_credentials` (`name`);--> statement-breakpoint
+ALTER TABLE `mcp_servers` ADD `credential_id` text;

+ 3161 - 0
apps/server/drizzle/meta/0022_snapshot.json

@@ -0,0 +1,3161 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "7173820d-1aa5-4a43-aea7-45d04c844233",
+  "prevId": "0dad397c-3f8a-4806-b33e-36066a92e03e",
+  "tables": {
+    "admin_sessions": {
+      "name": "admin_sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "api_keys": {
+      "name": "api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_profile_ids": {
+          "name": "allowed_profile_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "default_profile_id": {
+          "name": "default_profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "api_keys_key_hash_unique": {
+          "name": "api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "audit_logs": {
+      "name": "audit_logs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "actor": {
+          "name": "actor",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "detail": {
+          "name": "detail",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "audit_ts_idx": {
+          "name": "audit_ts_idx",
+          "columns": [
+            "ts"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "background_calls": {
+      "name": "background_calls",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "server_id": {
+          "name": "server_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "server_name": {
+          "name": "server_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "server_title": {
+          "name": "server_title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "tool": {
+          "name": "tool",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "args": {
+          "name": "args",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'running'"
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "delivered": {
+          "name": "delivered",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "mode": {
+          "name": "mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'wait'"
+        },
+        "mcp_task_id": {
+          "name": "mcp_task_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "conn_scope": {
+          "name": "conn_scope",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "poll_after": {
+          "name": "poll_after",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "poll_error_since": {
+          "name": "poll_error_since",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status_message": {
+          "name": "status_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "background_calls_session_idx": {
+          "name": "background_calls_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        },
+        "background_calls_team_idx": {
+          "name": "background_calls_team_idx",
+          "columns": [
+            "team_id"
+          ],
+          "isUnique": false
+        },
+        "background_calls_status_idx": {
+          "name": "background_calls_status_idx",
+          "columns": [
+            "status",
+            "delivered"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bot_api_keys": {
+      "name": "bot_api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_bot_ids": {
+          "name": "allowed_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "bot_api_keys_key_hash_unique": {
+          "name": "bot_api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bots": {
+      "name": "bots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "avatar": {
+          "name": "avatar",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'🤖'"
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "bots_name_unique": {
+          "name": "bots_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skill_versions": {
+      "name": "custom_skill_versions",
+      "columns": {
+        "skill_id": {
+          "name": "skill_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reason": {
+          "name": "reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "changed": {
+          "name": "changed",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "note": {
+          "name": "note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "custom_skill_versions_skill_id_version_pk": {
+          "columns": [
+            "skill_id",
+            "version"
+          ],
+          "name": "custom_skill_versions_skill_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skills": {
+      "name": "custom_skills",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "include_in_all": {
+          "name": "include_in_all",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "published_version": {
+          "name": "published_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "latest_version": {
+          "name": "latest_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "working_files": {
+          "name": "working_files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "custom_skills_name_unique": {
+          "name": "custom_skills_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "events": {
+      "name": "events",
+      "columns": {
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "events_session_id_seq_pk": {
+          "columns": [
+            "session_id",
+            "seq"
+          ],
+          "name": "events_session_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "files": {
+      "name": "files",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "rel_path": {
+          "name": "rel_path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mime": {
+          "name": "mime",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "size": {
+          "name": "size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mtime_ms": {
+          "name": "mtime_ms",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "files_session_path_idx": {
+          "name": "files_session_path_idx",
+          "columns": [
+            "session_id",
+            "rel_path"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "interactions": {
+      "name": "interactions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "request": {
+          "name": "request",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "response": {
+          "name": "response",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "resolved_at": {
+          "name": "resolved_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "interactions_session_idx": {
+          "name": "interactions_session_idx",
+          "columns": [
+            "session_id",
+            "status"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "jobs": {
+      "name": "jobs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "no": {
+          "name": "no",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "plan": {
+          "name": "plan",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'running'"
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "blocked_by": {
+          "name": "blocked_by",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lead_runs": {
+          "name": "lead_runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "run_ids": {
+          "name": "run_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "jobs_team_idx": {
+          "name": "jobs_team_idx",
+          "columns": [
+            "team_id",
+            "no"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "knowledge_roots": {
+      "name": "knowledge_roots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "access": {
+          "name": "access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'read'"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "mcp_credentials": {
+      "name": "mcp_credentials",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "values": {
+          "name": "values",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "url_prefixes": {
+          "name": "url_prefixes",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "mcp_credentials_name_unique": {
+          "name": "mcp_credentials_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "mcp_servers": {
+      "name": "mcp_servers",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "transport": {
+          "name": "transport",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "url": {
+          "name": "url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "headers": {
+          "name": "headers",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "command": {
+          "name": "command",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "args": {
+          "name": "args",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "env": {
+          "name": "env",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "allow_network": {
+          "name": "allow_network",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "read_paths": {
+          "name": "read_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "write_paths": {
+          "name": "write_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tool_policies": {
+          "name": "tool_policies",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "background_tools": {
+          "name": "background_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tool_hints": {
+          "name": "tool_hints",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "oauth": {
+          "name": "oauth",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "credential_id": {
+          "name": "credential_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "tools_cache": {
+          "name": "tools_cache",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tools_updated_at": {
+          "name": "tools_updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "features": {
+          "name": "features",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "mcp_servers_name_unique": {
+          "name": "mcp_servers_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profile_versions": {
+      "name": "profile_versions",
+      "columns": {
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "snapshot": {
+          "name": "snapshot",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "profile_versions_profile_id_version_pk": {
+          "columns": [
+            "profile_id",
+            "version"
+          ],
+          "name": "profile_versions_profile_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profiles": {
+      "name": "profiles",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "system_prompt": {
+          "name": "system_prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'ask'"
+        },
+        "allowed_tools": {
+          "name": "allowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "disallowed_tools": {
+          "name": "disallowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "skills": {
+          "name": "skills",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'\"all\"'"
+        },
+        "file_access": {
+          "name": "file_access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "knowledge_root_ids": {
+          "name": "knowledge_root_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "mcp_server_ids": {
+          "name": "mcp_server_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "codex_plugin_ids": {
+          "name": "codex_plugin_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin": {
+          "name": "builtin",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin_key": {
+          "name": "builtin_key",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 1
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "runs": {
+      "name": "runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "context_tokens": {
+          "name": "context_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "runs_session_idx": {
+          "name": "runs_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "schedule_runs": {
+      "name": "schedule_runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "schedule_id": {
+          "name": "schedule_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "outcome": {
+          "name": "outcome",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "note": {
+          "name": "note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "schedule_runs_idx": {
+          "name": "schedule_runs_idx",
+          "columns": [
+            "schedule_id",
+            "id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "schedules": {
+      "name": "schedules",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_by_bot_id": {
+          "name": "created_by_bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "prompt": {
+          "name": "prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "spec": {
+          "name": "spec",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "timezone": {
+          "name": "timezone",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'active'"
+        },
+        "status_note": {
+          "name": "status_note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "next_run_at": {
+          "name": "next_run_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_run_at": {
+          "name": "last_run_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_outcome": {
+          "name": "last_outcome",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "run_count": {
+          "name": "run_count",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "schedules_due_idx": {
+          "name": "schedules_due_idx",
+          "columns": [
+            "status",
+            "next_run_at"
+          ],
+          "isUnique": false
+        },
+        "schedules_session_idx": {
+          "name": "schedules_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        },
+        "schedules_team_idx": {
+          "name": "schedules_team_idx",
+          "columns": [
+            "team_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "sessions": {
+      "name": "sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "system_prompt_append": {
+          "name": "system_prompt_append",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider_session_id": {
+          "name": "provider_session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "archived": {
+          "name": "archived",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "sessions_source_idx": {
+          "name": "sessions_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "settings": {
+      "name": "settings",
+      "columns": {
+        "key": {
+          "name": "key",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "value": {
+          "name": "value",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_device_logins": {
+      "name": "skill_device_logins",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "device_code_hash": {
+          "name": "device_code_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "user_code": {
+          "name": "user_code",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "client": {
+          "name": "client",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ip": {
+          "name": "ip",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'pending'"
+        },
+        "key_name": {
+          "name": "key_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "key_days": {
+          "name": "key_days",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "key_id": {
+          "name": "key_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "last_poll_at": {
+          "name": "last_poll_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "skill_device_logins_device_code_hash_unique": {
+          "name": "skill_device_logins_device_code_hash_unique",
+          "columns": [
+            "device_code_hash"
+          ],
+          "isUnique": true
+        },
+        "skill_device_logins_user_code_unique": {
+          "name": "skill_device_logins_user_code_unique",
+          "columns": [
+            "user_code"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_publish_keys": {
+      "name": "skill_publish_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'manual'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_used_ip": {
+          "name": "last_used_ip",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "skill_publish_keys_key_hash_unique": {
+          "name": "skill_publish_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_submission_items": {
+      "name": "skill_submission_items",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "submission_id": {
+          "name": "submission_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "declared_version": {
+          "name": "declared_version",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "checks": {
+          "name": "checks",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reason": {
+          "name": "reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "review_note": {
+          "name": "review_note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "reviewed_at": {
+          "name": "reviewed_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_submissions": {
+      "name": "skill_submissions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_id": {
+          "name": "key_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_name": {
+          "name": "key_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "message": {
+          "name": "message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "meta": {
+          "name": "meta",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "package_sha": {
+          "name": "package_sha",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "package_size": {
+          "name": "package_size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "tasks": {
+      "name": "tasks",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "job_id": {
+          "name": "job_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "no": {
+          "name": "no",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "instructions": {
+          "name": "instructions",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "depends_on": {
+          "name": "depends_on",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "follow_up": {
+          "name": "follow_up",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "tasks_team_idx": {
+          "name": "tasks_team_idx",
+          "columns": [
+            "team_id",
+            "no"
+          ],
+          "isUnique": false
+        },
+        "tasks_job_idx": {
+          "name": "tasks_job_idx",
+          "columns": [
+            "job_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_bot_sessions": {
+      "name": "team_bot_sessions",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "last_read_seq": {
+          "name": "last_read_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "reset_requested": {
+          "name": "reset_requested",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        }
+      },
+      "indexes": {
+        "team_bot_sessions_session_id_unique": {
+          "name": "team_bot_sessions_session_id_unique",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_bot_sessions_team_id_bot_id_pk": {
+          "columns": [
+            "team_id",
+            "bot_id"
+          ],
+          "name": "team_bot_sessions_team_id_bot_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_bots": {
+      "name": "team_bots",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "role": {
+          "name": "role",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "joined_at": {
+          "name": "joined_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "team_bots_bot_idx": {
+          "name": "team_bots_bot_idx",
+          "columns": [
+            "bot_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_bots_team_id_bot_id_pk": {
+          "columns": [
+            "team_id",
+            "bot_id"
+          ],
+          "name": "team_bots_team_id_bot_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_cases": {
+      "name": "team_cases",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lead_bot_id": {
+          "name": "lead_bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "worker_bot_ids": {
+          "name": "worker_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "stub_bot_ids": {
+          "name": "stub_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "human_name": {
+          "name": "human_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "text": {
+          "name": "text",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mentions": {
+          "name": "mentions",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_events": {
+      "name": "team_events",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_events_team_id_seq_pk": {
+          "columns": [
+            "team_id",
+            "seq"
+          ],
+          "name": "team_events_team_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "teams": {
+      "name": "teams",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "human": {
+          "name": "human",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'active'"
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "debug": {
+          "name": "debug",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{\"stepMode\":false,\"stubBotIds\":[]}'"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "case_id": {
+          "name": "case_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "teams_source_idx": {
+          "name": "teams_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        },
+        "teams_case_idx": {
+          "name": "teams_case_idx",
+          "columns": [
+            "case_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "usage_daily": {
+      "name": "usage_daily",
+      "columns": {
+        "date": {
+          "name": "date",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "runs": {
+          "name": "runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "failed": {
+          "name": "failed",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "interrupted": {
+          "name": "interrupted",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "usage_daily_date_source_provider_model_pk": {
+          "columns": [
+            "date",
+            "source",
+            "provider",
+            "model"
+          ],
+          "name": "usage_daily_date_source_provider_model_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    }
+  },
+  "views": {},
+  "enums": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "indexes": {}
+  }
+}

+ 7 - 0
apps/server/drizzle/meta/_journal.json

@@ -155,6 +155,13 @@
       "when": 1793700306445,
       "tag": "0021_skill_device_login",
       "breakpoints": true
+    },
+    {
+      "idx": 22,
+      "version": "6",
+      "when": 1793779663500,
+      "tag": "0022_mcp_credentials",
+      "breakpoints": true
     }
   ]
 }

+ 7 - 2
apps/server/src/app.ts

@@ -24,6 +24,7 @@ import { SettingsService } from './services/settings'
 import { UsageService } from './services/usage'
 import { PdfRenderer } from './api-reference/pdf'
 import { McpUpstream } from './mcp-gateway/upstream'
+import { McpCredentialService } from './services/mcp-credentials'
 import { McpServerService } from './services/mcp-servers'
 import { McpOAuthService } from './services/mcp-oauth'
 import { McpContentService } from './services/mcp-content'
@@ -66,7 +67,11 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const browserTokens = new BrowserTokenService(db)
   const apiPdf = new PdfRenderer()
   const mcpUpstream = new McpUpstream()
-  const mcpServers = new McpServerService(db, mcpUpstream, () => settings.get(), summary => audit.log({ actor: 'admin', kind: 'admin', summary }))
+  // 凭据的值变化后,使用它的服务重新连接并更新工具列表
+  const mcpCredentials = new McpCredentialService(db, summary => audit.log({ actor: 'admin', kind: 'admin', summary }), ids => {
+    for (const id of ids) void mcpServers.refresh(id).catch(() => {})
+  })
+  const mcpServers = new McpServerService(db, mcpUpstream, () => settings.get(), summary => audit.log({ actor: 'admin', kind: 'admin', summary }), mcpCredentials)
   const adminAudit = (summary: string, detail?: unknown) => audit.log({ actor: 'admin', kind: 'admin', summary, detail })
   migrateManagedSkillDirs(log)
   const customSkills = new CustomSkillService(db, adminAudit)
@@ -112,7 +117,7 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   mcpUpstream.onElicit = e => elicitation.handle(e)
   const schedules = new ScheduleService({ db, log, settings, sessions, runs, events, teams, bots, profiles })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, mcpOAuth, mcpContent, customSkills, skillRegistry, skillDeviceLogins, fileManager, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs, bots, botKeys, teams, teamCards, teamCases, schedules, backgroundCalls, mcpTasks, elicitation, skillCatalog }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, mcpCredentials, mcpOAuth, mcpContent, customSkills, skillRegistry, skillDeviceLogins, fileManager, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs, bots, botKeys, teams, teamCards, teamCases, schedules, backgroundCalls, mcpTasks, elicitation, skillCatalog }
 }
 
 export type App = ReturnType<typeof createApp>

+ 15 - 0
apps/server/src/db/schema.ts

@@ -201,6 +201,8 @@ export const mcpServers = sqliteTable('mcp_servers', {
   toolHints: text('tool_hints', { mode: 'json' }).$type<Record<string, McpToolHints>>().notNull().default(sql`'{}'`),
   /** OAuth 授权(配置、动态注册的客户端信息、令牌、授权过程中的临时值),不出现在 DTO 中 */
   oauth: text('oauth', { mode: 'json' }).$type<McpOAuthState>(),
+  /** 使用的凭据(mcp_credentials.id):连接时补充请求头或环境变量,服务自己的同名项优先 */
+  credentialId: text('credential_id'),
   /** 最近一次列出的工具(引擎列工具时直接使用,不必每次启动服务) */
   toolsCache: text('tools_cache', { mode: 'json' }).$type<McpCachedTool[]>().notNull().default(sql`'[]'`),
   toolsUpdatedAt: text('tools_updated_at'),
@@ -210,6 +212,19 @@ export const mcpServers = sqliteTable('mcp_servers', {
   updatedAt: updatedAt(),
 })
 
+/** MCP 凭据(docs/design/20-mcp-credentials.md):多个服务共用的请求头(http)或环境变量(stdio)。values 含密钥,只在服务端使用 */
+export const mcpCredentials = sqliteTable('mcp_credentials', {
+  id: text('id').primaryKey(),
+  name: text('name').notNull().unique(),
+  description: text('description').notNull().default(''),
+  kind: text('kind', { enum: ['http', 'stdio'] }).notNull(),
+  values: text('values', { mode: 'json' }).$type<Record<string, string>>().notNull().default(sql`'{}'`),
+  /** http:适用地址前缀;空数组表示任意地址 */
+  urlPrefixes: text('url_prefixes', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
+  createdAt: createdAt(),
+  updatedAt: updatedAt(),
+})
+
 export interface McpFeatures {
   resources?: boolean
   prompts?: boolean

+ 16 - 1
apps/server/src/http/routes/admin.ts

@@ -3,7 +3,7 @@ import fs from 'node:fs'
 import type { FastifyInstance, FastifyRequest } from 'fastify'
 import mime from 'mime-types'
 import { z } from 'zod'
-import { BUILTIN_GLOBAL_DENY, COMMUNITY_SKILL_PLUGIN, CUSTOM_SKILL_PLUGIN, type AppSettings, type McpServerInput, type DeepPartial, type FileAccessPolicy, type PluginAction } from '@agent-server/shared'
+import { BUILTIN_GLOBAL_DENY, COMMUNITY_SKILL_PLUGIN, CUSTOM_SKILL_PLUGIN, type AppSettings, type McpCredentialInput, type McpServerInput, type DeepPartial, type FileAccessPolicy, type PluginAction } from '@agent-server/shared'
 import type { App } from '../../app'
 import { AppError, badRequest, notFound } from '../../lib/errors'
 import { runClaudeCli } from '../../providers/claude/cli'
@@ -205,6 +205,21 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       await app.mcpOAuth.revoke(req.params.id)
       reply.code(204)
     })
+    // MCP 凭据(docs/design/20-mcp-credentials.md)
+    g.post('/mcp/credentials', async (req, reply) => {
+      reply.code(201)
+      return app.mcpCredentials.create(req.body as McpCredentialInput)
+    })
+    g.put<IdParams>('/mcp/credentials/:id', async req => app.mcpCredentials.update(req.params.id, req.body as McpCredentialInput))
+    g.delete<IdParams>('/mcp/credentials/:id', async (req, reply) => {
+      app.mcpCredentials.delete(req.params.id)
+      reply.code(204)
+    })
+    g.post<IdParams>('/mcp/servers/:id/save-credential', async (req, reply) => {
+      const b = parse(z.object({ name: z.string(), urlPrefixes: z.array(z.string()).default([]) }), req.body)
+      reply.code(201)
+      return app.mcpCredentials.extract(req.params.id, b)
+    })
     g.post('/mcp/test', async req => {
       const b = req.body as { input: McpServerInput; id?: string | null }
       return app.mcpServers.test(b.input, b.id ?? null)

+ 1 - 1
apps/server/src/lib/ids.ts

@@ -1,6 +1,6 @@
 import { ulid } from 'ulid'
 
-export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg' | 'job' | 'task' | 'step' | 'case' | 'sch' | 'bg' | 'spk' | 'sub' | 'sbi' | 'sdl'
+export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg' | 'job' | 'task' | 'step' | 'case' | 'sch' | 'bg' | 'spk' | 'sub' | 'sbi' | 'sdl' | 'mcc'
 
 export const newId = (prefix: IdPrefix) => `${prefix}_${ulid().toLowerCase()}`
 

+ 187 - 0
apps/server/src/services/mcp-credentials.ts

@@ -0,0 +1,187 @@
+// MCP 凭据(docs/design/20-mcp-credentials.md):多个服务共用的请求头(http)或环境变量(stdio),服务引用而不是复制,
+// 修改后使用它的服务重新连接。http 凭据可以限定适用地址(前缀匹配,按路径段边界),不填时可用于任意地址
+import { eq } from 'drizzle-orm'
+import { z } from 'zod'
+import type { McpCredentialDto, McpCredentialInput, McpSecretEntry } from '@agent-server/shared'
+import type { DB } from '../db'
+import { mcpCredentials, mcpServers } from '../db/schema'
+import { badRequest, notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+import type { McpServerRow } from '../mcp-gateway/upstream'
+
+export const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
+export const HEADER_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/
+
+type CredentialRow = typeof mcpCredentials.$inferSelect
+
+const inputSchema = z.object({
+  name: z.string().trim().min(1, '请填写名称').max(60),
+  description: z.string().max(500).default(''),
+  kind: z.enum(['http', 'stdio']),
+  values: z.array(z.object({ name: z.string().trim().min(1).max(200), value: z.string().max(20_000).nullable() })).min(1, '至少填写一项').max(50),
+  urlPrefixes: z.array(z.string().max(2000)).max(20).default([]),
+})
+
+/** 适用地址:http / https,不带查询参数与片段;返回规范化的写法(协议、主机小写) */
+export function normalizePrefix(raw: string): string {
+  let u: URL
+  try {
+    u = new URL(raw.trim())
+  } catch {
+    throw badRequest(`适用地址不是有效的 URL:${raw}`)
+  }
+  if (!['http:', 'https:'].includes(u.protocol)) throw badRequest(`适用地址只支持 http / https:${raw}`)
+  if (u.search || u.hash || u.username || u.password) throw badRequest(`适用地址不能带查询参数、片段或用户名:${raw}`)
+  return u.pathname === '/' && !raw.trim().endsWith('/') ? u.origin : `${u.origin}${u.pathname}`
+}
+
+/**
+ * 地址是否在前缀之下:协议、主机、端口完全相同,路径按段匹配
+ * (前缀 https://a.com/mcp 匹配 /mcp、/mcp/x,不匹配 /mcp2;https://a.com 不匹配 https://a.com.evil.com)
+ */
+export function matchesPrefix(url: string, prefix: string): boolean {
+  let u: URL
+  let p: URL
+  try {
+    u = new URL(url)
+    p = new URL(prefix)
+  } catch {
+    return false
+  }
+  if (u.origin !== p.origin) return false
+  const pp = p.pathname
+  if (pp === '/' || pp === '') return true
+  return u.pathname === pp || u.pathname.startsWith(pp.endsWith('/') ? pp : `${pp}/`)
+}
+
+/** 凭据能否用于这个地址:没有限定地址时可用于任意地址 */
+export const credentialAllows = (c: Pick<CredentialRow, 'urlPrefixes'>, url: string) => !c.urlPrefixes.length || c.urlPrefixes.some(p => matchesPrefix(url, p))
+
+export class McpCredentialService {
+  constructor(private db: DB, private audit: (summary: string) => void, private onChanged: (serverIds: string[]) => void) {}
+
+  list(): McpCredentialDto[] {
+    const servers = this.db.select({ id: mcpServers.id, name: mcpServers.name, title: mcpServers.title, credentialId: mcpServers.credentialId }).from(mcpServers).orderBy(mcpServers.name).all()
+    return this.db.select().from(mcpCredentials).orderBy(mcpCredentials.name).all().map(r => toDto(r, servers.filter(s => s.credentialId === r.id)))
+  }
+
+  get(id: string): McpCredentialDto {
+    return this.list().find(c => c.id === id) ?? (() => {
+      throw notFound('MCP 凭据')
+    })()
+  }
+
+  create(input: McpCredentialInput): McpCredentialDto {
+    const data = this.validate(input, null)
+    this.checkName(data.name, null)
+    const id = newId('mcc')
+    this.db.insert(mcpCredentials).values({ id, ...data }).run()
+    this.audit(`添加 MCP 凭据 ${data.name}(${describe(data)})`)
+    return this.get(id)
+  }
+
+  update(id: string, input: McpCredentialInput): McpCredentialDto {
+    const prev = this.row(id)
+    const data = this.validate(input, prev)
+    this.checkName(data.name, id)
+    const users = this.users(id)
+    if (users.length && data.kind !== prev.kind) throw badRequest(`凭据正在被使用(${users.map(s => s.name).join('、')}),不能改变类型`)
+    const outside = users.filter(s => s.transport === 'http' && !credentialAllows(data, s.url))
+    if (outside.length) throw badRequest(`以下服务的地址不在新的适用地址内:${outside.map(s => `${s.name}(${s.url})`).join('、')}`)
+    this.db.update(mcpCredentials).set({ ...data, updatedAt: nowIso() }).where(eq(mcpCredentials.id, id)).run()
+    const changed = JSON.stringify(data.values) !== JSON.stringify(prev.values)
+    this.audit(`修改 MCP 凭据 ${data.name}(${describe(data)}${changed ? ',值已更新' : ''})`)
+    // 值变化后,使用它的服务按新值重新连接
+    if (changed && users.length) this.onChanged(users.map(s => s.id))
+    return this.get(id)
+  }
+
+  delete(id: string) {
+    const r = this.row(id)
+    const users = this.users(id)
+    if (users.length) throw badRequest(`凭据正在被使用:${users.map(s => s.name).join('、')}。请先在这些服务中改为不使用凭据`)
+    this.db.delete(mcpCredentials).where(eq(mcpCredentials.id, id)).run()
+    this.audit(`删除 MCP 凭据 ${r.name}`)
+  }
+
+  /** 服务能否使用这个凭据:类型一致;http 服务的地址在适用地址内 */
+  check(id: string, transport: 'http' | 'stdio', url: string) {
+    const c = this.db.select().from(mcpCredentials).where(eq(mcpCredentials.id, id)).get()
+    if (!c) throw badRequest('选择的凭据不存在')
+    if (c.kind !== transport) throw badRequest(`凭据「${c.name}」用于${c.kind === 'http' ? '远程 HTTP 服务(请求头)' : '本地进程(环境变量)'},不能用于这个服务`)
+    if (transport === 'http' && !credentialAllows(c, url)) throw badRequest(`凭据「${c.name}」只能用于 ${c.urlPrefixes.join('、')} 下的地址`)
+  }
+
+  /** 连接时:把凭据合并进请求头或环境变量(服务自己的同名项优先);凭据不存在或不适用时不合并 */
+  apply(r: McpServerRow): McpServerRow {
+    if (!r.credentialId) return r
+    const c = this.db.select().from(mcpCredentials).where(eq(mcpCredentials.id, r.credentialId)).get()
+    if (!c || c.kind !== r.transport || (r.transport === 'http' && !credentialAllows(c, r.url))) return r
+    return r.transport === 'http' ? { ...r, headers: { ...c.values, ...r.headers } } : { ...r, env: { ...c.values, ...r.env } }
+  }
+
+  /** 把服务自己的请求头(http)或环境变量(stdio)转成凭据,服务改为引用它 */
+  extract(serverId: string, input: { name: string; urlPrefixes: string[] }): McpCredentialDto {
+    const s = this.db.select().from(mcpServers).where(eq(mcpServers.id, serverId)).get()
+    if (!s) throw notFound('MCP 服务')
+    if (s.credentialId) throw badRequest('这个服务已经在使用凭据')
+    const values = s.transport === 'http' ? s.headers : s.env
+    if (!Object.keys(values).length) throw badRequest(`这个服务没有${s.transport === 'http' ? '请求头' : '环境变量'}`)
+    const data = this.validate({ name: input.name, description: `由服务 ${s.name} 保存`, kind: s.transport, values: Object.keys(values).map(name => ({ name, value: null })), urlPrefixes: input.urlPrefixes }, null, values)
+    this.checkName(data.name, null)
+    if (s.transport === 'http' && !credentialAllows(data, s.url)) throw badRequest(`服务地址 ${s.url} 不在适用地址内`)
+    const id = newId('mcc')
+    this.db.transaction(tx => {
+      tx.insert(mcpCredentials).values({ id, ...data }).run()
+      tx.update(mcpServers).set({ credentialId: id, ...(s.transport === 'http' ? { headers: {} } : { env: {} }), updatedAt: nowIso() }).where(eq(mcpServers.id, serverId)).run()
+    })
+    this.audit(`MCP 服务 ${s.name} 的${s.transport === 'http' ? '请求头' : '环境变量'}保存为凭据 ${data.name}(${describe(data)})`)
+    return this.get(id)
+  }
+
+  private row(id: string): CredentialRow {
+    const r = this.db.select().from(mcpCredentials).where(eq(mcpCredentials.id, id)).get()
+    if (!r) throw notFound('MCP 凭据')
+    return r
+  }
+
+  private users(id: string) {
+    return this.db.select({ id: mcpServers.id, name: mcpServers.name, transport: mcpServers.transport, url: mcpServers.url }).from(mcpServers).where(eq(mcpServers.credentialId, id)).all()
+  }
+
+  private checkName(name: string, id: string | null) {
+    const dup = this.db.select({ id: mcpCredentials.id }).from(mcpCredentials).where(eq(mcpCredentials.name, name)).get()
+    if (dup && dup.id !== id) throw badRequest(`凭据名称 ${name} 已被使用`)
+  }
+
+  /** prevValues:value 为 null 的项沿用的原值 */
+  private validate(input: McpCredentialInput, prev: CredentialRow | null, prevValues = prev?.values ?? {}) {
+    const r = inputSchema.safeParse(input)
+    if (!r.success) {
+      const i = r.error.issues[0]!
+      throw badRequest(`${i.path.length ? `${i.path.join('.')}:` : ''}${i.message}`)
+    }
+    const d = r.data
+    const re = d.kind === 'http' ? HEADER_RE : ENV_RE
+    const values: Record<string, string> = {}
+    for (const e of d.values) {
+      if (!re.test(e.name)) throw badRequest(`${d.kind === 'http' ? '请求头' : '环境变量'}名称不正确:${e.name}`)
+      const v = e.value ?? prevValues[e.name]
+      if (v === undefined || v === '') throw badRequest(`请填写 ${e.name} 的值`)
+      values[e.name] = v
+    }
+    const urlPrefixes = d.kind === 'http' ? [...new Set(d.urlPrefixes.map(p => p.trim()).filter(Boolean).map(normalizePrefix))] : []
+    return { name: d.name, description: d.description, kind: d.kind, values, urlPrefixes }
+  }
+}
+
+const describe = (d: { kind: string; values: Record<string, string>; urlPrefixes: string[] }) =>
+  `${d.kind === 'http' ? '请求头' : '环境变量'} ${Object.keys(d.values).join('、')}${d.kind === 'http' ? `;适用地址 ${d.urlPrefixes.length ? d.urlPrefixes.join('、') : '任意'}` : ''}`
+
+function toDto(r: CredentialRow, usedBy: { id: string; name: string; title: string }[]): McpCredentialDto {
+  const values: McpSecretEntry[] = Object.keys(r.values).map(name => ({ name, value: null, isSet: r.values[name] !== '' }))
+  return {
+    id: r.id, name: r.name, description: r.description, kind: r.kind, values, urlPrefixes: r.urlPrefixes,
+    usedBy: usedBy.map(s => ({ id: s.id, name: s.name, title: s.title })), createdAt: r.createdAt, updatedAt: r.updatedAt,
+  }
+}

+ 15 - 9
apps/server/src/services/mcp-servers.ts

@@ -13,10 +13,9 @@ import { isWithin, realpathLoose } from '../lib/paths'
 import { BUILTIN_TOOL_INFO } from '../mcp-gateway/builtin'
 import { BWRAP, mcpHomeDir, McpUpstream, type McpServerRow } from '../mcp-gateway/upstream'
 import { globalDenyList } from './access-policy'
+import { ENV_RE, HEADER_RE, type McpCredentialService } from './mcp-credentials'
 
 const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/
-const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
-const HEADER_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/
 
 const secretSchema = z.object({ name: z.string().min(1).max(200), value: z.string().max(20_000).nullable() })
 const inputSchema = z.object({
@@ -36,6 +35,7 @@ const inputSchema = z.object({
   allowNetwork: z.boolean().default(false),
   readPaths: z.array(z.string()).max(50).default([]),
   writePaths: z.array(z.string()).max(50).default([]),
+  credentialId: z.string().max(100).nullable().optional(),
 })
 
 export interface EffectiveHints {
@@ -69,7 +69,7 @@ export function toolState(r: Pick<McpServerRow, 'toolHints' | 'toolPolicies'>, t
 export class McpServerService {
   private refreshing = new Map<string, NodeJS.Timeout>()
 
-  constructor(private db: DB, private upstream: McpUpstream, private settings: () => AppSettings, private audit: (summary: string) => void) {
+  constructor(private db: DB, private upstream: McpUpstream, private settings: () => AppSettings, private audit: (summary: string) => void, private credentials: McpCredentialService) {
     // 服务通知工具列表变化,或新建了连接:重新列出工具(合并 2 秒内的多次通知,docs/design/16-mcp-support.md §2.3)
     upstream.onToolsChanged = id => {
       clearTimeout(this.refreshing.get(id))
@@ -82,7 +82,7 @@ export class McpServerService {
   }
 
   state(): McpStateDto {
-    return { builtin: BUILTIN_TOOL_INFO, servers: this.list(), sandboxAvailable: !!BWRAP }
+    return { builtin: BUILTIN_TOOL_INFO, servers: this.list(), credentials: this.credentials.list(), sandboxAvailable: !!BWRAP }
   }
 
   list(): McpServerDto[] {
@@ -94,10 +94,10 @@ export class McpServerService {
     return this.toDto(this.row(id), this.usage().get(id) ?? [])
   }
 
-  /** 运行时:取 Profile 勾选且已启用的服务 */
+  /** 运行时:取 Profile 勾选且已启用的服务(已合并凭据,可以直接连接) */
   rows(ids: string[]): McpServerRow[] {
     if (!ids.length) return []
-    return this.db.select().from(mcpServers).all().filter(r => r.enabled && ids.includes(r.id))
+    return this.db.select().from(mcpServers).all().filter(r => r.enabled && ids.includes(r.id)).map(r => this.credentials.apply(r))
   }
 
   create(input: McpServerInput): McpServerDto {
@@ -143,7 +143,8 @@ export class McpServerService {
     return this.get(id)
   }
 
-  async listToolsFor(r: McpServerRow): Promise<McpCachedTool[]> {
+  async listToolsFor(row: McpServerRow): Promise<McpCachedTool[]> {
+    const r = this.credentials.apply(row)
     const tools = await this.upstream.listTools(r)
     const features = await this.upstream.features(r).catch(() => r.features)
     this.updateTools(r, tools, features)
@@ -188,7 +189,7 @@ export class McpServerService {
     const t0 = Date.now()
     const tempId = id ?? `test_${Date.now()}`
     try {
-      const { tools, stderr } = await this.upstream.test({ ...(prev ?? emptyRow()), ...data, id: tempId })
+      const { tools, stderr } = await this.upstream.test(this.credentials.apply({ ...(prev ?? emptyRow()), ...data, id: tempId }))
       return { ok: true, tools: tools.map(t => toToolDto(t, prev ?? { toolPolicies: {}, toolHints: {}, backgroundTools: [] })), error: null, stderrTail: stderr.length ? stderr.join('\n') : null, ms: Date.now() - t0 }
     } catch (err) {
       return { ok: false, tools: [], error: (err as Error).message, stderrTail: null, ms: Date.now() - t0 }
@@ -234,6 +235,7 @@ export class McpServerService {
       } : null,
       command: r.command, args: r.args, env: masked(r.env),
       allowNetwork: r.allowNetwork, readPaths: r.readPaths, writePaths: r.writePaths,
+      credentialId: r.credentialId,
       tools: r.toolsCache.map(t => toToolDto(t, r)), toolsUpdatedAt: r.toolsUpdatedAt, features: { resources: !!r.features.resources, prompts: !!r.features.prompts },
       ...this.upstream.status(r.id), profileNames, createdAt: r.createdAt, updatedAt: r.updatedAt,
     }
@@ -259,6 +261,9 @@ export class McpServerService {
       if (!BWRAP && !forTest) throw badRequest('服务器上没有 bubblewrap(bwrap),无法运行本地 MCP 服务')
     }
     for (const h of d.headers) if (!HEADER_RE.test(h.name)) throw badRequest(`请求头名称不正确:${h.name}`)
+    // 凭据:没有传时沿用原来的选择
+    const credentialId = d.credentialId === undefined ? prev?.credentialId ?? null : d.credentialId || null
+    if (credentialId) this.credentials.check(credentialId, d.transport, d.url.trim())
     for (const e of d.env) if (!ENV_RE.test(e.name)) throw badRequest(`环境变量名称不正确:${e.name}`)
     return {
       name: d.name, title: d.title, description: d.description, transport: d.transport, enabled: d.enabled,
@@ -271,6 +276,7 @@ export class McpServerService {
       allowNetwork: d.transport === 'stdio' && d.allowNetwork,
       readPaths: d.transport === 'stdio' ? this.checkPaths(d.readPaths, 'read') : [],
       writePaths: d.transport === 'stdio' ? this.checkPaths(d.writePaths, 'write') : [],
+      credentialId,
     }
   }
 
@@ -327,6 +333,6 @@ function emptyRow(): McpServerRow {
   const now = nowIso()
   return {
     id: '', name: '', title: '', description: '', transport: 'http', enabled: true, url: '', headers: {}, command: '', args: [], env: {},
-    allowNetwork: false, readPaths: [], writePaths: [], toolPolicies: {}, toolHints: {}, backgroundTools: [], oauth: null, features: {}, toolsCache: [], toolsUpdatedAt: null, createdAt: now, updatedAt: now,
+    allowNetwork: false, readPaths: [], writePaths: [], credentialId: null, toolPolicies: {}, toolHints: {}, backgroundTools: [], oauth: null, features: {}, toolsCache: [], toolsUpdatedAt: null, createdAt: now, updatedAt: now,
   }
 }

+ 154 - 0
apps/server/test/mcp-credentials.test.ts

@@ -0,0 +1,154 @@
+// MCP 凭据(docs/design/20-mcp-credentials.md):服务引用凭据、适用地址的前缀匹配、修改后按新值连接、保存为凭据
+import http from 'node:http'
+import type { AddressInfo } from 'node:net'
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
+import { z } from 'zod'
+import type { McpCredentialInput, McpServerInput } from '@agent-server/shared'
+import { createApp, type App } from '../src/app'
+import { auditLogs } from '../src/db/schema'
+import { matchesPrefix, normalizePrefix } from '../src/services/mcp-credentials'
+
+let app: App
+let upstream: http.Server
+let origin: string
+/** 上游接受的令牌(模拟更换令牌) */
+let accepted = 'Bearer tok-1'
+
+beforeAll(async () => {
+  app = createApp(pino({ level: 'silent' }), { providers: {} })
+  upstream = http.createServer(async (req, res) => {
+    if (req.headers.authorization !== accepted) return res.writeHead(401).end()
+    const mcp = new McpServer({ name: 'fixture', version: '1.0.0' })
+    mcp.registerTool('echo', { description: '原样返回', inputSchema: { text: z.string() }, annotations: { readOnlyHint: true } }, async ({ text }) => ({ content: [{ type: 'text', text }] }))
+    const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
+    res.on('close', () => { void t.close(); void mcp.close() })
+    await mcp.connect(t)
+    let body = ''
+    req.on('data', c => (body += c))
+    req.on('end', () => void t.handleRequest(req, res, body ? JSON.parse(body) : undefined))
+  })
+  origin = await new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}`)))
+})
+afterAll(async () => {
+  await app.mcpUpstream.closeAll()
+  upstream.close()
+})
+
+const server = (over: Partial<McpServerInput> = {}): McpServerInput => ({
+  name: 'orders', title: '订单', description: '', transport: 'http', enabled: true, url: `${origin}/mcp/orders`,
+  headers: [], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [], credentialId: null, ...over,
+})
+const cred = (over: Partial<McpCredentialInput> = {}): McpCredentialInput => ({
+  name: '业务后台', description: '', kind: 'http', values: [{ name: 'Authorization', value: 'Bearer tok-1' }], urlPrefixes: [], ...over,
+})
+
+describe('适用地址:前缀匹配', () => {
+  it('协议、主机、端口完全相同,路径按段匹配', () => {
+    expect(matchesPrefix('https://a.com/mcp/x', 'https://a.com')).toBe(true)
+    expect(matchesPrefix('https://a.com/mcp', 'https://a.com/mcp')).toBe(true)
+    expect(matchesPrefix('https://a.com/mcp/orders', 'https://a.com/mcp')).toBe(true)
+    expect(matchesPrefix('https://a.com/mcp/orders', 'https://a.com/mcp/')).toBe(true)
+    expect(matchesPrefix('https://a.com/mcp2', 'https://a.com/mcp')).toBe(false)
+    expect(matchesPrefix('https://a.com.evil.com/mcp', 'https://a.com')).toBe(false)
+    expect(matchesPrefix('http://a.com/mcp', 'https://a.com')).toBe(false)
+    expect(matchesPrefix('https://a.com:8443/mcp', 'https://a.com')).toBe(false)
+    expect(matchesPrefix('https://A.COM/mcp', 'https://a.com')).toBe(true)
+  })
+
+  it('规范化:协议与主机小写;不接受查询参数、非 http 地址', () => {
+    expect(normalizePrefix(' HTTPS://API.Example.com ')).toBe('https://api.example.com')
+    expect(normalizePrefix('https://api.example.com/mcp/')).toBe('https://api.example.com/mcp/')
+    expect(() => normalizePrefix('https://a.com/mcp?x=1')).toThrow(/查询参数/)
+    expect(() => normalizePrefix('ftp://a.com')).toThrow(/http/)
+    expect(() => normalizePrefix('a.com')).toThrow(/URL/)
+  })
+})
+
+describe('MCP 凭据', () => {
+  it('服务引用凭据:连接时补充请求头,凭据显示被哪些服务使用;值不返回、不进入审计', async () => {
+    const c = app.mcpCredentials.create(cred({ urlPrefixes: [`${origin}/mcp`] }))
+    expect(c.values).toEqual([{ name: 'Authorization', value: null, isSet: true }])
+    expect(c.urlPrefixes).toEqual([`${origin}/mcp`])
+
+    // 没有凭据时连接被拒绝;选择凭据后可以连接
+    expect((await app.mcpServers.test(server(), null)).ok).toBe(false)
+    expect(await app.mcpServers.test(server({ credentialId: c.id }), null)).toMatchObject({ ok: true })
+
+    const s = app.mcpServers.create(server({ credentialId: c.id }))
+    expect(s.credentialId).toBe(c.id)
+    expect(s.headers).toEqual([])
+    expect(app.mcpServers.rows([s.id])[0]!.headers).toEqual({ Authorization: 'Bearer tok-1' })
+    expect((await app.mcpServers.refresh(s.id)).status).toBe('connected')
+    expect(app.mcpCredentials.get(c.id).usedBy).toEqual([{ id: s.id, name: 'orders', title: '订单' }])
+
+    // 服务自己的同名请求头优先
+    const own = app.mcpServers.update(s.id, server({ credentialId: c.id, headers: [{ name: 'Authorization', value: 'Bearer own' }, { name: 'X-Team', value: 'a' }] }))
+    expect(app.mcpServers.rows([own.id])[0]!.headers).toEqual({ Authorization: 'Bearer own', 'X-Team': 'a' })
+    app.mcpServers.update(s.id, server({ credentialId: c.id }))
+
+    const logs = app.db.select().from(auditLogs).all().map(l => l.summary).join('\n')
+    expect(logs).toContain('添加 MCP 凭据 业务后台(请求头 Authorization;适用地址')
+    expect(logs).not.toContain('tok-1')
+  })
+
+  it('适用地址与类型:地址不匹配或类型不同时不能选择;改小适用地址、改类型、删除使用中的凭据会被拒绝', () => {
+    const c = app.mcpCredentials.list().find(x => x.name === '业务后台')!
+    expect(() => app.mcpServers.create(server({ name: 'other', url: 'http://127.0.0.1:1/mcp', credentialId: c.id }))).toThrow(/只能用于/)
+    expect(() => app.mcpServers.create(server({ name: 'other', url: `${origin}/mcpx`, credentialId: c.id }))).toThrow(/只能用于/)
+    const env = app.mcpCredentials.create(cred({ name: '本地密钥', kind: 'stdio', values: [{ name: 'API_TOKEN', value: 'e1' }], urlPrefixes: ['https://ignored.com'] }))
+    expect(env.urlPrefixes).toEqual([])
+    expect(() => app.mcpServers.create(server({ name: 'other', credentialId: env.id }))).toThrow(/不能用于这个服务/)
+    expect(() => app.mcpServers.create(server({ name: 'other', credentialId: 'mcc_nope' }))).toThrow(/不存在/)
+
+    expect(() => app.mcpCredentials.update(c.id, cred({ values: [{ name: 'Authorization', value: null }], urlPrefixes: [`${origin}/other`] }))).toThrow(/不在新的适用地址内/)
+    expect(() => app.mcpCredentials.update(c.id, cred({ kind: 'stdio', values: [{ name: 'TOKEN', value: 'x' }] }))).toThrow(/不能改变类型/)
+    expect(() => app.mcpCredentials.delete(c.id)).toThrow(/正在被使用/)
+    expect(() => app.mcpCredentials.create(cred())).toThrow(/已被使用/)
+    expect(() => app.mcpCredentials.create(cred({ name: '空值', values: [{ name: 'Authorization', value: null }] }))).toThrow(/请填写/)
+    expect(() => app.mcpCredentials.create(cred({ name: '坏名称', values: [{ name: 'Bad Header', value: 'x' }] }))).toThrow(/名称不正确/)
+
+    // 不填适用地址:任意地址可用
+    const any = app.mcpCredentials.create(cred({ name: '通用' }))
+    expect(app.mcpServers.create(server({ name: 'anywhere', url: 'http://127.0.0.1:1/mcp', credentialId: any.id })).credentialId).toBe(any.id)
+    app.mcpServers.update(app.mcpServers.list().find(x => x.name === 'anywhere')!.id, server({ name: 'anywhere', url: 'http://127.0.0.1:1/mcp', credentialId: null }))
+    app.mcpCredentials.delete(any.id)
+  })
+
+  it('更换令牌:修改凭据后,使用它的服务按新值重新连接', async () => {
+    const c = app.mcpCredentials.list().find(x => x.name === '业务后台')!
+    const s = app.mcpServers.list().find(x => x.name === 'orders')!
+    accepted = 'Bearer tok-2'
+    expect((await app.mcpServers.refresh(s.id)).status).toBe('error')
+    app.mcpCredentials.update(c.id, cred({ values: [{ name: 'Authorization', value: 'Bearer tok-2' }], urlPrefixes: [`${origin}/mcp`] }))
+    expect(app.mcpServers.rows([s.id])[0]!.headers).toEqual({ Authorization: 'Bearer tok-2' })
+    expect((await app.mcpServers.refresh(s.id)).status).toBe('connected')
+    // 只改适用地址、值留空:沿用原值
+    app.mcpCredentials.update(c.id, cred({ values: [{ name: 'Authorization', value: null }], urlPrefixes: [origin] }))
+    expect(app.mcpServers.rows([s.id])[0]!.headers).toEqual({ Authorization: 'Bearer tok-2' })
+  })
+
+  it('保存为凭据:把服务自己的请求头转成凭据,服务改为引用它', async () => {
+    const s = app.mcpServers.create(server({ name: 'devices', url: `${origin}/mcp/devices`, headers: [{ name: 'Authorization', value: 'Bearer tok-2' }] }))
+    expect(() => app.mcpCredentials.extract(s.id, { name: '设备', urlPrefixes: ['https://elsewhere.com'] })).toThrow(/不在适用地址内/)
+    const c = app.mcpCredentials.extract(s.id, { name: '设备', urlPrefixes: [`${origin}/mcp/`] })
+    const after = app.mcpServers.get(s.id)
+    expect(after).toMatchObject({ credentialId: c.id, headers: [] })
+    expect(app.mcpServers.rows([s.id])[0]!.headers).toEqual({ Authorization: 'Bearer tok-2' })
+    expect((await app.mcpServers.refresh(s.id)).status).toBe('connected')
+    expect(() => app.mcpCredentials.extract(s.id, { name: '再来', urlPrefixes: [] })).toThrow(/已经在使用凭据/)
+  })
+
+  it('本地进程:凭据补充环境变量', () => {
+    const env = app.mcpCredentials.list().find(x => x.name === '本地密钥')!
+    const applied = app.mcpCredentials.apply({
+      id: 'x', name: 'x', title: 'x', description: '', transport: 'stdio', enabled: true, url: '', headers: {}, command: 'node', args: [], env: { OWN: '1', API_TOKEN: 'mine' },
+      allowNetwork: false, readPaths: [], writePaths: [], credentialId: env.id, toolPolicies: {}, toolHints: {}, backgroundTools: [], oauth: null, features: {}, toolsCache: [], toolsUpdatedAt: null, createdAt: '', updatedAt: '',
+    })
+    expect(applied.env).toEqual({ API_TOKEN: 'mine', OWN: '1' })
+    const noOwn = app.mcpCredentials.apply({ ...applied, env: {} })
+    expect(noOwn.env).toEqual({ API_TOKEN: 'e1' })
+  })
+})

+ 2 - 1
docs/README.md

@@ -31,7 +31,8 @@ docs/
 │  ├─ 16-mcp-support.md       MCP 支持完善:工具提示、结构化输出、长任务、询问用户、OAuth、资源与提示模板
 │  ├─ 17-run-script.md        脚本调用工具:模型写 Python 脚本,由网关在沙箱中执行并批量调用 MCP 工具
 │  ├─ 18-skill-registry.md    技能发布平台:项目推送技能,管理员审核后新建或覆盖
-│  └─ 19-skill-cli.md         技能推送工具 agent-skills:浏览器登录、预检打包推送、等待审核
+│  ├─ 19-skill-cli.md         技能推送工具 agent-skills:浏览器登录、预检打包推送、等待审核
+│  └─ 20-mcp-credentials.md   MCP 凭据:多个服务共用请求头 / 环境变量,按适用地址(前缀)自动选择
 ├─ adr/                       架构决策记录(一事一档,只追加不改写;推翻时新增一条并标注 Superseded)
 └─ research/                  调研材料(选型依据、参考实现分析)
 ```

+ 46 - 0
docs/design/20-mcp-credentials.md

@@ -0,0 +1,46 @@
+# 20 MCP 凭据
+
+> 状态:**已实施(2026-11-04)**。补充 ADR-0021(第三方 MCP 服务)。
+
+## 1. 问题
+
+每个 MCP 服务各自保存请求头(`mcp_servers.headers`)或环境变量(`env`)。同一个后端提供多个 MCP 服务时,令牌要在每个服务里重复填写;更换令牌时要逐个修改。
+
+## 2. 方案
+
+凭据是一组命名的密钥值,服务**引用**而不是复制:
+
+| 字段 | 说明 |
+| --- | --- |
+| 名称、说明 | 名称唯一 |
+| 类型 | `http`:请求头,用于远程服务;`stdio`:环境变量,用于本地进程 |
+| 值 | 若干键值(如 `Authorization: Bearer …`),保存后不再返回明文 |
+| 适用地址 | 仅 http:若干地址前缀;不填表示可用于任意地址 |
+
+- 服务增加 `credential_id`。连接时合并:`{ ...凭据的值, ...服务自己的值 }`,同名以服务自己填写的为准。
+- 合并发生在 `McpServerService` 交给连接层之前(`rows()`、列工具、测试连接),连接层的连接键包含合并后的值:凭据修改后,下次使用自动按新值重新连接;修改值时还会主动刷新使用它的服务。
+- 凭据只在服务端使用;新建、修改、删除记入审计(只记名称、键名与适用地址,不记值)。
+
+## 3. 适用地址(前缀匹配)
+
+- 规范化:只接受 http / https,不能带查询参数、片段、用户名;协议与主机小写。
+- 匹配:协议、主机、端口完全相同;路径按段匹配——`https://a.com/mcp` 匹配 `/mcp`、`/mcp/orders`,不匹配 `/mcp2`;`https://a.com` 不匹配 `https://a.com.evil.com`。
+- 服务端强制:http 服务只能选择适用地址包含其地址的凭据;修改凭据的适用地址时,若有使用它的服务不再匹配则拒绝;连接时再次检查,不匹配时不合并。
+- 没有适用地址的凭据可用于任意地址,后台选择时提醒确认服务地址可信。
+
+## 4. 后台
+
+- "MCP 服务"页增加"凭据"一栏:列出类型、键名、适用地址、使用它的服务;正在使用的凭据不能删除、不能改变类型。
+- 添加服务时有"凭据"下拉框(只列类型一致的凭据,地址不匹配的不可选)。新建服务时按地址自动选择:正好有一个**限定了适用地址**的凭据匹配时选中它;手动选择后不再自动改动。
+- 编辑已有服务时可以"把这个服务的请求头 / 环境变量保存为凭据":用已保存的值新建凭据(默认适用地址为服务地址的协议 + 主机),服务改为引用它,自己的请求头 / 环境变量清空。
+- 接口:`POST /admin/v1/mcp/credentials`、`PUT|DELETE /admin/v1/mcp/credentials/:id`、`POST /admin/v1/mcp/servers/:id/save-credential`;`GET /admin/v1/mcp` 返回 `credentials`。
+
+## 5. 不包括
+
+OAuth 授权的服务:令牌与各自的授权流程绑定,按服务保存,不纳入凭据。
+
+## 6. 实施记录
+
+- `services/mcp-credentials.ts`(凭据、前缀匹配、合并、保存为凭据),`services/mcp-servers.ts` 在连接前合并;迁移 `0022_mcp_credentials`。
+- 后台 `McpView.vue`。
+- 测试 `test/mcp-credentials.test.ts`:前缀匹配与规范化、引用凭据连接、同名覆盖、地址 / 类型限制、更换令牌后重新连接、保存为凭据、本地进程的环境变量。

+ 25 - 0
packages/shared/src/api.ts

@@ -578,6 +578,30 @@ export interface McpServerInput {
   /** stdio:沙箱内额外只读 / 可写的路径 */
   readPaths: string[]
   writePaths: string[]
+  /** 使用的凭据(http 补充请求头,stdio 补充环境变量;服务自己填写的同名项优先),不使用时为 null */
+  credentialId?: string | null
+}
+
+/**
+ * MCP 凭据(docs/design/20-mcp-credentials.md):多个服务共用的请求头或环境变量,修改后使用它的服务都用新值。
+ * http 凭据可以限定适用地址(前缀匹配),不填时可用于任意地址
+ */
+export interface McpCredentialInput {
+  name: string
+  description: string
+  /** http:请求头;stdio:环境变量 */
+  kind: 'http' | 'stdio'
+  values: McpSecretEntry[]
+  /** http:适用地址前缀,如 https://api.example.com 或 https://api.example.com/mcp/;空数组表示任意地址 */
+  urlPrefixes: string[]
+}
+
+export interface McpCredentialDto extends McpCredentialInput {
+  id: string
+  /** 使用这个凭据的服务 */
+  usedBy: { id: string; name: string; title: string }[]
+  createdAt: string
+  updatedAt: string
 }
 
 export interface McpServerDto extends McpServerInput {
@@ -658,6 +682,7 @@ export interface McpBuiltinToolDto {
 export interface McpStateDto {
   builtin: McpBuiltinToolDto[]
   servers: McpServerDto[]
+  credentials: McpCredentialDto[]
   /** 本地进程沙箱(bubblewrap)是否可用 */
   sandboxAvailable: boolean
 }