Преглед на файлове

feat: 按 MCP 规范补齐工具以外的能力(工具提示与总是审批、结构化输出、工具列表变化、长任务 Tasks、询问用户、OAuth、资源与提示模板);MCP 接入指南与参考服务;SDK 0.6.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang преди 5 дни
родител
ревизия
29fb53b51e
променени са 71 файла, в които са добавени 18400 реда и са изтрити 237 реда
  1. 4 1
      apps/admin/src/lib/api.ts
  2. 109 11
      apps/admin/src/views/McpView.vue
  3. 1 0
      apps/server/drizzle/0014_mcp_tool_hints.sql
  4. 6 0
      apps/server/drizzle/0015_mcp_tasks.sql
  5. 19 0
      apps/server/drizzle/0016_interactions_form.sql
  6. 1 0
      apps/server/drizzle/0017_mcp_oauth.sql
  7. 1 0
      apps/server/drizzle/0018_mcp_features.sql
  8. 2748 0
      apps/server/drizzle/meta/0014_snapshot.json
  9. 2791 0
      apps/server/drizzle/meta/0015_snapshot.json
  10. 2791 0
      apps/server/drizzle/meta/0016_snapshot.json
  11. 2798 0
      apps/server/drizzle/meta/0017_snapshot.json
  12. 2806 0
      apps/server/drizzle/meta/0018_snapshot.json
  13. 35 0
      apps/server/drizzle/meta/_journal.json
  14. 1 1
      apps/server/scripts/try-claude-adapter.ts
  15. 5 3
      apps/server/src/api-reference/bot-reference.ts
  16. 40 5
      apps/server/src/api-reference/reference.ts
  17. 10 1
      apps/server/src/app.ts
  18. 47 3
      apps/server/src/db/schema.ts
  19. 30 1
      apps/server/src/http/routes/admin.ts
  20. 28 5
      apps/server/src/http/routes/v1.ts
  21. 1 1
      apps/server/src/http/server.ts
  22. 79 13
      apps/server/src/mcp-gateway/background.ts
  23. 134 0
      apps/server/src/mcp-gateway/elicitation.ts
  24. 130 0
      apps/server/src/mcp-gateway/results.ts
  25. 81 100
      apps/server/src/mcp-gateway/server.ts
  26. 221 0
      apps/server/src/mcp-gateway/tasks.ts
  27. 3 3
      apps/server/src/mcp-gateway/tokens.ts
  28. 201 29
      apps/server/src/mcp-gateway/upstream.ts
  29. 2 2
      apps/server/src/providers/types.ts
  30. 87 4
      apps/server/src/services/interactions.ts
  31. 215 0
      apps/server/src/services/mcp-content.ts
  32. 185 0
      apps/server/src/services/mcp-oauth.ts
  33. 100 16
      apps/server/src/services/mcp-servers.ts
  34. 2 2
      apps/server/src/services/runs.ts
  35. 1 5
      apps/server/src/teams/routes.ts
  36. 3 3
      apps/server/test/codex.test.ts
  37. 202 0
      apps/server/test/mcp-elicitation.test.ts
  38. 198 0
      apps/server/test/mcp-hints.test.ts
  39. 186 0
      apps/server/test/mcp-oauth.test.ts
  40. 114 0
      apps/server/test/mcp-reference.test.ts
  41. 147 0
      apps/server/test/mcp-resources.test.ts
  42. 203 0
      apps/server/test/mcp-tasks.test.ts
  43. 1 1
      apps/server/test/mcp.test.ts
  44. 5 1
      docs/README.md
  45. 4 0
      docs/adr/0021-third-party-mcp-via-gateway.md
  46. 21 0
      docs/adr/0025-mcp-spec-features.md
  47. 1 1
      docs/design/09-admin-ui.md
  48. 1 1
      docs/design/15-background-calls.md
  49. 171 0
      docs/design/16-mcp-support.md
  50. 477 0
      docs/guides/mcp-guide.md
  51. 25 3
      docs/integration.md
  52. 12 0
      examples/mcp-reference-server/README.md
  53. 13 0
      examples/mcp-reference-server/package.json
  54. 276 0
      examples/mcp-reference-server/server.mjs
  55. 3 3
      examples/platform-demo/package.json
  56. 1 1
      packages/chat-ui/package.json
  57. 5 1
      packages/chat-ui/src/components/AgentChat.vue
  58. 6 1
      packages/chat-ui/src/components/Composer.vue
  59. 157 2
      packages/chat-ui/src/components/InteractionCard.vue
  60. 285 0
      packages/chat-ui/src/components/McpPicker.vue
  61. 13 3
      packages/chat-ui/src/store.ts
  62. 3 2
      packages/chat-ui/src/team-store.ts
  63. 1 1
      packages/client/package.json
  64. 12 0
      packages/client/src/index.ts
  65. 1 1
      packages/shared/package.json
  66. 93 2
      packages/shared/src/api.ts
  67. 4 0
      packages/shared/src/background.ts
  68. 31 2
      packages/shared/src/events.ts
  69. 2 2
      packages/shared/src/teams.ts
  70. 9 0
      pnpm-lock.yaml
  71. 1 0
      pnpm-workspace.yaml

+ 4 - 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, SkillScanDto, SkillSourceDto, SkillSourceInput, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
+  ApiKeyDto, ApiKeyProfiles, BotDto, BotInput, BotKeyDto, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, SkillScanDto, SkillSourceDto, SkillSourceInput, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto, 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'
 
@@ -121,8 +121,11 @@ export const admin = {
     remove: (id: string) => req<void>('DELETE', `/mcp/servers/${id}`),
     refresh: (id: string) => req<McpServerDto>('POST', `/mcp/servers/${id}/refresh`),
     setToolPolicy: (id: string, tool: string, policy: McpToolPolicy) => req<McpServerDto>('PUT', `/mcp/servers/${id}/tools/${encodeURIComponent(tool)}`, { policy }),
+    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 }),
+    oauthStart: (id: string) => req<{ authorizationUrl: string | null }>('POST', `/mcp/servers/${id}/oauth/start`),
+    oauthRevoke: (id: string) => req<void>('DELETE', `/mcp/servers/${id}/oauth`),
   },
   communitySkills: {
     list: () => req<CommunitySkillDto[]>('GET', '/community-skills'),

+ 109 - 11
apps/admin/src/views/McpView.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 // MCP 服务:内置工具(MCP Gateway 自带)与第三方服务(远程 HTTP / 本地进程,经网关转接,ADR-0021)
-import { onMounted, reactive, ref } from 'vue'
-import type { McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolPolicy } from '@agent-server/shared'
+import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
+import type { 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'
@@ -10,7 +10,15 @@ const state = ref<McpStateDto | null>(null)
 const busy = ref<string | null>(null)
 const expanded = reactive<Record<string, boolean>>({})
 
-const POLICY_LABELS: Record<McpToolPolicy, string> = { auto: '直接调用', ask: '跟随权限模式', off: '关闭' }
+const POLICY_LABELS: Record<McpToolPolicy, string> = { auto: '直接调用', ask: '跟随权限模式', confirm: '总是审批', off: '关闭' }
+const HINT_LABELS: Record<keyof McpToolHints, string> = { readOnly: '只读', destructive: '破坏性', idempotent: '幂等' }
+const HINT_TIPS: Record<keyof McpToolHints, string> = {
+  readOnly: '只读:不修改任何东西。默认直接调用;只读模式下只提供只读工具',
+  destructive: '破坏性:会删除或覆盖数据。默认"总是审批",自动模式下也要确认',
+  idempotent: '幂等:重复调用结果相同。遇到网络错误时自动重试一次',
+}
+const hintsOpen = reactive<Record<string, boolean>>({})
+const origin = window.location.origin
 
 async function load() {
   try {
@@ -47,7 +55,43 @@ const refresh = (s: McpServerDto) => act(`refresh:${s.id}`, async () => {
   return r
 })
 const setPolicy = (s: McpServerDto, tool: string, policy: McpToolPolicy) => act(`tool:${s.id}:${tool}`, () => admin.mcp.setToolPolicy(s.id, tool, policy))
+function setHint(s: McpServerDto, t: McpToolDto, key: keyof McpToolHints, value: string) {
+  const next: McpToolHints = { ...t.overrides }
+  if (value === '') delete next[key]
+  else next[key] = value === 'true'
+  return act(`hints:${s.id}:${t.name}`, () => admin.mcp.setToolHints(s.id, t.name, next))
+}
+const declaredText = (v: boolean | null) => (v === null ? '未声明' : v ? '是' : '否')
 const setBackground = (s: McpServerDto, tool: string, background: boolean) => act(`bg:${s.id}:${tool}`, () => admin.mcp.setToolBackground(s.id, tool, background))
+// OAuth 授权(docs/design/16-mcp-support.md §5):在新窗口中登录并同意,回调页通知这里刷新
+async function authorize(s: McpServerDto) {
+  // 先打开窗口再填地址:避免浏览器把异步打开的窗口当作弹窗拦截
+  const win = window.open('about:blank', 'mcp-oauth', 'width=620,height=760')
+  let navigated = false
+  await act(`oauth:${s.id}`, async () => {
+    const r = await admin.mcp.oauthStart(s.id)
+    if (!r.authorizationUrl) {
+      toast('已授权', 'success')
+      return admin.mcp.state().then(st => st.servers.find(x => x.id === s.id))
+    }
+    navigated = true
+    if (win) win.location.href = r.authorizationUrl
+    else window.location.href = r.authorizationUrl
+  })
+  if (!navigated) win?.close()
+}
+const revokeOAuth = (s: McpServerDto) => {
+  if (!window.confirm(`清除「${s.title}」的授权?使用它的会话将无法调用这些工具,直到重新授权。`)) return
+  return act(`oauth:${s.id}`, async () => {
+    await admin.mcp.oauthRevoke(s.id)
+    await load()
+  }, '已清除授权')
+}
+function onOAuthMessage(e: MessageEvent) {
+  if (e.origin !== window.location.origin || (e.data as { type?: string })?.type !== 'mcp-oauth') return
+  void load()
+  toast((e.data as { ok: boolean }).ok ? '授权完成' : '授权未完成,请查看授权窗口中的说明', (e.data as { ok: boolean }).ok ? 'success' : 'error')
+}
 const toggle = (s: McpServerDto) => act(`toggle:${s.id}`, () => admin.mcp.update(s.id, { ...toInput(s), enabled: !s.enabled }), s.enabled ? '已停用' : '已启用')
 async function remove(s: McpServerDto) {
   if (!window.confirm(`删除 MCP 服务「${s.title}」?使用它的 Profile 将不再提供这些工具${s.transport === 'stdio' ? ',它的本地数据目录也会删除' : ''}。`)) return
@@ -71,13 +115,14 @@ const testResult = ref<McpTestResultDto | null>(null)
 const saving = ref(false)
 
 const blank = (): McpServerInput => ({
-  name: '', title: '', description: '', transport: 'http', enabled: true, url: '', headers: [], command: '', args: [], env: [],
+  name: '', title: '', description: '', transport: 'http', enabled: true, url: '', headers: [], oauth: { enabled: false, clientId: '', clientSecret: null, scope: '' }, command: '', args: [], env: [],
   allowNetwork: false, readPaths: [], writePaths: [],
 })
 function toInput(s: McpServerDto): McpServerInput {
   return {
     name: s.name, title: s.title, description: s.description, transport: s.transport, enabled: s.enabled, url: s.url,
-    headers: s.headers.map(h => ({ name: h.name, value: null, isSet: h.isSet })), command: s.command, args: [...s.args],
+    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],
   }
 }
@@ -94,6 +139,7 @@ function collect(): McpServerInput {
   return {
     ...d.input,
     headers: d.input.headers.filter(h => h.name.trim()).map(h => ({ name: h.name.trim(), value: h.value === '' ? null : h.value })),
+    oauth: d.input.oauth ? { ...d.input.oauth, clientSecret: d.input.oauth.clientSecret === '' ? null : d.input.oauth.clientSecret } : undefined,
     env: d.input.env.filter(e => e.name.trim()).map(e => ({ name: e.name.trim(), value: e.value === '' ? null : e.value })),
     args: argLines(d.argsText), readPaths: lines(d.readText), writePaths: lines(d.writeText),
   }
@@ -125,7 +171,11 @@ async function save() {
   }
 }
 
-onMounted(load)
+onMounted(() => {
+  void load()
+  window.addEventListener('message', onOAuthMessage)
+})
+onBeforeUnmount(() => window.removeEventListener('message', onOAuthMessage))
 </script>
 
 <template>
@@ -140,7 +190,8 @@ onMounted(load)
           <span class="codicon codicon-info text-link text-[18px] mt-0.5" />
           <div class="flex flex-col gap-1.5 text-[12.5px]">
             <div>所有工具都经由 AgentServer 的 MCP Gateway 提供给 Claude 和 Codex:配置一次,两个引擎共用;密钥只保存在服务端。</div>
-            <div>第三方服务需要<b>在 Profile 中勾选</b>才会出现在对话中;工具名为 <code class="font-mono">服务名__工具名</code>。每个工具可以设为:直接调用、跟随权限模式("需审批"时弹出审批卡片,"自动"时直接调用,"只读"时只提供只读工具)、关闭。</div>
+            <div>第三方服务需要<b>在 Profile 中勾选</b>才会出现在对话中;工具名为 <code class="font-mono">服务名__工具名</code>。每个工具可以设为:直接调用、跟随权限模式("需审批"时弹出审批卡片,"自动"时直接调用,"只读"时只提供只读工具)、总是审批(自动模式下也要确认,每次都问)、关闭。</div>
+            <div>工具的提示(只读、破坏性、幂等)由服务声明,决定默认策略和出错时能否自动重试;声明不准确时可以在"提示"中覆盖。</div>
             <div class="hint">本地进程类服务在独立的沙箱中运行:系统目录只读、独立的数据目录、默认不能联网,只能访问你开放的路径。</div>
           </div>
         </section>
@@ -169,8 +220,15 @@ onMounted(load)
               <span class="font-600">{{ s.title }}</span>
               <code class="font-mono text-[12px] text-fg-muted">{{ s.name }}__*</code>
               <span class="badge bg-code">{{ s.transport === 'http' ? '远程 HTTP' : '本地进程' }}</span>
+              <span v-if="s.features.resources" class="badge bg-code text-fg-muted" title="提供资源(Resources):模型可以按需读取,用户可以在输入框的「资料」中附加">资源</span>
+              <span v-if="s.features.prompts" class="badge bg-code text-fg-muted" title="提供提示模板(Prompts):用户可以在输入框的「快捷指令」中使用">提示模板</span>
               <span v-if="!s.enabled" class="badge bg-code text-fg-muted">已停用</span>
+              <span v-if="s.oauthStatus" class="badge bg-code" :class="s.oauthStatus.authorized ? 'text-success' : 'text-warning'">{{ s.oauthStatus.authorized ? 'OAuth 已授权' : 'OAuth 未授权' }}</span>
               <span class="flex-1" />
+              <button v-if="s.oauthStatus" type="button" class="btn-ghost" :disabled="!!busy" @click="authorize(s)">
+                <span class="codicon" :class="busy === `oauth:${s.id}` ? 'codicon-loading codicon-modifier-spin' : 'codicon-key'" />{{ s.oauthStatus.authorized ? '重新授权' : '授权' }}
+              </button>
+              <button v-if="s.oauthStatus?.authorized" type="button" class="btn-ghost" :disabled="!!busy" @click="revokeOAuth(s)">清除授权</button>
               <button type="button" class="btn-ghost" :disabled="!!busy" @click="refresh(s)">
                 <span class="codicon" :class="busy === `refresh:${s.id}` ? 'codicon-loading codicon-modifier-spin' : 'codicon-sync'" />刷新工具
               </button>
@@ -187,6 +245,8 @@ onMounted(load)
               {{ s.profileNames.length ? `使用它的 Profile:${s.profileNames.join('、')}` : '还没有 Profile 勾选这个服务' }}
               <template v-if="s.toolsUpdatedAt"> · 工具列表更新于 {{ formatTime(s.toolsUpdatedAt) }}</template>
             </div>
+            <div v-if="s.oauthStatus?.lastError" class="text-warning text-[12px] whitespace-pre-wrap break-all">OAuth:{{ s.oauthStatus.lastError }}</div>
+            <div v-else-if="s.oauthStatus?.authorizedAt" class="hint">OAuth 授权于 {{ formatTime(s.oauthStatus.authorizedAt) }}(所有使用这个服务的会话共用这个账号)</div>
             <div v-if="s.lastError" class="text-error text-[12px] whitespace-pre-wrap break-all">连接失败:{{ s.lastError }}</div>
             <pre v-if="s.stderrTail && s.status === 'error'" class="m-0 p-2 rounded-md bg-code font-mono text-[11.5px] max-h-40 overflow-auto whitespace-pre-wrap">{{ s.stderrTail }}</pre>
 
@@ -196,12 +256,30 @@ onMounted(load)
             <div v-if="expanded[s.id]" class="flex flex-col divide-y divide-x-0 divide-solid divide-border border border-solid border-border rounded-md">
               <div v-for="t in s.tools" :key="t.name" class="flex items-start gap-3 px-3 py-2 text-[12.5px]">
                 <div class="flex-1 min-w-0">
-                  <div class="flex items-center gap-2">
+                  <div class="flex items-center gap-2 flex-wrap">
                     <span class="font-mono">{{ t.name }}</span>
-                    <span v-if="t.readOnly" class="badge bg-code text-success">只读</span>
+                    <span v-if="t.title" class="text-fg-muted">{{ t.title }}</span>
+                    <span v-if="t.readOnly" class="badge bg-code text-success" :title="HINT_TIPS.readOnly">只读</span>
+                    <span v-if="t.destructive" class="badge bg-code text-error" :title="HINT_TIPS.destructive">破坏性</span>
+                    <span v-if="t.idempotent && !t.readOnly" class="badge bg-code" :title="HINT_TIPS.idempotent">幂等</span>
+                    <span v-if="t.openWorld && !t.readOnly" class="badge bg-code text-fg-muted" title="会与外部世界交互(如发送消息、调用第三方服务);调用记录中会标出">外部交互</span>
+                    <span v-if="t.hasOutputSchema" class="badge bg-code text-fg-muted" title="声明了返回结构(outputSchema):结果会按结构校验">结构化输出</span>
+                    <span v-if="t.taskSupport" class="badge bg-code text-link" :title="t.taskSupport === 'required' ? '只能以长任务方式调用' : '支持以长任务方式调用'">长任务</span>
+                    <span v-if="Object.keys(t.overrides).length" class="badge bg-code text-warning" title="提示已被管理员覆盖">已覆盖</span>
                   </div>
                   <div class="hint line-clamp-2">{{ t.description }}</div>
+                  <div v-if="hintsOpen[`${s.id}:${t.name}`]" class="flex flex-wrap gap-3 mt-1.5">
+                    <label v-for="(label, k) in HINT_LABELS" :key="k" class="flex items-center gap-1" :title="HINT_TIPS[k]">
+                      {{ label }}
+                      <select class="field w-36 h-6" :value="t.overrides[k] === undefined ? '' : String(t.overrides[k])" :disabled="!!busy" :aria-label="`${t.name} 的${label}提示`" @change="setHint(s, t, k, ($event.target as HTMLSelectElement).value)">
+                        <option value="">沿用声明({{ declaredText(t.declared[k]) }})</option>
+                        <option value="true">是</option>
+                        <option value="false">否</option>
+                      </select>
+                    </label>
+                  </div>
                 </div>
+                <button type="button" class="btn-ghost flex-none h-7" title="查看或覆盖工具提示" @click="hintsOpen[`${s.id}:${t.name}`] = !hintsOpen[`${s.id}:${t.name}`]">提示</button>
                 <label class="flex items-center gap-1 flex-none h-7 cursor-pointer whitespace-nowrap" title="总是在后台执行:调用后 AI 先结束这一轮,完成后再把结果发给它。适合执行时间很长的工具;其他工具超过一定时间也会自动转到后台">
                   <input type="checkbox" :checked="t.background" :disabled="!!busy" @change="setBackground(s, t.name, ($event.target as HTMLInputElement).checked)">后台执行
                 </label>
@@ -246,10 +324,30 @@ onMounted(load)
           <div>
             <label class="label" for="m-url">服务地址</label>
             <input id="m-url" v-model="draft.input.url" class="field font-mono" placeholder="https://example.com/mcp" required>
-            <div class="hint mt-1">支持 Streamable HTTP,旧版 SSE 服务自动兼容。需要 OAuth 登录的服务暂不支持,可在请求头中填写访问令牌。</div>
+            <div class="hint mt-1">支持 Streamable HTTP,旧版 SSE 服务自动兼容。</div>
+          </div>
+          <div v-if="draft.input.oauth" class="flex flex-col gap-2">
+            <label class="flex items-center gap-2 cursor-pointer"><input v-model="draft.input.oauth.enabled" type="checkbox">使用 OAuth 授权(服务要求登录授权时启用;保存后在服务卡片上点"授权")</label>
+            <template v-if="draft.input.oauth.enabled">
+              <div class="grid grid-cols-2 gap-3">
+                <div>
+                  <label class="label" for="m-cid">Client ID(可选)</label>
+                  <input id="m-cid" v-model="draft.input.oauth.clientId" class="field font-mono" placeholder="留空则自动注册">
+                </div>
+                <div>
+                  <label class="label" for="m-csec">Client Secret(可选)</label>
+                  <input id="m-csec" v-model="draft.input.oauth.clientSecret" type="password" autocomplete="new-password" class="field font-mono" :placeholder="draft.id && state?.servers.find(x => x.id === draft!.id)?.oauthStatus?.clientSecretSet ? '已设置,留空保持不变' : ''">
+                </div>
+              </div>
+              <div>
+                <label class="label" for="m-scope">权限范围 scope(可选,空格分隔)</label>
+                <input id="m-scope" v-model="draft.input.oauth.scope" class="field font-mono" placeholder="例如 read write">
+              </div>
+              <div class="hint">授权按服务共享:所有使用这个服务的会话用同一个账号。令牌由服务端保存,过期前自动续期。回调地址为 <code class="font-mono">{{ origin }}/admin/v1/mcp/oauth/callback</code>(需要预先登记客户端时填这个地址)。</div>
+            </template>
           </div>
           <div>
-            <div class="label">请求头(如 Authorization: Bearer …)</div>
+            <div class="label">请求头(如 Authorization: Bearer …;使用 OAuth 时不需要)</div>
             <div v-for="(h, i) in draft.input.headers" :key="i" class="flex gap-2 mb-1.5">
               <input v-model="h.name" class="field w-48 font-mono" placeholder="名称" :aria-label="`请求头 ${i + 1} 名称`">
               <input v-model="h.value" type="password" autocomplete="new-password" class="field font-mono" :placeholder="h.isSet ? '已设置,留空保持不变' : '值'" :aria-label="`请求头 ${i + 1} 值`">

+ 1 - 0
apps/server/drizzle/0014_mcp_tool_hints.sql

@@ -0,0 +1 @@
+ALTER TABLE `mcp_servers` ADD `tool_hints` text DEFAULT '{}' NOT NULL;

+ 6 - 0
apps/server/drizzle/0015_mcp_tasks.sql

@@ -0,0 +1,6 @@
+ALTER TABLE `background_calls` ADD `mode` text DEFAULT 'wait' NOT NULL;--> statement-breakpoint
+ALTER TABLE `background_calls` ADD `mcp_task_id` text;--> statement-breakpoint
+ALTER TABLE `background_calls` ADD `conn_scope` text;--> statement-breakpoint
+ALTER TABLE `background_calls` ADD `poll_after` text;--> statement-breakpoint
+ALTER TABLE `background_calls` ADD `poll_error_since` text;--> statement-breakpoint
+ALTER TABLE `background_calls` ADD `status_message` text;

+ 19 - 0
apps/server/drizzle/0016_interactions_form.sql

@@ -0,0 +1,19 @@
+PRAGMA foreign_keys=OFF;--> statement-breakpoint
+CREATE TABLE `__new_interactions` (
+	`id` text PRIMARY KEY NOT NULL,
+	`session_id` text NOT NULL,
+	`run_id` text,
+	`kind` text NOT NULL,
+	`request` text NOT NULL,
+	`response` text,
+	`status` text NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`resolved_at` text,
+	`expires_at` text NOT NULL
+);
+--> statement-breakpoint
+INSERT INTO `__new_interactions`("id", "session_id", "run_id", "kind", "request", "response", "status", "created_at", "resolved_at", "expires_at") SELECT "id", "session_id", "run_id", "kind", "request", "response", "status", "created_at", "resolved_at", "expires_at" FROM `interactions`;--> statement-breakpoint
+DROP TABLE `interactions`;--> statement-breakpoint
+ALTER TABLE `__new_interactions` RENAME TO `interactions`;--> statement-breakpoint
+PRAGMA foreign_keys=ON;--> statement-breakpoint
+CREATE INDEX `interactions_session_idx` ON `interactions` (`session_id`,`status`);

+ 1 - 0
apps/server/drizzle/0017_mcp_oauth.sql

@@ -0,0 +1 @@
+ALTER TABLE `mcp_servers` ADD `oauth` text;

+ 1 - 0
apps/server/drizzle/0018_mcp_features.sql

@@ -0,0 +1 @@
+ALTER TABLE `mcp_servers` ADD `features` text DEFAULT '{}' NOT NULL;

+ 2748 - 0
apps/server/drizzle/meta/0014_snapshot.json

@@ -0,0 +1,2748 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "685fb771-aedb-4484-af3f-cb55565dc38b",
+  "prevId": "658fbfb0-0f4e-4af7-8342-281a25f14477",
+  "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
+        }
+      },
+      "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
+        },
+        "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": "''"
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source_id": {
+          "name": "source_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "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": true,
+          "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_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": "'{}'"
+        },
+        "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
+        },
+        "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_sources": {
+      "name": "skill_sources",
+      "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
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "sync_mode": {
+          "name": "sync_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'manual'"
+        },
+        "interval_min": {
+          "name": "interval_min",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 10
+        },
+        "last_sync_at": {
+          "name": "last_sync_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_sync_digest": {
+          "name": "last_sync_digest",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'never'"
+        },
+        "last_error": {
+          "name": "last_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'))"
+        },
+        "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": {}
+    },
+    "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": {}
+  }
+}

+ 2791 - 0
apps/server/drizzle/meta/0015_snapshot.json

@@ -0,0 +1,2791 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "25757f9e-0267-45e8-bc47-9a12c0ff1d85",
+  "prevId": "685fb771-aedb-4484-af3f-cb55565dc38b",
+  "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
+        },
+        "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": "''"
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source_id": {
+          "name": "source_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "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": true,
+          "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_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": "'{}'"
+        },
+        "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
+        },
+        "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_sources": {
+      "name": "skill_sources",
+      "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
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "sync_mode": {
+          "name": "sync_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'manual'"
+        },
+        "interval_min": {
+          "name": "interval_min",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 10
+        },
+        "last_sync_at": {
+          "name": "last_sync_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_sync_digest": {
+          "name": "last_sync_digest",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'never'"
+        },
+        "last_error": {
+          "name": "last_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'))"
+        },
+        "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": {}
+    },
+    "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": {}
+  }
+}

+ 2791 - 0
apps/server/drizzle/meta/0016_snapshot.json

@@ -0,0 +1,2791 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "eee08c04-6bd9-42a5-bd69-a6bfaf5dece3",
+  "prevId": "25757f9e-0267-45e8-bc47-9a12c0ff1d85",
+  "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
+        },
+        "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": "''"
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source_id": {
+          "name": "source_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "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_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": "'{}'"
+        },
+        "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
+        },
+        "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_sources": {
+      "name": "skill_sources",
+      "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
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "sync_mode": {
+          "name": "sync_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'manual'"
+        },
+        "interval_min": {
+          "name": "interval_min",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 10
+        },
+        "last_sync_at": {
+          "name": "last_sync_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_sync_digest": {
+          "name": "last_sync_digest",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'never'"
+        },
+        "last_error": {
+          "name": "last_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'))"
+        },
+        "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": {}
+    },
+    "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": {}
+  }
+}

+ 2798 - 0
apps/server/drizzle/meta/0017_snapshot.json

@@ -0,0 +1,2798 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "68450877-b219-4d12-bf07-7daa4aae73da",
+  "prevId": "eee08c04-6bd9-42a5-bd69-a6bfaf5dece3",
+  "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
+        },
+        "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": "''"
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source_id": {
+          "name": "source_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "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_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
+        },
+        "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
+        },
+        "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_sources": {
+      "name": "skill_sources",
+      "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
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "sync_mode": {
+          "name": "sync_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'manual'"
+        },
+        "interval_min": {
+          "name": "interval_min",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 10
+        },
+        "last_sync_at": {
+          "name": "last_sync_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_sync_digest": {
+          "name": "last_sync_digest",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'never'"
+        },
+        "last_error": {
+          "name": "last_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'))"
+        },
+        "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": {}
+    },
+    "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": {}
+  }
+}

+ 2806 - 0
apps/server/drizzle/meta/0018_snapshot.json

@@ -0,0 +1,2806 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "452d9b92-c60b-4563-a10e-5fc573e69617",
+  "prevId": "68450877-b219-4d12-bf07-7daa4aae73da",
+  "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
+        },
+        "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": "''"
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source_id": {
+          "name": "source_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "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_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
+        },
+        "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_sources": {
+      "name": "skill_sources",
+      "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
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "sync_mode": {
+          "name": "sync_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'manual'"
+        },
+        "interval_min": {
+          "name": "interval_min",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 10
+        },
+        "last_sync_at": {
+          "name": "last_sync_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_sync_digest": {
+          "name": "last_sync_digest",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'never'"
+        },
+        "last_error": {
+          "name": "last_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'))"
+        },
+        "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": {}
+    },
+    "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": {}
+  }
+}

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

@@ -99,6 +99,41 @@
       "when": 1793160347080,
       "tag": "0013_profile_codex_plugins",
       "breakpoints": true
+    },
+    {
+      "idx": 14,
+      "version": "6",
+      "when": 1793263125060,
+      "tag": "0014_mcp_tool_hints",
+      "breakpoints": true
+    },
+    {
+      "idx": 15,
+      "version": "6",
+      "when": 1793263584748,
+      "tag": "0015_mcp_tasks",
+      "breakpoints": true
+    },
+    {
+      "idx": 16,
+      "version": "6",
+      "when": 1793263832005,
+      "tag": "0016_interactions_form",
+      "breakpoints": true
+    },
+    {
+      "idx": 17,
+      "version": "6",
+      "when": 1793264079508,
+      "tag": "0017_mcp_oauth",
+      "breakpoints": true
+    },
+    {
+      "idx": 18,
+      "version": "6",
+      "when": 1793264349381,
+      "tag": "0018_mcp_features",
+      "breakpoints": true
     }
   ]
 }

+ 1 - 1
apps/server/scripts/try-claude-adapter.ts

@@ -23,7 +23,7 @@ const res = await new ClaudeProvider().run({
   },
   interact: async req => {
     console.log('[interact]', JSON.stringify(req).slice(0, 300))
-    return req.kind === 'approval' ? { resolution: 'allowed', scope: 'once' } : { resolution: 'answered', answers: { '0': req.questions[0]!.options[0]!.label } }
+    return req.kind !== 'question' ? { resolution: 'allowed', scope: 'once' } : { resolution: 'answered', answers: { '0': req.questions[0]!.options[0]!.label } }
   },
   setProviderSessionId: id => console.log('[providerSessionId]', id),
   audit: (kind, summary) => console.log(`[audit] ${kind}: ${summary}`),

+ 5 - 3
apps/server/src/api-reference/bot-reference.ts

@@ -193,12 +193,14 @@ export const BOT_GROUPS: ApiEndpointGroup[] = [
         response: { status: 200, type: 'text/event-stream', desc: '每条为 `id: <seq>`、`event: <type>`、`data: <TeamEvent JSON>`' },
       },
       {
-        method: 'POST', path: team('/interactions/:iid'), summary: '回复审批或提问',
-        desc: '机器人触发审批或提问时,事件流中出现 interaction.required;由成员在这里回复。超时按拒绝处理。',
+        method: 'POST', path: team('/interactions/:iid'), summary: '回复审批、提问或表单',
+        desc: '机器人触发审批或提问、外部 MCP 服务向用户询问信息(kind 为 form)时,事件流中出现 interaction.required;由成员在这里回复。超时按拒绝处理(表单按"暂不处理")。',
         body: [
           { name: 'decision', type: '"allow_once" | "allow_session" | "deny"', desc: '审批的决定' },
           { name: 'reason', type: 'string', desc: '拒绝的理由(可选)' },
           { name: 'answers', type: 'Record<问题 id, string | string[]>', desc: '提问的回答' },
+          { name: 'action', type: '"accept" | "decline" | "cancel"', desc: '表单:提交(url 模式表示已完成)、拒绝、暂不处理' },
+          { name: 'content', type: 'Record<字段 name, string | number | boolean | string[]>', desc: '表单提交的内容(action 为 accept 时)' },
         ],
         response: { status: 200, type: '{ ok: true }' },
         sampleBody: { decision: 'allow_once' },
@@ -429,7 +431,7 @@ export const BOT_TYPES: ApiTypeDef[] = [
 export const BOT_EVENTS: ApiEventDef[] = [
   { type: 'message.created', payload: 'TeamMessage', desc: '新消息:成员的命令、机器人的回复(交付文件在 files 中)、系统消息(成员变化、执行失败、暂停等)', persisted: true },
   { type: 'interaction.required', payload: '{ botId, sessionId, request: InteractionRequest }', desc: '机器人需要成员审批或回答问题', persisted: true },
-  { type: 'interaction.resolved', payload: '{ botId, sessionId, interactionId, resolution, answers?, reason? }', desc: 'resolution:allowed / denied / answered / timeout / cancelled', persisted: true },
+  { type: 'interaction.resolved', payload: '{ botId, sessionId, interactionId, resolution, answers?, reason?, content? }', desc: 'resolution:allowed / denied / answered / timeout / cancelled(表单提交为 answered,带 content)', persisted: true },
   { type: 'bot.status', payload: '{ botId, status, sessionId, runId }', desc: '机器人状态变化:idle / queued / working / waiting_input', persisted: false },
   { type: 'file.changed', payload: '{ file: File, kind: "created" | "modified" }', desc: '共享目录中新建或修改了文件', persisted: false },
   { type: 'team.updated', payload: 'Team', desc: '工作组的标题、状态或成员变化', persisted: false },

+ 40 - 5
apps/server/src/api-reference/reference.ts

@@ -187,8 +187,8 @@ data: {"seq":12,"type":"message.delta","sessionId":"ses_…","runId":"run_…","
       },
       {
         method: 'POST', path: '/v1/interactions/:id', summary: '回复交互',
-        desc: '审批回复 decision;提问回复 answers(键为问题 id,多选时为数组)。拒绝时的 reason 会转告 Agent。',
-        bodyNote: 'InteractionResponse(三选一):{ decision: "allow_once" | "allow_session" } | { decision: "deny", reason? } | { answers: { [questionId]: string | string[] } }',
+        desc: '审批回复 decision;提问回复 answers(键为问题 id,多选时为数组);表单(外部服务的询问)回复 action:accept 带 content(键为字段 name),decline 为拒绝,cancel 为暂不处理。拒绝时的 reason 会转告 Agent。',
+        bodyNote: 'InteractionResponse(四选一):{ decision: "allow_once" | "allow_session" } | { decision: "deny", reason? } | { answers: { [questionId]: string | string[] } } | { action: "accept", content?: { [field]: string | number | boolean | string[] } } 或 { action: "decline" | "cancel" }',
         response: { status: 200, type: '{ ok: true }' },
         sampleBody: { decision: 'allow_once' },
         errors: ['409 already_resolved:已被回复、超时或取消', '404 not_found'],
@@ -235,6 +235,38 @@ data: {"seq":12,"type":"message.delta","sessionId":"ses_…","runId":"run_…","
       },
     ],
   },
+  {
+    id: 'mcp-content',
+    title: '资料与快捷指令',
+    desc: '会话的 Profile 勾选的 MCP 服务可以提供资源(Resources,如文档、配置、数据表结构)和提示模板(Prompts,带参数的指令模板)。资源可以附加到消息中;提示模板展开后放进输入框,由用户修改后发送。模型也可以通过 mcp_resources / mcp_resource_read 工具按需读取资源。',
+    endpoints: [
+      {
+        method: 'GET', path: '/v1/sessions/:id/mcp/resources', summary: '列出资源',
+        desc: '只返回目录(地址、名称、说明),不含内容。不指定 server 时每个服务取第一页;指定时可以用 cursor 翻页。templates 为资源模板(地址中带 {参数}),填好参数后可以像普通资源一样附加。',
+        query: [{ name: 'server', type: 'string', desc: '服务 id 或服务名(可选)' }, { name: 'cursor', type: 'string', desc: '翻页游标(指定 server 时有效)' }],
+        response: { status: 200, type: '{ resources: [{ serverId, serverName, serverTitle, uri, name, title, description, mimeType, size }], templates: [{ serverId, serverName, serverTitle, uriTemplate, name, title, description, mimeType }], nextCursor, errors: [{ serverTitle, error }] }' },
+      },
+      {
+        method: 'POST', path: '/v1/sessions/:id/mcp/resources/attach', summary: '附加资源',
+        desc: '读取资源并保存为会话的上传文件,返回的文件 id 放进发送消息的 attachments。',
+        body: [{ name: 'server', type: 'string', required: true, desc: '服务 id 或服务名' }, { name: 'uri', type: 'string', required: true, desc: '资源地址' }],
+        response: { status: 200, type: 'File[]' }, errors: ['404 not_found:服务不在这个会话的 Profile 中,或没有提供资源', '400:资源超过上传大小上限'],
+      },
+      {
+        method: 'GET', path: '/v1/sessions/:id/mcp/prompts', summary: '列出提示模板',
+        response: { status: 200, type: '{ prompts: [{ serverId, serverName, serverTitle, name, title, description, arguments: [{ name, description, required }] }], errors }' },
+      },
+      {
+        method: 'POST', path: '/v1/sessions/:id/mcp/prompts/get', summary: '展开提示模板',
+        desc: '按参数展开模板:text 放进输入框;模板中嵌入的资源、图片已保存为会话文件,放进 attachments 一起发送。',
+        body: [
+          { name: 'server', type: 'string', required: true, desc: '服务 id 或服务名' }, { name: 'name', type: 'string', required: true, desc: '模板名' },
+          { name: 'arguments', type: 'Record<string, string>', desc: '参数' },
+        ],
+        response: { status: 200, type: '{ text, attachments: File[] }' },
+      },
+    ],
+  },
   {
     id: 'schedules',
     title: '定时任务',
@@ -364,14 +396,17 @@ export const API_TYPES: ApiTypeDef[] = [
     ],
   },
   {
-    name: 'InteractionRequest', desc: 'kind 为 approval(审批)或 question(提问)', fields: [
+    name: 'InteractionRequest', desc: 'kind 为 approval(审批)、question(提问)或 form(外部 MCP 服务向用户询问信息)', fields: [
       { name: 'interactionId', type: 'string', desc: 'itx_ 开头' },
-      { name: 'kind', type: '"approval" | "question"', desc: '' },
+      { name: 'kind', type: '"approval" | "question" | "form"', desc: '' },
       { name: 'expiresAt', type: 'string', desc: '超时时间,超时按拒绝处理' },
       { name: 'tool / title / input', type: 'approval', desc: '要执行的工具、可读摘要、原始参数' },
       { name: 'detail', type: 'approval', desc: '{ command?, path?, url?, reason? }' },
       { name: 'options', type: 'approval', desc: '可选的决定:allow_once(允许一次)、allow_session(本会话内允许)、deny' },
       { name: 'questions', type: 'question', desc: '[{ id, question, header?, multiSelect, options: [{ label, description? }] }]' },
+      { name: 'mode / source / message', type: 'form', desc: 'mode 为 form(填写表单)或 url(请用户打开 url 自行完成,如授权、付款;不要自动打开);source 为发起询问的服务;message 为说明' },
+      { name: 'fields', type: 'form', desc: '[{ name, title, description?, required, type: "string" | "number" | "integer" | "boolean" | "choice", ... }]:string 可有 format(email / uri / date / date-time)、minLength、maxLength;数字可有 minimum、maximum;choice 有 options: [{ value, label }] 与 multiple;各类型可有 default' },
+      { name: 'url', type: 'form', desc: 'url 模式要打开的网址' },
     ],
   },
 ]
@@ -388,7 +423,7 @@ export const API_EVENTS: ApiEventDef[] = [
   { type: 'tool.completed', payload: '{ toolCallId, status: "ok" | "error" | "denied", output, truncated }', desc: '工具调用结束', persisted: true },
   { type: 'file.changed', payload: '{ fileId, path, kind: "created" | "modified", size, mime }', desc: '工作区中新建或修改了文件(Agent 产出)', persisted: true },
   { type: 'interaction.required', payload: 'InteractionRequest', desc: '需要用户审批或回答问题', persisted: true },
-  { type: 'interaction.resolved', payload: '{ interactionId, resolution, answers?, reason? }', desc: 'resolution:allowed / denied / answered / timeout / cancelled。多个页面同时打开时用于同步关闭卡片', persisted: true },
+  { type: 'interaction.resolved', payload: '{ interactionId, resolution, answers?, reason?, content? }', desc: 'resolution:allowed / denied / answered / timeout / cancelled(表单提交为 answered,带 content)。多个页面同时打开时用于同步关闭卡片。外部服务在后台长任务中发来的询问不属于某一轮(runId 为 null),不改变 Run 状态', persisted: true },
   { type: 'usage', payload: 'Usage', desc: '本次 Run 的 token 用量', persisted: true },
   { type: 'run.completed', payload: '{ durationMs }', desc: 'Run 正常结束', persisted: true },
   { type: 'run.failed', payload: '{ code, message }', desc: 'Run 失败', persisted: true },

+ 10 - 1
apps/server/src/app.ts

@@ -25,12 +25,16 @@ import { UsageService } from './services/usage'
 import { PdfRenderer } from './api-reference/pdf'
 import { McpUpstream } from './mcp-gateway/upstream'
 import { McpServerService } from './services/mcp-servers'
+import { McpOAuthService } from './services/mcp-oauth'
+import { McpContentService } from './services/mcp-content'
 import { CustomSkillService } from './custom-skills/service'
 import { SkillSourceService } from './custom-skills/sources'
 import { FileManagerService } from './services/file-manager'
 import { BrowserTokenService } from './services/browser-tokens'
 import { BotKeyService, BotService } from './teams/bots'
 import { BackgroundCallService } from './mcp-gateway/background'
+import { McpTaskRunner } from './mcp-gateway/tasks'
+import { ElicitationService } from './mcp-gateway/elicitation'
 import { ScheduleService } from './schedules/service'
 import { TeamCaseService } from './teams/cases'
 import { ensureLeadProfiles } from './teams/lead-profile'
@@ -82,6 +86,8 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
     () => (providers.codex instanceof CodexProvider ? providers.codex : null), () => settings.get(),
     summary => audit.log({ actor: 'admin', kind: 'admin', summary }),
   )
+  const mcpContent = new McpContentService({ mcp: mcpServers, upstream: mcpUpstream, files, settings })
+  const mcpOAuth = new McpOAuthService(db, mcpUpstream, summary => audit.log({ actor: 'admin', kind: 'admin', summary }), id => mcpServers.refresh(id))
   sessions.extraModels = p => providers[p]?.knownModels?.() ?? []
   const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, usage, plugins, communitySkills, mcpServers, customSkills, gatewayTokens, providers, codexPlugins })
   const bots = new BotService(db, profiles)
@@ -99,9 +105,12 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   })
   const teamCases = new TeamCaseService({ db, teams, dir: teamCasesDir() })
   const backgroundCalls = new BackgroundCallService({ db, log, settings, runs, events, teams })
+  const mcpTasks = new McpTaskRunner({ log, settings, upstream: mcpUpstream, mcp: mcpServers, background: backgroundCalls })
+  const elicitation = new ElicitationService({ log, audit, interactions, background: backgroundCalls, tasks: mcpTasks })
+  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, customSkills, skillSources, fileManager, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs, bots, botKeys, teams, teamCards, teamCases, schedules, backgroundCalls, skillCatalog }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, mcpOAuth, mcpContent, customSkills, skillSources, 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>

+ 47 - 3
apps/server/src/db/schema.ts

@@ -1,7 +1,7 @@
 import { sql } from 'drizzle-orm'
 import { index, integer, primaryKey, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'
 import type {
-  FileAccessPolicy, InteractionRequest, InteractionResponse, McpToolPolicy, PermissionMode, ProfileSnapshot, ScheduleOutcome, ScheduleSpec, TeamDebug, TeamFileRef, TeamLimitKey, TeamLimits,
+  FileAccessPolicy, InteractionRequest, InteractionResponse, McpToolHints, McpToolPolicy, PermissionMode, ProfileSnapshot, ScheduleOutcome, ScheduleSpec, TeamDebug, TeamFileRef, TeamLimitKey, TeamLimits,
 } from '@agent-server/shared'
 
 const now = sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`
@@ -106,8 +106,9 @@ export const events = sqliteTable('events', {
 export const interactions = sqliteTable('interactions', {
   id: text('id').primaryKey(),
   sessionId: text('session_id').notNull(),
-  runId: text('run_id').notNull(),
-  kind: text('kind', { enum: ['approval', 'question'] }).notNull(),
+  /** 不属于某一轮的交互(后台长任务中服务发来的询问)为空 */
+  runId: text('run_id'),
+  kind: text('kind', { enum: ['approval', 'question', 'form'] }).notNull(),
   request: text('request', { mode: 'json' }).$type<InteractionRequest>().notNull(),
   response: text('response', { mode: 'json' }).$type<InteractionResponse>(),
   status: text('status', { enum: ['pending', 'allowed', 'denied', 'answered', 'timeout', 'cancelled'] }).notNull(),
@@ -196,18 +197,53 @@ export const mcpServers = sqliteTable('mcp_servers', {
   toolPolicies: text('tool_policies', { mode: 'json' }).$type<Record<string, McpToolPolicy>>().notNull().default(sql`'{}'`),
   /** 总是在后台执行的工具(docs/design/15-background-calls.md §3) */
   backgroundTools: text('background_tools', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
+  /** 管理员覆盖的工具提示(工具名 → 只读 / 破坏性 / 幂等,docs/design/16-mcp-support.md §2.1) */
+  toolHints: text('tool_hints', { mode: 'json' }).$type<Record<string, McpToolHints>>().notNull().default(sql`'{}'`),
+  /** OAuth 授权(配置、动态注册的客户端信息、令牌、授权过程中的临时值),不出现在 DTO 中 */
+  oauth: text('oauth', { mode: 'json' }).$type<McpOAuthState>(),
   /** 最近一次列出的工具(引擎列工具时直接使用,不必每次启动服务) */
   toolsCache: text('tools_cache', { mode: 'json' }).$type<McpCachedTool[]>().notNull().default(sql`'[]'`),
   toolsUpdatedAt: text('tools_updated_at'),
+  /** 服务声明的能力(最近一次连接时记录):资源、提示模板 */
+  features: text('features', { mode: 'json' }).$type<McpFeatures>().notNull().default(sql`'{}'`),
   createdAt: createdAt(),
   updatedAt: updatedAt(),
 })
 
+export interface McpFeatures {
+  resources?: boolean
+  prompts?: boolean
+}
+
+export interface McpOAuthState {
+  enabled: boolean
+  clientId: string
+  clientSecret: string
+  scope: string
+  /** 回调地址(发起授权时访问后台的地址) */
+  redirectUrl?: string
+  /** 动态注册得到的客户端信息 */
+  clientInfo?: Record<string, unknown>
+  tokens?: Record<string, unknown>
+  codeVerifier?: string
+  /** 授权进行中:state 与过期时间 */
+  state?: string
+  stateExpiresAt?: string
+  authorizedAt?: string
+  lastError?: string
+}
+
 export interface McpCachedTool {
   name: string
   description: string
   inputSchema: Record<string, unknown>
+  /** 服务声明的只读提示(annotations.readOnlyHint === true) */
   readOnly: boolean
+  title?: string
+  /** 服务声明的其他提示(未声明的不出现) */
+  annotations?: { destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint?: boolean; readOnlyHint?: boolean }
+  outputSchema?: Record<string, unknown>
+  taskSupport?: 'required' | 'optional'
 }
 
 /** 文件清单:相对路径 → 内容摘要与大小(内容存放在按 sha256 寻址的文件库中) */
@@ -435,4 +471,12 @@ export const backgroundCalls = sqliteTable('background_calls', {
   result: text('result'),
   error: text('error'),
   delivered: integer('delivered', { mode: 'boolean' }).notNull().default(false),
+  /** 等待方式:wait 保持连接等待;task 以长任务方式调用、按任务 id 查询(docs/design/16-mcp-support.md §3) */
+  mode: text('mode', { enum: ['wait', 'task'] }).notNull().default('wait'),
+  /** 长任务:服务返回的任务 id、调用时使用的连接(会话)、下次查询时间、查询连续出错的起始时间 */
+  mcpTaskId: text('mcp_task_id'),
+  connScope: text('conn_scope'),
+  pollAfter: text('poll_after'),
+  pollErrorSince: text('poll_error_since'),
+  statusMessage: text('status_message'),
 }, t => [index('background_calls_session_idx').on(t.sessionId), index('background_calls_team_idx').on(t.teamId), index('background_calls_status_idx').on(t.status, t.delivered)])

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

@@ -63,6 +63,14 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
 
   f.get('/auth/me', async req => ({ loggedIn: isAdmin(app, req) }))
 
+  // OAuth 授权回调(docs/design/16-mcp-support.md §5):授权服务器跳转回来时不带后台登录(Cookie 为 SameSite=Strict),
+  // 凭一次性的 state 找到发起授权的服务
+  f.get<{ Querystring: Record<string, string> }>('/mcp/oauth/callback', async (req, reply) => {
+    const r = await app.mcpOAuth.callback(req.query)
+    reply.type('text/html; charset=utf-8').header('cache-control', 'no-store')
+    return oauthResultPage(r)
+  })
+
   // ---- 以下需要登录 ----
   f.register(async g => {
     g.addHook('preHandler', requireAdmin(app))
@@ -180,13 +188,22 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
     })
     g.post<IdParams>('/mcp/servers/:id/refresh', async req => app.mcpServers.refresh(req.params.id))
     g.put<{ Params: { id: string; tool: string } }>('/mcp/servers/:id/tools/:tool', async req => {
-      const policy = parse(z.object({ policy: z.enum(['auto', 'ask', 'off']) }), req.body).policy
+      const policy = parse(z.object({ policy: z.enum(['auto', 'ask', 'confirm', 'off']) }), req.body).policy
       return app.mcpServers.setToolPolicy(req.params.id, req.params.tool, policy)
     })
+    g.put<{ Params: { id: string; tool: string } }>('/mcp/servers/:id/tools/:tool/hints', async req => {
+      const hints = parse(z.object({ readOnly: z.boolean().optional(), destructive: z.boolean().optional(), idempotent: z.boolean().optional() }), req.body)
+      return app.mcpServers.setToolHints(req.params.id, req.params.tool, hints)
+    })
     g.put<{ Params: { id: string; tool: string } }>('/mcp/servers/:id/tools/:tool/background', async req => {
       const { background } = parse(z.object({ background: z.boolean() }), req.body)
       return app.mcpServers.setToolBackground(req.params.id, req.params.tool, background)
     })
+    g.post<IdParams>('/mcp/servers/:id/oauth/start', async req => app.mcpOAuth.start(req.params.id, `${req.protocol}://${req.host}`))
+    g.delete<IdParams>('/mcp/servers/:id/oauth', async (req, reply) => {
+      await app.mcpOAuth.revoke(req.params.id)
+      reply.code(204)
+    })
     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)
@@ -582,3 +599,15 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       app.audit.list({ kind: req.query.kind, sessionId: req.query.sessionId, limit: req.query.limit ? Number(req.query.limit) : undefined }))
   })
 }
+
+const escapeHtml = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!)
+
+/** 授权结果页:通知打开它的后台页面刷新,然后可以关闭 */
+function oauthResultPage(r: { ok: boolean; title: string; message: string }) {
+  const head = r.ok ? '授权成功' : '授权未完成'
+  return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${head}</title>
+<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:90vh;margin:0 16px;color:#222;background:#fafafa}
+@media (prefers-color-scheme:dark){body{color:#ddd;background:#1e1e1e}}main{max-width:420px;text-align:center}h1{font-size:20px}.ok{color:#2e7d32}.bad{color:#c62828}</style></head>
+<body><main><h1 class="${r.ok ? 'ok' : 'bad'}">${head}</h1>${r.title ? `<p>${escapeHtml(r.title)}</p>` : ''}<p>${escapeHtml(r.message)}</p></main>
+<script>try{window.opener&&window.opener.postMessage({type:'mcp-oauth',ok:${r.ok}},location.origin)}catch(e){}${r.ok ? 'setTimeout(function(){window.close()},1500)' : ''}</script></body></html>`
+}

+ 28 - 5
apps/server/src/http/routes/v1.ts

@@ -11,14 +11,10 @@ import { parse } from '../../services/catalog'
 import { principal, requireClient } from '../auth'
 import { streamSessionEvents } from '../sse'
 import { forgetGatewaySession } from '../../mcp-gateway/server'
+import { interactionResponseSchema } from '../../services/interactions'
 
 type IdParams = { Params: { id: string } }
 
-const interactionResponseSchema: z.ZodType<InteractionResponse> = z.union([
-  z.object({ decision: z.enum(['allow_once', 'allow_session']) }),
-  z.object({ decision: z.literal('deny'), reason: z.string().max(2000).optional() }),
-  z.object({ answers: z.record(z.string(), z.union([z.string(), z.array(z.string())])) }),
-])
 
 export async function providerStatuses(app: App): Promise<ProviderStatusDto[]> {
   const settings = app.settings.get()
@@ -48,6 +44,14 @@ export default async function v1Routes(f: FastifyInstance, { app }: { app: App }
   f.addHook('preHandler', requireClient(app))
 
   const sessionFor = (req: FastifyRequest<IdParams>) => app.sessions.getRow(req.params.id, principal(req).source)
+  /** 会话的 Profile 勾选的 MCP 服务(Profile 已删除时为空) */
+  const mcpIds = (row: { profileId: string | null }) => {
+    try {
+      return row.profileId ? app.profiles.get(row.profileId).mcpServerIds : []
+    } catch {
+      return []
+    }
+  }
 
   // 引擎状态缓存 30 秒,避免频繁调用 CLI
   let statusCache: { at: number; data: ProviderStatusDto[] } | null = null
@@ -109,6 +113,7 @@ export default async function v1Routes(f: FastifyInstance, { app }: { app: App }
       permissionMode: eff.permissionMode,
       permissionModeOverridden: eff.overridden,
       systemPromptAppend: row.systemPromptAppend,
+      mcp: { resources: app.mcpContent.servers(mcpIds(row), 'resources').length > 0, prompts: app.mcpContent.servers(mcpIds(row), 'prompts').length > 0 },
     }
   })
 
@@ -118,6 +123,7 @@ export default async function v1Routes(f: FastifyInstance, { app }: { app: App }
     await app.sessions.delete(req.params.id, principal(req).source)
     app.schedules.deleteFor({ kind: 'session', sessionId: req.params.id })
     app.backgroundCalls.cancelFor({ sessionId: req.params.id })
+    app.interactions.cancelSession(req.params.id)
     app.gatewayTokens.forgetSession(req.params.id)
     forgetGatewaySession(req.params.id)
     reply.code(204)
@@ -141,6 +147,23 @@ export default async function v1Routes(f: FastifyInstance, { app }: { app: App }
 
   // ---- 后台调用(docs/design/15-background-calls.md):外部工具的长调用,平台可以查看和取消 ----
   f.get<IdParams>('/sessions/:id/background-calls', async req => app.backgroundCalls.list({ sessionId: sessionFor(req).id }))
+  // ---- 服务端资源与提示模板(docs/design/16-mcp-support.md §6):只涉及会话的 Profile 勾选的 MCP 服务 ----
+  f.get<IdParams & { Querystring: { server?: string; cursor?: string } }>('/sessions/:id/mcp/resources', async req => {
+    const row = sessionFor(req)
+    return app.mcpContent.listResources(mcpIds(row), req.query.server || undefined, req.query.cursor || undefined)
+  })
+  f.post<IdParams>('/sessions/:id/mcp/resources/attach', async req => {
+    const row = sessionFor(req)
+    const b = parse(z.object({ server: z.string().min(1).max(64), uri: z.string().min(1).max(4000) }), req.body)
+    return app.mcpContent.attach(mcpIds(row), { id: row.id, workspaceDir: row.workspaceDir }, b.server, b.uri)
+  })
+  f.get<IdParams>('/sessions/:id/mcp/prompts', async req => app.mcpContent.listPrompts(mcpIds(sessionFor(req))))
+  f.post<IdParams>('/sessions/:id/mcp/prompts/get', async req => {
+    const row = sessionFor(req)
+    const b = parse(z.object({ server: z.string().min(1).max(64), name: z.string().min(1).max(200), arguments: z.record(z.string(), z.string().max(20_000)).default({}) }), req.body)
+    return app.mcpContent.getPrompt(mcpIds(row), { id: row.id, workspaceDir: row.workspaceDir }, b.server, b.name, b.arguments)
+  })
+
   f.post<IdParams>('/background-calls/:id/cancel', async req => {
     const call = app.backgroundCalls.get(req.params.id)
     if (call.teamId) throw notFound('后台调用')

+ 1 - 1
apps/server/src/http/server.ts

@@ -55,7 +55,7 @@ export async function buildServer(app: App, opts: { logger: FastifyServerOptions
 
   await f.register(v1Routes, { prefix: '/v1', app })
   await f.register(botRoutes, { prefix: '/bot/v1', app })
-  registerGateway(f, app.gatewayTokens, { mcp: app.mcpServers, upstream: app.mcpUpstream, background: app.backgroundCalls })
+  registerGateway(f, app.gatewayTokens, { mcp: app.mcpServers, upstream: app.mcpUpstream, background: app.backgroundCalls, tasks: app.mcpTasks, elicitation: app.elicitation, content: app.mcpContent })
   await f.register(adminRoutes, { prefix: '/admin/v1', app })
 
   // 生产环境由服务托管后台静态资源,同源访问

+ 79 - 13
apps/server/src/mcp-gateway/background.ts

@@ -54,8 +54,8 @@ export class BackgroundCallService {
     })
     this.timer = setInterval(() => this.flush(), 15_000)
     this.timer.unref()
-    // 服务重启:与外部服务的连接已断开,进行中的调用无法接续
-    const interrupted = deps.db.select().from(backgroundCalls).where(eq(backgroundCalls.status, 'running')).all()
+    // 服务重启:与外部服务的连接已断开,保持连接等待的调用无法接续;长任务按任务 id 继续查询(tasks.ts)
+    const interrupted = deps.db.select().from(backgroundCalls).where(and(eq(backgroundCalls.status, 'running'), eq(backgroundCalls.mode, 'wait'))).all()
     const undelivered = deps.db.select().from(backgroundCalls).where(eq(backgroundCalls.delivered, false)).all().filter(r => r.status !== 'running')
     setImmediate(() => {
       for (const r of interrupted) this.end(r.id, 'failed', null, '服务重启,后台调用中断(可以重新发起)')
@@ -68,14 +68,22 @@ export class BackgroundCallService {
     for (const c of this.controllers.values()) c.abort()
   }
 
+  /** 结束时调用(长任务据此释放连接、通知服务取消) */
+  onEnded?: (row: CallRow, status: Exclude<BackgroundCallStatus, 'running'>) => void
+
+  /** 发起方(会话或所在的工作组)的后台调用是否已达上限 */
+  atLimit(sessionId: string): boolean {
+    const team = this.deps.teams.teamOfSession(sessionId)
+    const running = this.deps.db.select({ id: backgroundCalls.id }).from(backgroundCalls)
+      .where(and(eq(backgroundCalls.status, 'running'), team ? eq(backgroundCalls.teamId, team.teamId) : eq(backgroundCalls.sessionId, sessionId))).all().length
+    return running >= this.deps.settings.get().backgroundCalls.maxPerTarget
+  }
+
   /** 网关调用外部工具:必要时转到后台 */
   async call<R>(req: BackgroundCallRequest<R>): Promise<ToolResult> {
     const s = this.deps.settings.get().backgroundCalls
-    const team = this.deps.teams.teamOfSession(req.ctx.sessionId)
-    const running = this.deps.db.select({ id: backgroundCalls.id }).from(backgroundCalls)
-      .where(and(eq(backgroundCalls.status, 'running'), team ? eq(backgroundCalls.teamId, team.teamId) : eq(backgroundCalls.sessionId, req.ctx.sessionId))).all().length
     // 后台调用已达上限:按原来的方式等待
-    if (running >= s.maxPerTarget) return req.toResult(await req.invoke(req.runSignal, req.foregroundTimeoutMs))
+    if (this.atLimit(req.ctx.sessionId)) return req.toResult(await req.invoke(req.runSignal, req.foregroundTimeoutMs))
 
     const controller = new AbortController()
     const onAbort = () => controller.abort()
@@ -98,12 +106,7 @@ export class BackgroundCallService {
 
     // 转为后台:与这一轮无关,只能单独取消
     req.runSignal.removeEventListener('abort', onAbort)
-    const id = newId('bg')
-    const ref = this.deps.teams.noteBackground(req.ctx.sessionId)
-    this.deps.db.insert(backgroundCalls).values({
-      id, sessionId: req.ctx.sessionId, teamId: ref?.teamId ?? null, botId: ref?.botId ?? null, taskId: ref?.taskId ?? null,
-      serverId: req.server.id, serverName: req.server.name, serverTitle: req.server.title, tool: req.tool, args: req.args,
-    }).run()
+    const id = this.insert(req, { mode: 'wait' })
     this.controllers.set(id, controller)
     const limit = setTimeout(() => this.end(id, 'failed', null, `超过 ${s.maxHours} 小时,已停止`), s.maxHours * 3_600_000)
     limit.unref()
@@ -117,7 +120,57 @@ export class BackgroundCallService {
       (err: unknown) => this.end(id, 'failed', null, (err as Error).message),
     ).finally(() => clearTimeout(limit))
     this.deps.log.info({ id, sessionId: req.ctx.sessionId, tool: `${req.server.name}__${req.tool}` }, 'tool call moved to background')
-    return ok(`调用「${req.server.title} · ${req.tool}」耗时较长,已转为后台执行(后台调用 ${id})。完成后系统会把结果作为一条新消息发给你。请结束本轮,告诉用户已提交、完成后会继续处理;不要等待或轮询。`)
+    return ok(movedText(req.server.title, req.tool, id))
+  }
+
+  /** 长任务转为后台:记录任务 id,之后由 tasks.ts 按任务 id 查询 */
+  recordTask(req: Pick<BackgroundCallRequest<unknown>, 'ctx' | 'server' | 'tool' | 'args'>, task: { mcpTaskId: string; connScope: string | null; pollAfter: string; statusMessage: string | null }): string {
+    const id = this.insert(req, { mode: 'task', ...task })
+    this.deps.log.info({ id, sessionId: req.ctx.sessionId, tool: `${req.server.name}__${req.tool}`, mcpTaskId: task.mcpTaskId }, 'task moved to background')
+    return id
+  }
+
+  /** 进行中的长任务 */
+  runningTasks(): CallRow[] {
+    return this.deps.db.select().from(backgroundCalls).where(and(eq(backgroundCalls.status, 'running'), eq(backgroundCalls.mode, 'task'))).all()
+  }
+
+  /** 按服务的任务 id 找进行中的长任务(服务在任务中发来询问时据此确定发起方) */
+  findTask(serverId: string, mcpTaskId: string): CallRow | undefined {
+    return this.deps.db.select().from(backgroundCalls).where(and(eq(backgroundCalls.serverId, serverId), eq(backgroundCalls.mcpTaskId, mcpTaskId), eq(backgroundCalls.status, 'running'))).get()
+  }
+
+  updateTask(id: string, patch: Partial<Pick<CallRow, 'pollAfter' | 'pollErrorSince' | 'statusMessage'>>) {
+    this.deps.db.update(backgroundCalls).set(patch).where(eq(backgroundCalls.id, id)).run()
+  }
+
+  /** 会话的工作区(会话已删除时为 undefined) */
+  workspaceOf(sessionId: string): string | undefined {
+    return this.deps.db.select({ dir: sessions.workspaceDir }).from(sessions).where(eq(sessions.id, sessionId)).get()?.dir
+  }
+
+  /** 长任务结束(由 tasks.ts 调用):结果已经过与普通调用相同的处理 */
+  finishTask(id: string, outcome: { status: 'completed' | 'failed' | 'cancelled'; result?: ToolResult; error?: string }) {
+    const row = this.deps.db.select().from(backgroundCalls).where(eq(backgroundCalls.id, id)).get()
+    if (!row) return
+    if (outcome.result) {
+      const text = resultText(outcome.result)
+      const dir = this.workspaceOf(row.sessionId)
+      if (outcome.result.isError) this.end(id, 'failed', null, text, true, dir)
+      else this.end(id, 'completed', text, null, true, dir)
+    } else {
+      this.end(id, outcome.status === 'completed' ? 'failed' : outcome.status, null, outcome.error ?? '(没有说明)')
+    }
+  }
+
+  private insert(req: Pick<BackgroundCallRequest<unknown>, 'ctx' | 'server' | 'tool' | 'args'>, extra: Partial<Pick<CallRow, 'mode' | 'mcpTaskId' | 'connScope' | 'pollAfter' | 'statusMessage'>>): string {
+    const id = newId('bg')
+    const ref = this.deps.teams.noteBackground(req.ctx.sessionId)
+    this.deps.db.insert(backgroundCalls).values({
+      id, sessionId: req.ctx.sessionId, teamId: ref?.teamId ?? null, botId: ref?.botId ?? null, taskId: ref?.taskId ?? null,
+      serverId: req.server.id, serverName: req.server.name, serverTitle: req.server.title, tool: req.tool, args: req.args, ...extra,
+    }).run()
+    return id
   }
 
   // ---- 查询与取消 ----
@@ -168,6 +221,13 @@ export class BackgroundCallService {
     if (!row || row.status !== 'running') return
     this.controllers.get(id)?.abort()
     this.controllers.delete(id)
+    if (row.mode === 'task') {
+      try {
+        this.onEnded?.(row, status)
+      } catch (err) {
+        this.deps.log.warn({ err, id }, 'task end hook failed')
+      }
+    }
     const stored = result !== null && workspaceDir ? this.storeResult(id, row.tool, result, workspaceDir) : result
     this.deps.db.update(backgroundCalls).set({ status, result: stored, error, endedAt: nowIso(), delivered: !notify }).where(eq(backgroundCalls.id, id)).run()
     if (notify) this.deliver(id)
@@ -237,6 +297,11 @@ function duration(from: string, to: string | null) {
   return min < 60 ? `${min} 分钟` : `${Math.floor(min / 60)} 小时 ${min % 60} 分钟`
 }
 
+/** 转为后台时告诉模型的话 */
+export function movedText(serverTitle: string, tool: string, id: string, note?: string) {
+  return `调用「${serverTitle} · ${tool}」耗时较长,已转为后台执行(后台调用 ${id})。完成后系统会把结果作为一条新消息发给你。请结束本轮,告诉用户已提交、完成后会继续处理;不要等待或轮询。${note ? `\n服务的说明:${note}` : ''}`
+}
+
 function deliveryText(r: CallRow): string {
   const head = { completed: '后台调用完成', failed: '后台调用失败', cancelled: '后台调用已取消', running: '' }[r.status]
   const meta = `${r.serverTitle} · ${r.tool}(${r.id},耗时 ${duration(r.startedAt, r.endedAt)})`
@@ -248,5 +313,6 @@ function toDto(r: CallRow): BackgroundCallDto {
   return {
     id: r.id, sessionId: r.sessionId, teamId: r.teamId, botId: r.botId, taskId: r.taskId, serverName: r.serverName, serverTitle: r.serverTitle,
     tool: r.tool, args: r.args, status: r.status, startedAt: r.startedAt, endedAt: r.endedAt, result: r.result, error: r.error, delivered: r.delivered,
+    mode: r.mode, statusMessage: r.statusMessage,
   }
 }

+ 134 - 0
apps/server/src/mcp-gateway/elicitation.ts

@@ -0,0 +1,134 @@
+// 外部服务向用户询问信息(MCP Elicitation,docs/design/16-mcp-support.md §4):把服务发来的询问变成会话里的表单卡片,
+// 用户的提交、拒绝、关闭原样回给服务。询问必须能确定属于哪个会话,否则拒绝,避免显示到别的会话
+import type { InteractionFormField } from '@agent-server/shared'
+import type { ElicitRequest, ElicitResult } from '@modelcontextprotocol/sdk/types.js'
+import type { AppLogger } from '../app'
+import type { AuditService } from '../services/catalog'
+import type { InteractionOutcome, InteractionService, NewForm } from '../services/interactions'
+import type { BackgroundCallService } from './background'
+import type { McpTaskRunner } from './tasks'
+import type { ElicitContext } from './upstream'
+
+const MAX_FIELDS = 30
+
+/** 把 requestedSchema(规范限定为一层的对象,属性为基本类型)转换为表单字段;不支持的结构返回错误说明 */
+export function schemaToFields(schema: unknown): InteractionFormField[] | string {
+  const s = schema as { type?: unknown; properties?: Record<string, Record<string, unknown>>; required?: unknown } | undefined
+  if (!s || s.type !== 'object' || typeof s.properties !== 'object' || !s.properties) return '询问的表单结构不是对象'
+  const required = new Set(Array.isArray(s.required) ? s.required.filter((x): x is string => typeof x === 'string') : [])
+  const fields: InteractionFormField[] = []
+  for (const [name, p] of Object.entries(s.properties).slice(0, MAX_FIELDS)) {
+    const base = { name, title: str(p.title) ?? name, ...(str(p.description) ? { description: str(p.description)! } : {}), required: required.has(name) }
+    const type = p.type
+    // 单选:enum(可带 enumNames)或 oneOf [{ const, title }]
+    const single = choices(p)
+    if (type === 'string' && single) {
+      fields.push({ ...base, type: 'choice', options: single, multiple: false, ...(str(p.default) ? { default: str(p.default)! } : {}) })
+    } else if (type === 'array') {
+      const items = (p.items ?? {}) as Record<string, unknown>
+      const opts = choices(items) ?? choices({ anyOf: items.anyOf })
+      if (!opts) return `字段 ${name} 是不支持的数组类型`
+      fields.push({
+        ...base, type: 'choice', options: opts, multiple: true,
+        ...(typeof p.minItems === 'number' ? { minItems: p.minItems } : {}), ...(typeof p.maxItems === 'number' ? { maxItems: p.maxItems } : {}),
+        ...(Array.isArray(p.default) ? { default: p.default.filter((x): x is string => typeof x === 'string') } : {}),
+      })
+    } else if (type === 'string') {
+      const format = ['email', 'uri', 'date', 'date-time'].includes(p.format as string) ? p.format as 'email' | 'uri' | 'date' | 'date-time' : undefined
+      fields.push({
+        ...base, type: 'string', ...(format ? { format } : {}),
+        ...(typeof p.minLength === 'number' ? { minLength: p.minLength } : {}), ...(typeof p.maxLength === 'number' ? { maxLength: p.maxLength } : {}),
+        ...(str(p.default) ? { default: str(p.default)! } : {}),
+      })
+    } else if (type === 'number' || type === 'integer') {
+      fields.push({
+        ...base, type, ...(typeof p.minimum === 'number' ? { minimum: p.minimum } : {}), ...(typeof p.maximum === 'number' ? { maximum: p.maximum } : {}),
+        ...(typeof p.default === 'number' ? { default: p.default } : {}),
+      })
+    } else if (type === 'boolean') {
+      fields.push({ ...base, type: 'boolean', ...(typeof p.default === 'boolean' ? { default: p.default } : {}) })
+    } else {
+      return `字段 ${name} 的类型不支持:${String(type)}`
+    }
+  }
+  return fields
+}
+
+const str = (v: unknown) => (typeof v === 'string' && v ? v : undefined)
+
+function choices(p: Record<string, unknown>): { value: string; label: string }[] | null {
+  if (Array.isArray(p.enum)) {
+    const names = Array.isArray(p.enumNames) ? p.enumNames : []
+    return p.enum.filter((v): v is string => typeof v === 'string').map((v, i) => ({ value: v, label: str(names[i]) ?? v }))
+  }
+  const list = (Array.isArray(p.oneOf) ? p.oneOf : Array.isArray(p.anyOf) ? p.anyOf : null) as Record<string, unknown>[] | null
+  if (!list) return null
+  const opts = list.filter(o => typeof o?.const === 'string').map(o => ({ value: o.const as string, label: str(o.title) ?? (o.const as string) }))
+  return opts.length ? opts : null
+}
+
+/** 交互结果 → 规范的三种回复 */
+function toResult(o: InteractionOutcome): ElicitResult {
+  if (o.resolution === 'submitted') return { action: 'accept', content: o.content }
+  if (o.resolution === 'denied') return { action: 'decline' }
+  return { action: 'cancel' }
+}
+
+export class ElicitationService {
+  /** 正在执行的调用 → 发起调用那一轮的交互(审批卡片与这一轮关联,等待期间 Run 处于 waiting_input) */
+  private runInteract = new Map<string, (req: NewForm) => Promise<InteractionOutcome>>()
+
+  constructor(private deps: { log: AppLogger; audit: AuditService; interactions: InteractionService; background: BackgroundCallService; tasks: McpTaskRunner }) {}
+
+  /** 网关调用外部工具期间登记这一轮的交互方式,返回注销函数 */
+  enterCall(sessionId: string, interact: (req: NewForm) => Promise<InteractionOutcome>): () => void {
+    this.runInteract.set(sessionId, interact)
+    return () => {
+      if (this.runInteract.get(sessionId) === interact) this.runInteract.delete(sessionId)
+    }
+  }
+
+  async handle(e: ElicitContext): Promise<ElicitResult> {
+    const source = e.row.title
+    const sessionId = this.owner(e)
+    if (!sessionId) {
+      this.deps.audit.log({ actor: 'gateway', kind: 'gateway_call', summary: `${e.row.name} 的询问无法确定属于哪个会话,已拒绝`, detail: { message: e.params.message } })
+      return { action: 'decline' }
+    }
+    const req = this.toForm(source, e.params)
+    if (typeof req === 'string') {
+      this.deps.audit.log({ actor: 'gateway', kind: 'gateway_call', summary: `${e.row.name} 的询问无法显示:${req}`, sessionId, detail: { message: e.params.message } })
+      return { action: 'decline' }
+    }
+    // 调用过程中的询问交给这一轮;长任务中的询问不属于某一轮
+    const interact = e.taskId ? undefined : this.runInteract.get(sessionId)
+    const outcome = interact ? await interact(req) : await this.deps.interactions.request(sessionId, null, req)
+    this.deps.audit.log({ actor: 'gateway', kind: 'gateway_call', summary: `${e.row.name} 询问用户:${outcome.resolution === 'submitted' ? '已提交' : outcome.resolution}`, sessionId, detail: { message: e.params.message, mode: req.mode } })
+    return toResult(outcome)
+  }
+
+  /** 询问属于哪个会话:长任务按任务 id;其他按连接或连接上正在执行的调用,必须唯一 */
+  private owner(e: ElicitContext): string | null {
+    if (e.taskId) return this.deps.tasks.liveSession(e.row.id, e.taskId) ?? this.deps.background.findTask(e.row.id, e.taskId)?.sessionId ?? null
+    if (e.scope) return e.scope
+    const sessions = new Set(e.owners.map(o => o.sessionId))
+    return sessions.size === 1 ? [...sessions][0]! : null
+  }
+
+  private toForm(source: string, p: ElicitRequest['params']): NewForm | string {
+    const message = String(p.message ?? '').slice(0, 4000)
+    if (p.mode === 'url') {
+      let url: URL
+      try {
+        url = new URL(String(p.url))
+      } catch {
+        return '网址无效'
+      }
+      if (url.protocol !== 'https:' && url.protocol !== 'http:') return '只支持 http / https 网址'
+      return { kind: 'form', mode: 'url', source, message, fields: [], url: url.toString() }
+    }
+    const fields = schemaToFields(p.requestedSchema)
+    if (typeof fields === 'string') return fields
+    return { kind: 'form', mode: 'form', source, message, fields }
+  }
+}

+ 130 - 0
apps/server/src/mcp-gateway/results.ts

@@ -0,0 +1,130 @@
+// 第三方工具结果的处理(网关直接返回与后台调用结束时共用):只保留引擎能处理的内容,过长或二进制的内容存入工作区,
+// 结构化结果按 outputSchema 校验(docs/design/16-mcp-support.md §2.2)
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import path from 'node:path'
+import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'
+import type { McpCachedTool } from '../db/schema'
+import type { ToolContent, ToolResult } from './builtin'
+
+const MAX_TEXT = 100_000
+const MAX_INLINE_BINARY = 5 * 1048576
+/** 附在文本后面的结构化结果超过这个长度时保存为文件 */
+const MAX_STRUCTURED_INLINE = 20_000
+
+export type RawToolResult = { content?: ToolContent[]; structuredContent?: unknown; isError?: boolean }
+
+/** 资源文件名:取 URI 最后一段,只保留安全字符;取不到时用工具名 */
+function resourceFileName(uri: unknown, tool: string): string {
+  let last = ''
+  if (typeof uri === 'string') {
+    const noQuery = uri.split(/[?#]/)[0]!
+    last = noQuery.slice(noQuery.lastIndexOf('/') + 1)
+    try {
+      last = decodeURIComponent(last)
+    } catch {
+      // 保留原文
+    }
+  }
+  const safe = last.replace(/[^\p{L}\p{N}._-]+/gu, '_').replace(/^[._]+/, '').slice(0, 120)
+  return safe || `${tool.replace(/[^\w.-]/g, '_')}.bin`
+}
+
+const formatBytes = (n: number) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`)
+
+/**
+ * 只保留引擎能处理的内容;过长的文本和过大的二进制存入工作区 .agent/mcp/ 并返回路径。
+ * resource(嵌入资源,如导出的 CSV、文件)一律存入工作区,只返回路径,不放进上下文
+ */
+export function postProcess(r: RawToolResult, workspaceDir: string, tool: string): ToolResult {
+  const dir = path.join(workspaceDir, '.agent', 'mcp')
+  const save = (ext: string, data: string | Buffer) => {
+    fs.mkdirSync(dir, { recursive: true })
+    const file = path.join(dir, `${tool.replace(/[^\w.-]/g, '_')}-${Date.now()}-${crypto.randomBytes(3).toString('hex')}.${ext}`)
+    fs.writeFileSync(file, data)
+    return path.relative(workspaceDir, file)
+  }
+  /** 按资源原名保存,重名时加序号 */
+  const saveNamed = (name: string, data: string | Buffer) => {
+    fs.mkdirSync(dir, { recursive: true })
+    const ext = path.extname(name)
+    const stem = name.slice(0, name.length - ext.length)
+    let file = path.join(dir, name)
+    for (let i = 2; fs.existsSync(file); i++) file = path.join(dir, `${stem}-${i}${ext}`)
+    fs.writeFileSync(file, data)
+    return path.relative(workspaceDir, file)
+  }
+  const content: ToolContent[] = []
+  for (const c of r.content ?? []) {
+    if (c.type === 'resource' && c.resource && typeof c.resource === 'object') {
+      const res = c.resource as { uri?: unknown; mimeType?: unknown; text?: unknown; blob?: unknown }
+      const data = typeof res.blob === 'string' ? Buffer.from(res.blob, 'base64') : typeof res.text === 'string' ? res.text : null
+      if (data === null) {
+        content.push({ type: 'text', text: '(资源没有内容)' })
+        continue
+      }
+      const saved = saveNamed(resourceFileName(res.uri, tool), data)
+      const size = typeof data === 'string' ? Buffer.byteLength(data) : data.length
+      const mime = typeof res.mimeType === 'string' && res.mimeType ? res.mimeType : typeof data === 'string' ? 'text/plain' : 'application/octet-stream'
+      content.push({ type: 'text', text: `已保存到 ${saved}(${formatBytes(size)},${mime})` })
+    } else if (c.type === 'text' && typeof c.text === 'string' && c.text.length > MAX_TEXT) {
+      const saved = save('txt', c.text)
+      content.push({ type: 'text', text: `${c.text.slice(0, MAX_TEXT)}\n\n(内容过长,以上为前一部分;完整内容已保存到 ${saved})` })
+    } else if ((c.type === 'image' || c.type === 'audio') && typeof c.data === 'string' && c.data.length > MAX_INLINE_BINARY) {
+      const ext = String(c.mimeType ?? '').split('/')[1]?.replace(/[^\w]/g, '') || 'bin'
+      content.push({ type: 'text', text: `(${c.type === 'image' ? '图片' : '音频'}过大,已保存到 ${save(ext, Buffer.from(c.data, 'base64'))})` })
+    } else {
+      content.push(c)
+    }
+  }
+  if (r.structuredContent !== undefined && !containsJson(r.content ?? [], r.structuredContent)) {
+    // 结构化结果:文本中没有同样的 JSON 时附在后面(只有结构化结果时就是全部内容);过长的保存为文件
+    const json = JSON.stringify(r.structuredContent, null, 2)
+    const head = content.length ? '结构化结果:\n' : ''
+    content.push({ type: 'text', text: json.length > MAX_STRUCTURED_INLINE ? `${head}${json.slice(0, 2000)}\n…(共 ${json.length} 字,完整结构化结果已保存到 ${save('json', json)})` : `${head}${json}` })
+  }
+  if (!content.length) content.push({ type: 'text', text: '(无返回内容)' })
+  return { content, ...(r.isError ? { isError: true } : {}) }
+}
+
+/** 文本内容中是否已经有同样的 JSON(规范建议服务把结构化结果也序列化成一段文本) */
+function containsJson(content: ToolContent[], value: unknown): boolean {
+  const target = JSON.stringify(value)
+  return content.some(c => {
+    if (c.type !== 'text' || typeof c.text !== 'string') return false
+    const t = c.text.trim()
+    if (!t.startsWith('{') && !t.startsWith('[')) return false
+    try {
+      return JSON.stringify(JSON.parse(t)) === target
+    } catch {
+      return false
+    }
+  })
+}
+
+const validator = new AjvJsonSchemaValidator()
+const validators = new Map<string, ReturnType<AjvJsonSchemaValidator['getValidator']>>()
+
+/** 按 outputSchema 校验结构化结果;返回错误说明,合格或没有 schema 时为 null */
+export function validateStructured(tool: Pick<McpCachedTool, 'outputSchema'>, value: unknown): string | null {
+  if (!tool.outputSchema) return null
+  const key = JSON.stringify(tool.outputSchema)
+  try {
+    let v = validators.get(key)
+    if (!v) {
+      v = validator.getValidator(tool.outputSchema as Parameters<AjvJsonSchemaValidator['getValidator']>[0])
+      if (validators.size > 500) validators.clear()
+      validators.set(key, v)
+    }
+    const r = v(value)
+    return r.valid ? null : `返回结构不符合 outputSchema:${r.errorMessage}`
+  } catch (err) {
+    return `outputSchema 无法使用:${(err as Error).message}`
+  }
+}
+
+export const truncateJson = (v: unknown, max: number) => {
+  const s = JSON.stringify(v)
+  return s.length <= max ? v : `${s.slice(0, max)}…(已截断)`
+}
+

+ 81 - 100
apps/server/src/mcp-gateway/server.ts

@@ -1,19 +1,21 @@
 // MCP Gateway:服务进程内的 Streamable HTTP MCP 服务(/mcp),两个引擎共用同一套工具(ADR-0006)。
 // 提供内置工具(builtin.ts),并转接 Profile 勾选的第三方 MCP 服务(ADR-0021):工具名为 <服务名>__<工具名>,
 // 调用前按工具策略与权限模式审批,结果过长时截断并把完整内容存入工作区
-import crypto from 'node:crypto'
-import fs from 'node:fs'
-import path from 'node:path'
 import type { FastifyInstance } from 'fastify'
+import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
 import { Server } from '@modelcontextprotocol/sdk/server/index.js'
 import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
 import { CallToolRequestSchema, ListToolsRequestSchema, type Tool } from '@modelcontextprotocol/sdk/types.js'
 import { z } from 'zod'
 import type { AppSettings } from '@agent-server/shared'
 import type { McpCachedTool } from '../db/schema'
-import { defaultPolicy, type McpServerService } from '../services/mcp-servers'
+import { toolState, type EffectiveHints, type McpServerService } from '../services/mcp-servers'
 import type { BackgroundCallService } from './background'
-import { BUILTIN_TOOLS, fail, type ToolContent, type ToolResult } from './builtin'
+import type { McpContentService } from '../services/mcp-content'
+import type { ElicitationService } from './elicitation'
+import type { McpTaskRunner } from './tasks'
+import { BUILTIN_TOOLS, fail, type ToolContent } from './builtin'
+import { postProcess, truncateJson, validateStructured } from './results'
 import type { GatewayRunContext, RunTokenRegistry } from './tokens'
 import { isTransient, type McpServerRow, type McpUpstream } from './upstream'
 
@@ -22,8 +24,8 @@ export const GATEWAY_TOOLS = BUILTIN_TOOLS.map(t => t.name)
 /** 第三方工具名中服务名与工具名的分隔符 */
 export const MCP_SEP = '__'
 
-const MAX_TEXT = 100_000
-const MAX_INLINE_BINARY = 5 * 1048576
+/** 工具说明后附的返回结构简述 */
+const MAX_SCHEMA_HINT = 1500
 const CALL_TIMEOUT_MS = 10 * 60_000
 const LIST_TIMEOUT_MS = 20_000
 
@@ -35,24 +37,39 @@ export interface GatewayDeps {
   upstream: McpUpstream
   /** 长调用转到后台(docs/design/15-background-calls.md) */
   background?: BackgroundCallService
+  /** 长任务(docs/design/16-mcp-support.md §3) */
+  tasks?: McpTaskRunner
+  /** 服务在调用过程中向用户询问(docs/design/16-mcp-support.md §4) */
+  elicitation?: ElicitationService
+  /** 服务提供的资源(docs/design/16-mcp-support.md §6) */
+  content?: McpContentService
 }
 
 /** 会话内"本会话允许"过的第三方工具 */
 const sessionAllowed = new Map<string, Set<string>>()
 
-const ownTools = (ctx: GatewayRunContext) => [...BUILTIN_TOOLS, ...(ctx.extraTools ?? [])]
+/** 网关自己的工具:内置工具、会话额外的工具(工作组协作)、读取 MCP 资源的工具 */
+const ownTools = (ctx: GatewayRunContext, deps: GatewayDeps) => [...BUILTIN_TOOLS, ...(ctx.extraTools ?? []), ...(deps.content?.gatewayTools(ctx) ?? [])]
 
-function builtinTools(ctx: GatewayRunContext): Tool[] {
-  return ownTools(ctx).map(t => ({
+function builtinTools(ctx: GatewayRunContext, deps: GatewayDeps): Tool[] {
+  return ownTools(ctx, deps).map(t => ({
     name: t.name, description: t.description,
     inputSchema: z.toJSONSchema(t.schema) as Tool['inputSchema'],
     ...(t.readOnly ? { annotations: { readOnlyHint: true } } : {}),
   }))
 }
 
+interface ExternalTool {
+  row: McpServerRow
+  tool: McpCachedTool
+  exposed: string
+  hints: EffectiveHints
+  policy: ReturnType<typeof toolState>['policy']
+}
+
 /** 这次对话可用的第三方工具(只读模式下只提供只读工具) */
-async function externalTools(ctx: GatewayRunContext, deps: GatewayDeps): Promise<{ row: McpServerRow; tool: McpCachedTool; exposed: string }[]> {
-  const out: { row: McpServerRow; tool: McpCachedTool; exposed: string }[] = []
+async function externalTools(ctx: GatewayRunContext, deps: GatewayDeps): Promise<ExternalTool[]> {
+  const out: ExternalTool[] = []
   for (const row of deps.mcp.rows(ctx.mcpServerIds)) {
     let tools = row.toolsCache
     if (!tools.length) {
@@ -62,9 +79,9 @@ async function externalTools(ctx: GatewayRunContext, deps: GatewayDeps): Promise
       ])
     }
     for (const tool of tools) {
-      const policy = row.toolPolicies[tool.name] ?? defaultPolicy(tool)
-      if (policy === 'off' || (ctx.permissionMode === 'readonly' && !tool.readOnly)) continue
-      out.push({ row, tool, exposed: `${row.name}${MCP_SEP}${tool.name}`.slice(0, 64) })
+      const { hints, policy } = toolState(row, tool)
+      if (policy === 'off' || (ctx.permissionMode === 'readonly' && !hints.readOnly)) continue
+      out.push({ row, tool, hints, policy, exposed: `${row.name}${MCP_SEP}${tool.name}`.slice(0, 64) })
     }
   }
   return out
@@ -75,12 +92,13 @@ function buildServer(ctx: GatewayRunContext, deps: GatewayDeps): Server {
 
   server.setRequestHandler(ListToolsRequestSchema, async () => ({
     tools: [
-      ...builtinTools(ctx),
-      ...(await externalTools(ctx, deps)).map(({ row, tool, exposed }) => ({
+      ...builtinTools(ctx, deps),
+      ...(await externalTools(ctx, deps)).map(({ row, tool, exposed, hints }) => ({
         name: exposed,
-        description: `[${row.title}] ${tool.description}`.slice(0, 2000),
+        ...(tool.title ? { title: tool.title } : {}),
+        description: `[${row.title}] ${tool.description.slice(0, 2000)}${schemaHint(tool.outputSchema)}`,
         inputSchema: (tool.inputSchema?.type === 'object' ? tool.inputSchema : { type: 'object', properties: {} }) as Tool['inputSchema'],
-        ...(tool.readOnly ? { annotations: { readOnlyHint: true } } : {}),
+        annotations: { readOnlyHint: hints.readOnly, destructiveHint: hints.destructive, idempotentHint: hints.idempotent, openWorldHint: hints.openWorld },
       })),
     ],
   }))
@@ -92,7 +110,7 @@ function buildServer(ctx: GatewayRunContext, deps: GatewayDeps): Server {
     const audit = (summary: string, detail: Record<string, unknown> = {}) =>
       ctx.audit('gateway_call', summary, { args, ms: Date.now() - started, engine: ctx.provider, ...detail })
 
-    const builtin = ownTools(ctx).find(t => t.name === name)
+    const builtin = ownTools(ctx, deps).find(t => t.name === name)
     if (builtin) {
       const parsed = builtin.schema.safeParse(args)
       if (!parsed.success) return fail(`参数不正确:${parsed.error.issues.map(i => `${i.path.join('.')} ${i.message}`).join(';')}`)
@@ -108,113 +126,76 @@ function buildServer(ctx: GatewayRunContext, deps: GatewayDeps): Server {
 
     const found = (await externalTools(ctx, deps)).find(t => t.exposed === name)
     if (!found) return fail(`工具不存在或当前不可用:${name}`)
-    const { row, tool } = found
-    const policy = row.toolPolicies[tool.name] ?? defaultPolicy(tool)
-    // 需要审批:跟随权限模式(需审批时询问;自动模式直接调用;只读模式已在列表中排除非只读工具)
-    if (policy === 'ask' && ctx.permissionMode === 'ask' && !sessionAllowed.get(ctx.sessionId)?.has(name)) {
+    const { row, tool, hints, policy } = found
+    // 需要审批:ask 跟随权限模式(需审批时询问;自动模式直接调用;只读模式已在列表中排除非只读工具);confirm 总是询问且每次都问
+    const confirm = policy === 'confirm'
+    if (confirm || (policy === 'ask' && ctx.permissionMode === 'ask' && !sessionAllowed.get(ctx.sessionId)?.has(name))) {
+      const reason = confirm ? `这是会造成破坏性修改的操作,每次都需要确认。${tool.description.slice(0, 260)}` : tool.description.slice(0, 300)
       const outcome = await ctx.interact({
-        kind: 'approval', tool: `mcp__${GATEWAY_SERVER}__${name}`, title: `${row.title} · ${tool.name}`,
-        input: args, detail: { reason: tool.description.slice(0, 300) || undefined }, options: ['allow_once', 'allow_session', 'deny'],
+        kind: 'approval', tool: `mcp__${GATEWAY_SERVER}__${name}`, title: `${row.title} · ${tool.title ?? tool.name}`,
+        input: args, detail: { reason: reason || undefined }, options: confirm ? ['allow_once', 'deny'] : ['allow_once', 'allow_session', 'deny'],
       })
       ctx.audit('approval', `MCP 工具 ${name}:${outcome.resolution}`, { args })
       if (outcome.resolution !== 'allowed') return fail(`用户拒绝了这次调用${outcome.resolution === 'denied' && outcome.reason ? `:${outcome.reason}` : ''}`)
       if (outcome.scope === 'session') sessionAllowed.set(ctx.sessionId, new Set([...(sessionAllowed.get(ctx.sessionId) ?? []), name]))
     }
     type Raw = { content?: ToolContent[]; structuredContent?: unknown; isError?: boolean }
+    const owner = { sessionId: ctx.sessionId }
     const invoke = (signal: AbortSignal, timeoutMs: number) => {
-      const call = () => deps.upstream.callTool(row, tool.name, args, signal, timeoutMs) as Promise<Raw>
-      // 偶发的网络错误:只读工具重新连接后重试一次;其他工具不重试,避免重复执行
+      const call = () => deps.upstream.callTool(row, tool.name, args, signal, timeoutMs, owner) as Promise<Raw>
+      // 偶发的网络错误:只读或幂等的工具重新连接后重试一次;其他工具不重试,避免重复执行
       return call().catch(async err => {
-        if (!tool.readOnly || !isTransient(err) || signal.aborted) throw err
-        await deps.upstream.close(row.id)
+        if (!hints.idempotent || !isTransient(err) || signal.aborted) throw err
+        await deps.upstream.reset(row, ctx.sessionId)
         return call()
       })
     }
-    const toResult = (r: Raw) => postProcess(r, ctx.workspaceDir, name)
+    const structured: { value?: unknown; error?: string } = {}
+    const toResult = (r: Raw) => {
+      if (r.structuredContent !== undefined && !r.isError) {
+        structured.value = r.structuredContent
+        structured.error = validateStructured(tool, r.structuredContent) ?? undefined
+      } else if (tool.outputSchema && !r.isError) {
+        structured.error = '声明了返回结构(outputSchema),但没有返回 structuredContent'
+      }
+      return postProcess(r, ctx.workspaceDir, name)
+    }
+    const auditStructured = () => ({
+      ...(structured.value !== undefined ? { structured: truncateJson(structured.value, 20_000) } : {}),
+      ...(structured.error ? { structuredError: structured.error } : {}),
+      ...(hints.openWorld ? { openWorld: true } : {}),
+    })
+    // 调用期间服务发来的询问显示在这一轮
+    const leave = deps.elicitation?.enterCall(ctx.sessionId, req => ctx.interact(req))
     try {
-      const result = deps.background
+      const always = row.backgroundTools.includes(tool.name)
+      const result = deps.background && deps.tasks && await deps.tasks.supports(row, tool.taskSupport, ctx.sessionId)
+        ? await deps.tasks.call({ ctx, row, tool: tool.name, args, always, runSignal: extra.signal, foregroundTimeoutMs: CALL_TIMEOUT_MS, toResult })
+        : deps.background
         ? await deps.background.call({
-          ctx, server: { id: row.id, name: row.name, title: row.title }, tool: tool.name, args, always: row.backgroundTools.includes(tool.name),
+          ctx, server: { id: row.id, name: row.name, title: row.title }, tool: tool.name, args, always,
           runSignal: extra.signal, foregroundTimeoutMs: CALL_TIMEOUT_MS, invoke, toResult,
         })
         : toResult(await invoke(extra.signal, CALL_TIMEOUT_MS))
-      audit(`${name}${result.isError ? '(失败)' : ''}`, { server: row.name })
+      audit(`${name}${result.isError ? '(失败)' : ''}`, { server: row.name, ...auditStructured() })
       return result
     } catch (err) {
       audit(`${name}(异常)`, { server: row.name, error: (err as Error).message })
+      if (err instanceof UnauthorizedError) return fail(`调用 ${row.title} 失败:服务的授权已失效,需要管理员在后台"MCP 服务"页面重新授权`)
       return fail(`调用 ${row.title} 失败:${(err as Error).message}`)
+    } finally {
+      leave?.()
     }
   })
 
   return server
 }
 
-/** 资源文件名:取 URI 最后一段,只保留安全字符;取不到时用工具名 */
-function resourceFileName(uri: unknown, tool: string): string {
-  let last = ''
-  if (typeof uri === 'string') {
-    const noQuery = uri.split(/[?#]/)[0]!
-    last = noQuery.slice(noQuery.lastIndexOf('/') + 1)
-    try {
-      last = decodeURIComponent(last)
-    } catch {
-      // 保留原文
-    }
-  }
-  const safe = last.replace(/[^\p{L}\p{N}._-]+/gu, '_').replace(/^[._]+/, '').slice(0, 120)
-  return safe || `${tool.replace(/[^\w.-]/g, '_')}.bin`
-}
-
-const formatBytes = (n: number) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`)
-
-/**
- * 只保留引擎能处理的内容;过长的文本和过大的二进制存入工作区 .agent/mcp/ 并返回路径。
- * resource(嵌入资源,如导出的 CSV、文件)一律存入工作区,只返回路径,不放进上下文
- */
-function postProcess(r: { content?: ToolContent[]; structuredContent?: unknown; isError?: boolean }, workspaceDir: string, tool: string): ToolResult {
-  const dir = path.join(workspaceDir, '.agent', 'mcp')
-  const save = (ext: string, data: string | Buffer) => {
-    fs.mkdirSync(dir, { recursive: true })
-    const file = path.join(dir, `${tool.replace(/[^\w.-]/g, '_')}-${Date.now()}-${crypto.randomBytes(3).toString('hex')}.${ext}`)
-    fs.writeFileSync(file, data)
-    return path.relative(workspaceDir, file)
-  }
-  /** 按资源原名保存,重名时加序号 */
-  const saveNamed = (name: string, data: string | Buffer) => {
-    fs.mkdirSync(dir, { recursive: true })
-    const ext = path.extname(name)
-    const stem = name.slice(0, name.length - ext.length)
-    let file = path.join(dir, name)
-    for (let i = 2; fs.existsSync(file); i++) file = path.join(dir, `${stem}-${i}${ext}`)
-    fs.writeFileSync(file, data)
-    return path.relative(workspaceDir, file)
-  }
-  const content: ToolContent[] = []
-  for (const c of r.content ?? []) {
-    if (c.type === 'resource' && c.resource && typeof c.resource === 'object') {
-      const res = c.resource as { uri?: unknown; mimeType?: unknown; text?: unknown; blob?: unknown }
-      const data = typeof res.blob === 'string' ? Buffer.from(res.blob, 'base64') : typeof res.text === 'string' ? res.text : null
-      if (data === null) {
-        content.push({ type: 'text', text: '(资源没有内容)' })
-        continue
-      }
-      const saved = saveNamed(resourceFileName(res.uri, tool), data)
-      const size = typeof data === 'string' ? Buffer.byteLength(data) : data.length
-      const mime = typeof res.mimeType === 'string' && res.mimeType ? res.mimeType : typeof data === 'string' ? 'text/plain' : 'application/octet-stream'
-      content.push({ type: 'text', text: `已保存到 ${saved}(${formatBytes(size)},${mime})` })
-    } else if (c.type === 'text' && typeof c.text === 'string' && c.text.length > MAX_TEXT) {
-      const saved = save('txt', c.text)
-      content.push({ type: 'text', text: `${c.text.slice(0, MAX_TEXT)}\n\n(内容过长,以上为前一部分;完整内容已保存到 ${saved})` })
-    } else if ((c.type === 'image' || c.type === 'audio') && typeof c.data === 'string' && c.data.length > MAX_INLINE_BINARY) {
-      const ext = String(c.mimeType ?? '').split('/')[1]?.replace(/[^\w]/g, '') || 'bin'
-      content.push({ type: 'text', text: `(${c.type === 'image' ? '图片' : '音频'}过大,已保存到 ${save(ext, Buffer.from(c.data, 'base64'))})` })
-    } else {
-      content.push(c)
-    }
-  }
-  if (!content.length && r.structuredContent !== undefined) content.push({ type: 'text', text: JSON.stringify(r.structuredContent, null, 2).slice(0, MAX_TEXT) })
-  if (!content.length) content.push({ type: 'text', text: '(无返回内容)' })
-  return { content, ...(r.isError ? { isError: true } : {}) }
+/** 工具说明后附上返回结构的简述,让模型事先知道会得到什么(不把 outputSchema 原样转给引擎,见 docs/design/16-mcp-support.md §2.2) */
+function schemaHint(schema: Record<string, unknown> | undefined): string {
+  if (!schema) return ''
+  const s = JSON.stringify(schema)
+  return `\n\n返回结构(JSON Schema):${s.length > MAX_SCHEMA_HINT ? `${s.slice(0, MAX_SCHEMA_HINT)}…` : s}`
 }
 
 export function registerGateway(f: FastifyInstance, tokens: RunTokenRegistry, deps: GatewayDeps) {

+ 221 - 0
apps/server/src/mcp-gateway/tasks.ts

@@ -0,0 +1,221 @@
+// 长任务(MCP Tasks,规范 2025-11-25,实验性;docs/design/16-mcp-support.md §3):服务声明支持任务时,网关以任务方式调用工具,
+// 先在前台查询一段时间,未完成的转为后台调用(mode = task),之后按任务 id 定时查询;服务重启后继续查询。
+// 与 Tasks 有关的协议处理都集中在这里,规范调整时只改这一处
+import type { GetTaskResult } from '@modelcontextprotocol/sdk/types.js'
+import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'
+import type { AppLogger } from '../app'
+import { nowIso } from '../lib/ids'
+import type { McpServerService } from '../services/mcp-servers'
+import type { SettingsService } from '../services/settings'
+import { movedText, type BackgroundCallService } from './background'
+import { ok, type ToolResult } from './builtin'
+import { postProcess, type RawToolResult } from './results'
+import type { GatewayRunContext } from './tokens'
+import { connScope, isTransient, type McpServerRow, type McpUpstream } from './upstream'
+
+const IMMEDIATE = 'io.modelcontextprotocol/model-immediate-response'
+/** 查询的时间参数(测试中调小) */
+export const taskTiming = {
+  minPollMs: 5_000,
+  maxPollMs: 5 * 60_000,
+  defaultPollMs: 30_000,
+  /** 前台等待期间的查询间隔上限(让很快完成的任务尽快返回) */
+  foregroundPollMs: 2_000,
+  /** 查询连续出错超过这个时间按失败处理 */
+  pollErrorLimitMs: 30 * 60_000,
+  tickMs: 5_000,
+}
+
+type TaskState = Pick<GetTaskResult, 'status' | 'statusMessage' | 'pollInterval'>
+const TERMINAL = new Set(['completed', 'failed', 'cancelled'])
+
+export const pollDelay = (t: Pick<GetTaskResult, 'pollInterval'>) => Math.min(taskTiming.maxPollMs, Math.max(taskTiming.minPollMs, t.pollInterval ?? taskTiming.defaultPollMs))
+
+export interface TaskCallRequest {
+  ctx: GatewayRunContext
+  row: McpServerRow
+  tool: string
+  args: Record<string, unknown>
+  /** 工具被标记为总是后台执行 */
+  always: boolean
+  runSignal: AbortSignal
+  /** 达到后台调用上限时,在前台最多等多久 */
+  foregroundTimeoutMs: number
+  toResult: (r: RawToolResult) => ToolResult
+}
+
+export class McpTaskRunner {
+  /** 前台等待中的任务(还没有后台记录):服务在任务中发来询问时据此确定会话 */
+  private live = new Map<string, string>()
+  /** 后台记录 → 连接占用的释放函数 */
+  private holds = new Map<string, () => void>()
+  /** 正在查询的后台记录 */
+  private busy = new Set<string>()
+  /** input_required 时发出的 tasks/result(一直等到任务结束) */
+  private waiting = new Map<string, Promise<void>>()
+  private timer: NodeJS.Timeout
+
+  constructor(private deps: { log: AppLogger; settings: SettingsService; upstream: McpUpstream; mcp: McpServerService; background: BackgroundCallService }) {
+    deps.background.onEnded = (row, status) => {
+      this.holds.get(row.id)?.()
+      this.holds.delete(row.id)
+      // 平台这边取消(成员、管理员、子任务取消、会话删除):通知服务取消任务(尽力而为)
+      if (status === 'cancelled' && row.mcpTaskId) {
+        const server = this.server(row.serverId)
+        if (server) void deps.upstream.cancelTask(server, row.connScope, row.mcpTaskId).catch(() => {})
+      }
+    }
+    this.timer = setInterval(() => this.tick(), taskTiming.tickMs)
+    this.timer.unref()
+  }
+
+  stop() {
+    clearInterval(this.timer)
+  }
+
+  /** 这个工具是否以任务方式调用:工具声明支持,且服务声明了任务能力 */
+  async supports(row: McpServerRow, taskSupport: 'required' | 'optional' | undefined, sessionId: string): Promise<boolean> {
+    if (!taskSupport) return false
+    return this.deps.upstream.supportsTasks(row, { sessionId }).catch(() => false)
+  }
+
+  /** 前台进行中的任务属于哪个会话 */
+  liveSession(serverId: string, taskId: string): string | undefined {
+    return this.live.get(`${serverId}:${taskId}`)
+  }
+
+  /** 以任务方式调用:先在前台查询,到时间未完成的转为后台 */
+  async call(req: TaskCallRequest): Promise<ToolResult> {
+    const s = this.deps.settings.get().backgroundCalls
+    const { row, ctx } = req
+    const scope = connScope(row, ctx.sessionId)
+    const created = await this.deps.upstream.startTask(row, req.tool, req.args, s.maxHours * 3_600_000, req.runSignal, { sessionId: ctx.sessionId })
+    const taskId = created.task.taskId
+    const liveKey = `${row.id}:${taskId}`
+    this.live.set(liveKey, ctx.sessionId)
+    const release = this.deps.upstream.hold(row, scope)
+    let handedOver = false
+    try {
+      const atLimit = this.deps.background.atLimit(ctx.sessionId)
+      const deadline = Date.now() + (atLimit ? req.foregroundTimeoutMs : req.always ? 0 : s.autoAfterSec * 1000)
+      let task: TaskState = created.task
+      let result: Promise<RawToolResult> | null = null
+      while (true) {
+        if (TERMINAL.has(task.status) && !result) result = this.deps.upstream.taskResult(row, scope, taskId, req.runSignal, 120_000)
+        // 需要输入:发出 tasks/result,服务借此发来询问;它会一直等到任务结束
+        if (task.status === 'input_required' && !result) result = this.deps.upstream.taskResult(row, scope, taskId, req.runSignal, s.maxHours * 3_600_000)
+        const remaining = deadline - Date.now()
+        if (remaining <= 0) break
+        const wait = Math.min(remaining, Math.min(taskTiming.foregroundPollMs, pollDelay(task)))
+        const settled = await raceTimeout(result, wait, req.runSignal)
+        if (settled) return req.toResult(settled.value)
+        if (req.runSignal.aborted) throw new Error('这一轮已中断')
+        if (!result) task = await this.deps.upstream.getTask(row, scope, taskId)
+      }
+      if (atLimit) throw new Error(`任务在 ${Math.round(req.foregroundTimeoutMs / 60_000)} 分钟内没有完成(后台调用已达上限,没有转到后台)`)
+
+      // 转为后台
+      const server = { id: row.id, name: row.name, title: row.title }
+      const id = this.deps.background.recordTask({ ctx, server, tool: req.tool, args: req.args }, {
+        mcpTaskId: taskId, connScope: scope, pollAfter: new Date(Date.now() + pollDelay(task)).toISOString(), statusMessage: task.statusMessage ?? null,
+      })
+      handedOver = true
+      this.holds.set(id, release)
+      if (result) this.waitResult(id, result)
+      const note = created._meta?.[IMMEDIATE]
+      return ok(movedText(row.title, req.tool, id, typeof note === 'string' ? note.slice(0, 1000) : undefined))
+    } catch (err) {
+      // 这一轮中断或出错:通知服务取消任务
+      void this.deps.upstream.cancelTask(row, scope, taskId).catch(() => {})
+      throw err
+    } finally {
+      this.live.delete(liveKey)
+      if (!handedOver) release()
+    }
+  }
+
+  // ---- 后台查询 ----
+
+  private tick() {
+    const now = Date.now()
+    for (const r of this.deps.background.runningTasks()) {
+      if (this.busy.has(r.id) || this.waiting.has(r.id)) continue
+      if (r.pollAfter && Date.parse(r.pollAfter) > now) continue
+      this.busy.add(r.id)
+      void this.poll(r.id).finally(() => this.busy.delete(r.id))
+    }
+  }
+
+  private server(id: string): McpServerRow | undefined {
+    return this.deps.mcp.rows([id])[0]
+  }
+
+  private async poll(id: string) {
+    const bg = this.deps.background
+    const row = bg.runningTasks().find(r => r.id === id)
+    if (!row?.mcpTaskId) return
+    const server = this.server(row.serverId)
+    if (!server) return bg.finishTask(id, { status: 'failed', error: 'MCP 服务已删除或停用,无法继续查询任务' })
+    const maxMs = this.deps.settings.get().backgroundCalls.maxHours * 3_600_000
+    if (Date.now() - Date.parse(row.startedAt) > maxMs) {
+      bg.finishTask(id, { status: 'cancelled', error: `超过 ${maxMs / 3_600_000} 小时,已停止` })
+      return
+    }
+    // 服务重启后第一次查询:重新占用连接
+    if (!this.holds.has(id)) this.holds.set(id, this.deps.upstream.hold(server, row.connScope))
+    let task: GetTaskResult
+    try {
+      task = await this.deps.upstream.getTask(server, row.connScope, row.mcpTaskId)
+    } catch (err) {
+      if (err instanceof McpError && err.code === ErrorCode.InvalidParams) {
+        return bg.finishTask(id, { status: 'failed', error: `服务找不到这个任务(可能已过期,或服务把任务绑定在已断开的连接上):${err.message}` })
+      }
+      const since = row.pollErrorSince ?? nowIso()
+      if (Date.now() - Date.parse(since) > taskTiming.pollErrorLimitMs) return bg.finishTask(id, { status: 'failed', error: `查询任务状态持续失败:${(err as Error).message}` })
+      if (!isTransient(err)) this.deps.log.warn({ err, id }, 'task poll failed')
+      bg.updateTask(id, { pollErrorSince: since, pollAfter: new Date(Date.now() + taskTiming.defaultPollMs).toISOString() })
+      return
+    }
+    bg.updateTask(id, { pollErrorSince: null, statusMessage: task.statusMessage ?? null, pollAfter: new Date(Date.now() + pollDelay(task)).toISOString() })
+    if (task.status === 'cancelled') return bg.finishTask(id, { status: 'cancelled', error: task.statusMessage ?? '服务取消了任务' })
+    if (TERMINAL.has(task.status) || task.status === 'input_required') {
+      const wait = task.status === 'input_required' ? maxMs : 120_000
+      this.waitResult(id, this.deps.upstream.taskResult(server, row.connScope, row.mcpTaskId, new AbortController().signal, wait))
+    }
+  }
+
+  /** 等 tasks/result 返回后结束后台记录 */
+  private waitResult(id: string, p: Promise<RawToolResult>) {
+    const done = p.then(
+      raw => {
+        const row = this.deps.background.runningTasks().find(r => r.id === id)
+        if (!row) return
+        const dir = this.deps.background.workspaceOf(row.sessionId)
+        this.deps.background.finishTask(id, { status: raw.isError ? 'failed' : 'completed', result: dir ? postProcess(raw, dir, `${row.serverName}__${row.tool}`) : undefined, error: dir ? undefined : '会话已删除' })
+      },
+      (err: Error) => {
+        // 任务失败时 tasks/result 返回的就是这个错误;网络错误留给下次查询
+        if (isTransient(err)) return
+        this.deps.background.finishTask(id, { status: 'failed', error: err.message })
+      },
+    ).finally(() => this.waiting.delete(id))
+    this.waiting.set(id, done)
+  }
+}
+
+/** 等 p 返回,最多等 ms;返回 null 表示到时间还没返回(或 p 为 null) */
+async function raceTimeout<T>(p: Promise<T> | null, ms: number, signal: AbortSignal): Promise<{ value: T } | null> {
+  let timer: NodeJS.Timeout | undefined
+  let onAbort: (() => void) | undefined
+  const sleep = new Promise<null>(resolve => {
+    timer = setTimeout(() => resolve(null), ms)
+    onAbort = () => resolve(null)
+    signal.addEventListener('abort', onAbort, { once: true })
+  })
+  try {
+    return await Promise.race([p ? p.then(value => ({ value })) : sleep, sleep])
+  } finally {
+    clearTimeout(timer)
+    if (onAbort) signal.removeEventListener('abort', onAbort)
+  }
+}

+ 3 - 3
apps/server/src/mcp-gateway/tokens.ts

@@ -4,7 +4,7 @@
 import crypto from 'node:crypto'
 import type { AppSettings, KnowledgeRootDto, PermissionMode } from '@agent-server/shared'
 import type { ResolvedAccess } from '../services/access-policy'
-import type { InteractionOutcome, NewApproval } from '../services/interactions'
+import type { InteractionOutcome, NewApproval, NewForm } from '../services/interactions'
 import type { BuiltinTool } from './builtin'
 
 export interface GatewayRunContext {
@@ -19,8 +19,8 @@ export interface GatewayRunContext {
   /** Profile 勾选的第三方 MCP 服务 */
   mcpServerIds: string[]
   audit: (kind: 'gateway_call' | 'network_denied' | 'file_access_denied' | 'approval', summary: string, detail?: unknown) => void
-  /** 发起审批(与引擎侧的审批相同:显示交互卡片并等待回复) */
-  interact: (req: NewApproval) => Promise<InteractionOutcome>
+  /** 发起审批或表单询问(与引擎侧的审批相同:显示交互卡片并等待回复) */
+  interact: (req: NewApproval | NewForm) => Promise<InteractionOutcome>
   /** 这个会话额外提供的工具(工作组的协作工具,按角色固定,见 teams/tools.ts) */
   extraTools?: BuiltinTool[]
 }

+ 201 - 29
apps/server/src/mcp-gateway/upstream.ts

@@ -2,7 +2,8 @@
 // - http:Streamable HTTP(失败时退回 SSE),请求头可携带令牌
 // - stdio:本地进程,在 bubblewrap 沙箱中运行:系统目录只读、独立的可写 home、按配置开放网络与额外路径;
 //   宿主 Unix socket(docker.sock 等)不可达;密钥只通过环境变量传给该进程
-// 连接按需建立、多个对话共用,空闲 10 分钟后关闭;配置变化时重新连接
+// 连接按需建立,空闲 10 分钟后关闭;配置变化时重新连接。HTTP 服务按会话分别连接(服务发来的询问据此归属,
+// docs/design/16-mcp-support.md §4.3),本地进程所有会话共用一个
 import fs from 'node:fs'
 import path from 'node:path'
 import type { Readable } from 'node:stream'
@@ -10,8 +11,14 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
 import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
 import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
 import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import { UnauthorizedError, type OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
+import {
+  CallToolResultSchema, CreateTaskResultSchema, ElicitRequestSchema, ToolListChangedNotificationSchema,
+  type CallToolResult, type CreateTaskResult, type ElicitRequest, type ElicitResult, type GetPromptResult, type GetTaskResult,
+  type ListPromptsResult, type ListResourcesResult, type ListResourceTemplatesResult, type ReadResourceResult,
+} from '@modelcontextprotocol/sdk/types.js'
 import { APP_VERSION, config } from '../config'
-import type { McpCachedTool, mcpServers } from '../db/schema'
+import type { McpCachedTool, McpFeatures, mcpServers } from '../db/schema'
 import { hostSockets } from '../services/office'
 
 export type McpServerRow = typeof mcpServers.$inferSelect
@@ -21,17 +28,41 @@ const IDLE_MS = 10 * 60_000
 const STDERR_LINES = 40
 export const BWRAP = ['/usr/bin/bwrap', '/bin/bwrap'].find(p => fs.existsSync(p)) ?? null
 
+/** 一次调用的归属:服务在调用过程中发来的询问(elicitation)据此归到发起调用的会话(docs/design/16-mcp-support.md §4.3) */
+export interface CallOwner {
+  sessionId: string
+}
+
+export interface ElicitContext {
+  row: McpServerRow
+  params: ElicitRequest['params']
+  /** 询问带有的 io.modelcontextprotocol/related-task(长任务中的询问) */
+  taskId: string | null
+  /** 连接所属的会话(HTTP 服务按会话建立连接;本地进程为 null) */
+  scope: string | null
+  /** 这个连接上正在执行的调用 */
+  owners: CallOwner[]
+}
+
 interface Conn {
+  /** 连接在 conns 中的键:服务 id,HTTP 服务再加上会话 */
+  name: string
+  serverId: string
+  scope: string | null
   key: string
   client: Client
   stderr: string[]
   lastUsed: number
-  /** 正在执行的调用数:有调用时不因空闲关闭(后台调用可能执行数小时) */
-  inFlight: number
+  /** 正在执行的调用:有调用时不因空闲关闭(后台调用可能执行数小时) */
+  calls: Map<number, CallOwner>
 }
 
+const RELATED_TASK = 'io.modelcontextprotocol/related-task'
+const NEED_OAUTH = '需要 OAuth 授权:请在后台"MCP 服务"页面完成授权'
+let callSeq = 0
+
 /** 影响连接的配置;变化时需要重新连接 */
-const connKey = (s: McpServerRow) => JSON.stringify([s.transport, s.url, s.headers, s.command, s.args, s.env, s.allowNetwork, s.readPaths, s.writePaths])
+const connKey = (s: McpServerRow) => JSON.stringify([s.transport, s.url, s.headers, s.command, s.args, s.env, s.allowNetwork, s.readPaths, s.writePaths, s.oauth?.enabled, s.oauth?.clientId, s.oauth?.authorizedAt])
 
 /** 本地进程的独立 home(npx / uvx 缓存等),在数据目录下 */
 export const mcpHomeDir = (id: string) => path.join(config.dataDir, 'data', 'mcp', id)
@@ -72,11 +103,22 @@ function withTimeout<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
     .finally(() => clearTimeout(timer))
 }
 
+/** HTTP 服务按会话建立连接,本地进程所有会话共用一个 */
+export const connScope = (s: Pick<McpServerRow, 'transport'>, sessionId: string | null | undefined) => (s.transport === 'http' ? sessionId ?? null : null)
+
 export class McpUpstream {
   private conns = new Map<string, Conn>()
   private pending = new Map<string, Promise<Conn>>()
   private errors = new Map<string, { error: string; stderr: string | null }>()
   private sweeper: NodeJS.Timeout
+  /** 后台长任务对连接的占用:任务可能绑定在这个连接(MCP 会话)上,占用期间不因空闲关闭 */
+  private holdCounts = new Map<string, number>()
+  /** 服务通知工具列表变化、或新建了连接时调用(由 McpServerService 刷新工具缓存) */
+  onToolsChanged?: (id: string) => void
+  /** 服务发来询问(由网关的询问处理决定归属并显示卡片) */
+  onElicit?: (e: ElicitContext) => Promise<ElicitResult>
+  /** OAuth 授权的服务:提供令牌(docs/design/16-mcp-support.md §5) */
+  authProvider?: (s: McpServerRow) => OAuthClientProvider | undefined
 
   constructor() {
     this.sweeper = setInterval(() => this.closeIdle(), 60_000)
@@ -84,7 +126,7 @@ export class McpUpstream {
   }
 
   status(id: string): { status: 'connected' | 'idle' | 'error'; lastError: string | null; stderrTail: string | null } {
-    const c = this.conns.get(id)
+    const c = [...this.conns.values()].find(x => x.serverId === id)
     const e = this.errors.get(id)
     if (c) return { status: 'connected', lastError: null, stderrTail: c.stderr.length ? c.stderr.join('\n') : null }
     return { status: e ? 'error' : 'idle', lastError: e?.error ?? null, stderrTail: e?.stderr ?? null }
@@ -92,22 +134,94 @@ export class McpUpstream {
 
   /** 列出工具(包括分页) */
   async listTools(s: McpServerRow): Promise<McpCachedTool[]> {
-    const c = await this.conn(s)
+    const c = await this.conn(s, null)
     return listAll(c.client)
   }
 
-  async callTool(s: McpServerRow, name: string, args: Record<string, unknown>, signal: AbortSignal, timeoutMs: number) {
-    const c = await this.conn(s)
+  /** 调用工具。不用 client.callTool:它在返回结构不符时直接报错,网关改为照常返回并记录(docs/design/16-mcp-support.md §2.2) */
+  async callTool(s: McpServerRow, name: string, args: Record<string, unknown>, signal: AbortSignal, timeoutMs: number, owner?: CallOwner): Promise<CallToolResult> {
+    return this.using(s, owner, c => c.client.request({ method: 'tools/call', params: { name, arguments: args } }, CallToolResultSchema, { signal, timeout: timeoutMs, resetTimeoutOnProgress: true, maxTotalTimeout: timeoutMs * 2 }))
+  }
+
+  /** 服务声明的能力(资源、提示模板);需要已经连接 */
+  async features(s: McpServerRow): Promise<McpFeatures> {
+    const caps = (await this.conn(s, null)).client.getServerCapabilities()
+    return { resources: !!caps?.resources, prompts: !!caps?.prompts }
+  }
+
+  // ---- 资源与提示模板(docs/design/16-mcp-support.md §6) ----
+
+  async listResources(s: McpServerRow, cursor?: string): Promise<ListResourcesResult> {
+    return (await this.conn(s, null)).client.listResources(cursor ? { cursor } : undefined, { timeout: 60_000 })
+  }
+
+  async listResourceTemplates(s: McpServerRow, cursor?: string): Promise<ListResourceTemplatesResult> {
+    return (await this.conn(s, null)).client.listResourceTemplates(cursor ? { cursor } : undefined, { timeout: 60_000 })
+  }
+
+  async readResource(s: McpServerRow, uri: string, owner?: CallOwner): Promise<ReadResourceResult> {
+    return this.using(s, owner, c => c.client.readResource({ uri }, { timeout: 120_000 }))
+  }
+
+  async listPrompts(s: McpServerRow, cursor?: string): Promise<ListPromptsResult> {
+    return (await this.conn(s, null)).client.listPrompts(cursor ? { cursor } : undefined, { timeout: 60_000 })
+  }
+
+  async getPrompt(s: McpServerRow, name: string, args: Record<string, string>, owner?: CallOwner): Promise<GetPromptResult> {
+    return this.using(s, owner, c => c.client.getPrompt({ name, arguments: args }, { timeout: 120_000 }))
+  }
+
+  // ---- 长任务(Tasks,docs/design/16-mcp-support.md §3) ----
+
+  /** 服务是否支持以任务方式调用工具(需要连接后才知道服务的能力) */
+  async supportsTasks(s: McpServerRow, owner?: CallOwner): Promise<boolean> {
+    const c = await this.conn(s, connScope(s, owner?.sessionId))
+    return !!c.client.getServerCapabilities()?.tasks?.requests?.tools?.call
+  }
+
+  /** 以任务方式调用工具:立即返回任务 */
+  async startTask(s: McpServerRow, name: string, args: Record<string, unknown>, ttlMs: number, signal: AbortSignal, owner?: CallOwner): Promise<CreateTaskResult> {
+    return this.using(s, owner, c => c.client.request({ method: 'tools/call', params: { name, arguments: args } }, CreateTaskResultSchema, { signal, timeout: 120_000, task: { ttl: ttlMs } }))
+  }
+
+  async getTask(s: McpServerRow, scope: string | null, taskId: string): Promise<GetTaskResult> {
+    const c = await this.conn(s, scope)
     c.lastUsed = Date.now()
-    c.inFlight++
+    return c.client.experimental.tasks.getTask(taskId, { timeout: 60_000 })
+  }
+
+  /** 取回任务结果:任务还没结束时会一直等到结束(期间服务可以借此发出询问) */
+  async taskResult(s: McpServerRow, scope: string | null, taskId: string, signal: AbortSignal, timeoutMs: number): Promise<CallToolResult> {
+    const c = await this.conn(s, scope)
+    const id = ++callSeq
+    c.calls.set(id, { sessionId: scope ?? '' })
     try {
-      return await c.client.callTool({ name, arguments: args }, undefined, { signal, timeout: timeoutMs, resetTimeoutOnProgress: true, maxTotalTimeout: timeoutMs * 2 })
+      return await c.client.experimental.tasks.getTaskResult(taskId, CallToolResultSchema, { signal, timeout: timeoutMs }) as CallToolResult
     } finally {
-      c.inFlight--
+      c.calls.delete(id)
       c.lastUsed = Date.now()
     }
   }
 
+  async cancelTask(s: McpServerRow, scope: string | null, taskId: string) {
+    const c = await this.conn(s, scope)
+    await c.client.experimental.tasks.cancelTask(taskId, { timeout: 30_000 })
+  }
+
+  /** 占用连接:任务进行中时连接不因空闲关闭(服务可能把任务绑定在 MCP 会话上)。返回释放函数 */
+  hold(s: McpServerRow, scope: string | null): () => void {
+    const name = connName(s.id, scope)
+    this.holdCounts.set(name, (this.holdCounts.get(name) ?? 0) + 1)
+    let released = false
+    return () => {
+      if (released) return
+      released = true
+      const n = (this.holdCounts.get(name) ?? 1) - 1
+      if (n > 0) this.holdCounts.set(name, n)
+      else this.holdCounts.delete(name)
+    }
+  }
+
   /** 用一份配置临时连接并列出工具(后台"测试连接"),不影响正在使用的连接 */
   async test(s: McpServerRow): Promise<{ tools: McpCachedTool[]; stderr: string[] }> {
     const stderr: string[] = []
@@ -119,62 +233,106 @@ export class McpUpstream {
     }
   }
 
+  /** 关闭某个会话使用的连接(偶发网络错误后重新连接);被长任务占用的不关闭 */
+  async reset(s: McpServerRow, sessionId: string) {
+    const name = connName(s.id, connScope(s, sessionId))
+    const c = this.conns.get(name)
+    if (c && !this.holdCounts.get(name)) await this.closeConn(c)
+  }
+
+  /** 关闭这个服务的所有连接 */
   async close(id: string) {
-    const c = this.conns.get(id)
-    this.conns.delete(id)
     this.errors.delete(id)
-    await c?.client.close().catch(() => {})
+    await Promise.all([...this.conns.values()].filter(c => c.serverId === id).map(c => this.closeConn(c)))
   }
 
   async closeAll() {
     clearInterval(this.sweeper)
-    await Promise.all([...this.conns.keys()].map(id => this.close(id)))
+    await Promise.all([...this.conns.values()].map(c => this.closeConn(c)))
+  }
+
+  private async closeConn(c: Conn) {
+    if (this.conns.get(c.name) === c) this.conns.delete(c.name)
+    await c.client.close().catch(() => {})
   }
 
   private closeIdle() {
-    for (const [id, c] of this.conns) if (c.inFlight === 0 && Date.now() - c.lastUsed > IDLE_MS) void this.close(id)
+    for (const c of this.conns.values()) {
+      if (c.calls.size === 0 && !this.holdCounts.get(c.name) && Date.now() - c.lastUsed > IDLE_MS) void this.closeConn(c)
+    }
   }
 
-  private async conn(s: McpServerRow): Promise<Conn> {
+  private async using<T>(s: McpServerRow, owner: CallOwner | undefined, fn: (c: Conn) => Promise<T>): Promise<T> {
+    const c = await this.conn(s, connScope(s, owner?.sessionId))
+    const id = ++callSeq
+    c.lastUsed = Date.now()
+    c.calls.set(id, owner ?? { sessionId: '' })
+    try {
+      return await fn(c)
+    } finally {
+      c.calls.delete(id)
+      c.lastUsed = Date.now()
+    }
+  }
+
+  private async conn(s: McpServerRow, scope: string | null): Promise<Conn> {
     const key = connKey(s)
-    const existing = this.conns.get(s.id)
+    const name = connName(s.id, scope)
+    const existing = this.conns.get(name)
     if (existing && existing.key === key) {
       existing.lastUsed = Date.now()
       return existing
     }
     if (existing) await this.close(s.id)
-    const inflight = this.pending.get(s.id)
+    const inflight = this.pending.get(name)
     if (inflight) return inflight
     const stderr: string[] = []
     // 远程服务偶发的网络错误重试一次
     const connectOnce = () => this.connect(s, stderr)
     const p = connectOnce().catch(err => (s.transport === 'http' && isTransient(err) ? connectOnce() : Promise.reject(err))).then(client => {
-      const c: Conn = { key, client, stderr, lastUsed: Date.now(), inFlight: 0 }
-      this.conns.set(s.id, c)
+      const c: Conn = { name, serverId: s.id, scope, key, client, stderr, lastUsed: Date.now(), calls: new Map() }
+      this.conns.set(name, c)
       this.errors.delete(s.id)
       // 连接断开(进程退出、服务端关闭)后下次调用重新连接
       client.onclose = () => {
-        if (this.conns.get(s.id) === c) this.conns.delete(s.id)
+        if (this.conns.get(name) === c) this.conns.delete(name)
       }
+      if (client.getServerCapabilities()?.tools?.listChanged) {
+        client.setNotificationHandler(ToolListChangedNotificationSchema, () => this.onToolsChanged?.(s.id))
+      }
+      client.setRequestHandler(ElicitRequestSchema, async req => {
+        const meta = req.params._meta as Record<string, { taskId?: unknown } | undefined> | undefined
+        const taskId = typeof meta?.[RELATED_TASK]?.taskId === 'string' ? meta[RELATED_TASK]!.taskId as string : null
+        const owners = [...c.calls.values()].filter(o => o.sessionId)
+        if (!this.onElicit) return { action: 'decline' as const }
+        return this.onElicit({ row: s, params: req.params, taskId, scope, owners })
+      })
+      // 新连接:在后台刷新一次工具缓存,避免长期过时
+      if (!scope) this.onToolsChanged?.(s.id)
       return c
     }, (err: Error) => {
       this.errors.set(s.id, { error: err.message, stderr: stderr.length ? stderr.join('\n') : null })
       throw err
-    }).finally(() => this.pending.delete(s.id))
-    this.pending.set(s.id, p)
+    }).finally(() => this.pending.delete(name))
+    this.pending.set(name, p)
     return p
   }
 
   private async connect(s: McpServerRow, stderr: string[]): Promise<Client> {
-    const client = new Client({ name: 'agent-server-gateway', version: APP_VERSION })
+    const newClient = () => new Client({ name: 'agent-server-gateway', version: APP_VERSION }, { capabilities: { elicitation: { form: {}, url: {} } } })
+    const client = newClient()
     if (s.transport === 'http') {
       const url = new URL(s.url)
       const requestInit: RequestInit = { headers: s.headers }
+      const authProvider = this.authProvider?.(s)
+      if (s.oauth?.enabled && !s.oauth.tokens) throw new Error(NEED_OAUTH)
       try {
-        await withTimeout(client.connect(new StreamableHTTPClientTransport(url, { requestInit })), CONNECT_TIMEOUT_MS, '连接')
+        await withTimeout(client.connect(new StreamableHTTPClientTransport(url, { requestInit, authProvider })), CONNECT_TIMEOUT_MS, '连接')
       } catch (err) {
+        if (err instanceof UnauthorizedError) throw new Error(authProvider ? NEED_OAUTH : '服务要求授权(401):请填写访问令牌,或启用 OAuth 授权')
+        if (authProvider) throw err
         // 旧版服务只支持 SSE
-        const fallback = new Client({ name: 'agent-server-gateway', version: APP_VERSION })
+        const fallback = newClient()
         try {
           await withTimeout(fallback.connect(new SSEClientTransport(url, { requestInit, eventSourceInit: { fetch: (u, init) => fetch(u, { ...init, headers: { ...(init?.headers as Record<string, string>), ...s.headers } }) } })), CONNECT_TIMEOUT_MS, '连接')
           return fallback
@@ -210,13 +368,27 @@ export class McpUpstream {
   }
 }
 
+const connName = (id: string, scope: string | null) => (scope ? `${id}@${scope}` : id)
+
 async function listAll(client: Client): Promise<McpCachedTool[]> {
   const tools: McpCachedTool[] = []
+  // 只提供资源或提示模板的服务没有工具
+  if (!client.getServerCapabilities()?.tools) return tools
   let cursor: string | undefined
   for (let page = 0; page < 20; page++) {
     const r = await withTimeout(client.listTools(cursor ? { cursor } : undefined), 60_000, '列出工具')
     for (const t of r.tools) {
-      tools.push({ name: t.name, description: t.description ?? '', inputSchema: t.inputSchema as Record<string, unknown>, readOnly: t.annotations?.readOnlyHint === true })
+      const a = t.annotations ?? {}
+      const annotations = Object.fromEntries((['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'] as const)
+        .filter(k => typeof a[k] === 'boolean').map(k => [k, a[k]]))
+      const taskSupport = t.execution?.taskSupport
+      tools.push({
+        name: t.name, description: t.description ?? '', inputSchema: t.inputSchema as Record<string, unknown>, readOnly: a.readOnlyHint === true,
+        ...(t.title || a.title ? { title: t.title ?? a.title } : {}),
+        ...(Object.keys(annotations).length ? { annotations } : {}),
+        ...(t.outputSchema ? { outputSchema: t.outputSchema as Record<string, unknown> } : {}),
+        ...(taskSupport === 'required' || taskSupport === 'optional' ? { taskSupport } : {}),
+      })
     }
     cursor = r.nextCursor
     if (!cursor) break

+ 2 - 2
apps/server/src/providers/types.ts

@@ -1,7 +1,7 @@
 import type { AgentEventType, AppSettings, EventPayloads, PermissionMode, ProviderId, ProviderQuotaDto, ProviderStatusDto, UsagePayload } from '@agent-server/shared'
 import type { ResolvedAccess } from '../services/access-policy'
 import type { PluginRef } from './claude/skills'
-import type { InteractionOutcome, NewApproval, NewQuestion } from '../services/interactions'
+import type { InteractionOutcome, NewApproval, NewForm, NewQuestion } from '../services/interactions'
 
 export interface RunInput {
   text: string
@@ -31,7 +31,7 @@ export interface RunContext {
   settings: AppSettings
   signal: AbortSignal
   emit<T extends AgentEventType>(type: T, payload: EventPayloads[T]): void
-  interact(req: NewApproval | NewQuestion): Promise<InteractionOutcome>
+  interact(req: NewApproval | NewQuestion | NewForm): Promise<InteractionOutcome>
   setProviderSessionId(id: string): void
   audit(kind: 'command' | 'file_access_denied' | 'network_denied' | 'approval', summary: string, detail?: unknown): void
   /** 开放哪些 Codex 远程插件与应用连接器:Playground 全部开放;其他会话只开放 Profile 勾选的插件(ADR-0018) */

+ 87 - 4
apps/server/src/services/interactions.ts

@@ -2,11 +2,15 @@
 import { and, eq } from 'drizzle-orm'
 import type {
   InteractionApprovalRequest,
+  InteractionFormField,
+  InteractionFormRequest,
+  InteractionFormValue,
   InteractionQuestionRequest,
   InteractionRequest,
   InteractionResolution,
   InteractionResponse,
 } from '@agent-server/shared'
+import { z } from 'zod'
 import type { DB } from '../db'
 import { interactions } from '../db/schema'
 import { badRequest, conflict, notFound } from '../lib/errors'
@@ -14,18 +18,31 @@ import { newId, nowIso } from '../lib/ids'
 import type { EventStore } from './event-store'
 import type { SettingsService } from './settings'
 
+/** 回复交互的请求体(Agent API 与 Bot API 共用) */
+export const interactionResponseSchema: z.ZodType<InteractionResponse> = z.union([
+  z.object({ decision: z.enum(['allow_once', 'allow_session']) }),
+  z.object({ decision: z.literal('deny'), reason: z.string().max(2000).optional() }),
+  z.object({ answers: z.record(z.string(), z.union([z.string(), z.array(z.string())])) }),
+  z.object({ action: z.literal('accept'), content: z.record(z.string(), z.union([z.string().max(20_000), z.number(), z.boolean(), z.array(z.string().max(2000)).max(100)])).optional() }),
+  z.object({ action: z.enum(['decline', 'cancel']) }),
+])
+
 export type NewApproval = Omit<InteractionApprovalRequest, 'interactionId' | 'expiresAt'>
 export type NewQuestion = Omit<InteractionQuestionRequest, 'interactionId' | 'expiresAt'>
+export type NewForm = Omit<InteractionFormRequest, 'interactionId' | 'expiresAt'>
 
 export type InteractionOutcome =
   | { resolution: 'allowed'; scope: 'once' | 'session' }
   | { resolution: 'denied'; reason?: string }
   | { resolution: 'answered'; answers: Record<string, string | string[]> }
+  /** 表单询问:提交了内容(记录为 answered) */
+  | { resolution: 'submitted'; content: Record<string, InteractionFormValue> }
   | { resolution: 'timeout' | 'cancelled' }
 
 interface Pending {
   sessionId: string
-  runId: string
+  /** 不属于某一轮的交互(后台长任务中服务发来的询问)为 null */
+  runId: string | null
   request: InteractionRequest
   resolve: (o: InteractionOutcome) => void
   timer: NodeJS.Timeout
@@ -43,7 +60,7 @@ export class InteractionService {
     db.update(interactions).set({ status: 'cancelled', resolvedAt: nowIso() }).where(eq(interactions.status, 'pending')).run()
   }
 
-  request(sessionId: string, runId: string, req: NewApproval | NewQuestion, signal?: AbortSignal): Promise<InteractionOutcome> {
+  request(sessionId: string, runId: string | null, req: NewApproval | NewQuestion | NewForm, signal?: AbortSignal): Promise<InteractionOutcome> {
     const id = newId('itx')
     const timeoutMs = this.settings.get().limits.interactionTimeoutSec * 1000
     const request = { ...req, interactionId: id, expiresAt: new Date(Date.now() + timeoutMs).toISOString() } as InteractionRequest
@@ -69,7 +86,14 @@ export class InteractionService {
     if (sessionScope && !sessionScope(p.sessionId)) throw notFound('交互')
 
     let outcome: InteractionOutcome
-    if ('answers' in body) {
+    if ('action' in body) {
+      if (p.request.kind !== 'form') throw badRequest('只有表单类交互使用 action 字段')
+      outcome = body.action === 'accept'
+        ? { resolution: 'submitted', content: p.request.mode === 'form' ? validateFormContent(p.request.fields, body.content ?? {}) : {} }
+        : body.action === 'decline' ? { resolution: 'denied' } : { resolution: 'cancelled' }
+    } else if (p.request.kind === 'form') {
+      throw badRequest('表单类交互需要 action 字段')
+    } else if ('answers' in body) {
       if (p.request.kind !== 'question') throw badRequest('审批类交互需要 decision 字段')
       outcome = { resolution: 'answered', answers: body.answers }
     } else {
@@ -90,6 +114,11 @@ export class InteractionService {
     for (const [id, p] of this.pending) if (p.runId === runId) this.finish(id, { resolution: 'cancelled' }, null)
   }
 
+  /** 取消会话中所有未决的交互(会话删除时) */
+  cancelSession(sessionId: string) {
+    for (const [id, p] of this.pending) if (p.sessionId === sessionId) this.finish(id, { resolution: 'cancelled' }, null)
+  }
+
   hasPending(runId: string) {
     for (const p of this.pending.values()) if (p.runId === runId) return true
     return false
@@ -100,7 +129,7 @@ export class InteractionService {
     if (!p) return
     this.pending.delete(id)
     clearTimeout(p.timer)
-    const status: InteractionResolution = outcome.resolution
+    const status: InteractionResolution = outcome.resolution === 'submitted' ? 'answered' : outcome.resolution
     this.db.update(interactions)
       .set({ status, response, resolvedAt: nowIso() })
       .where(and(eq(interactions.id, id), eq(interactions.status, 'pending')))
@@ -110,7 +139,61 @@ export class InteractionService {
       resolution: status,
       ...(outcome.resolution === 'answered' ? { answers: outcome.answers } : {}),
       ...(outcome.resolution === 'denied' && outcome.reason ? { reason: outcome.reason } : {}),
+      ...(outcome.resolution === 'submitted' ? { content: outcome.content } : {}),
     })
     p.resolve(outcome)
   }
 }
+
+/** 校验表单内容:只保留声明的字段,类型、必填、格式、范围不符时报错 */
+export function validateFormContent(fields: InteractionFormField[], content: Record<string, unknown>): Record<string, InteractionFormValue> {
+  const out: Record<string, InteractionFormValue> = {}
+  for (const f of fields) {
+    const v = content[f.name]
+    const label = f.title || f.name
+    if (v === undefined || v === null || v === '' || (Array.isArray(v) && !v.length && f.type === 'choice' && f.multiple && f.required)) {
+      if (f.required) throw badRequest(`请填写:${label}`)
+      continue
+    }
+    switch (f.type) {
+      case 'string': {
+        if (typeof v !== 'string') throw badRequest(`${label} 应为文本`)
+        if (f.minLength !== undefined && v.length < f.minLength) throw badRequest(`${label} 至少 ${f.minLength} 个字`)
+        if (f.maxLength !== undefined && v.length > f.maxLength) throw badRequest(`${label} 最多 ${f.maxLength} 个字`)
+        if (f.format === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) throw badRequest(`${label} 不是有效的邮箱地址`)
+        if (f.format === 'uri' && !/^[a-z][a-z0-9+.-]*:/i.test(v)) throw badRequest(`${label} 不是有效的网址`)
+        if (f.format === 'date' && !/^\d{4}-\d{2}-\d{2}$/.test(v)) throw badRequest(`${label} 应为日期(YYYY-MM-DD)`)
+        if (f.format === 'date-time' && Number.isNaN(Date.parse(v))) throw badRequest(`${label} 应为日期时间`)
+        out[f.name] = v
+        break
+      }
+      case 'number':
+      case 'integer': {
+        const n = typeof v === 'string' ? Number(v) : v
+        if (typeof n !== 'number' || !Number.isFinite(n) || (f.type === 'integer' && !Number.isInteger(n))) throw badRequest(`${label} 应为${f.type === 'integer' ? '整数' : '数字'}`)
+        if (f.minimum !== undefined && n < f.minimum) throw badRequest(`${label} 不能小于 ${f.minimum}`)
+        if (f.maximum !== undefined && n > f.maximum) throw badRequest(`${label} 不能大于 ${f.maximum}`)
+        out[f.name] = n
+        break
+      }
+      case 'boolean':
+        if (typeof v !== 'boolean') throw badRequest(`${label} 应为是或否`)
+        out[f.name] = v
+        break
+      case 'choice': {
+        const allowed = new Set(f.options.map(o => o.value))
+        if (f.multiple) {
+          if (!Array.isArray(v) || v.some(x => typeof x !== 'string' || !allowed.has(x))) throw badRequest(`${label} 的选项不正确`)
+          if (f.minItems !== undefined && v.length < f.minItems) throw badRequest(`${label} 至少选 ${f.minItems} 项`)
+          if (f.maxItems !== undefined && v.length > f.maxItems) throw badRequest(`${label} 最多选 ${f.maxItems} 项`)
+          out[f.name] = v as string[]
+        } else {
+          if (typeof v !== 'string' || !allowed.has(v)) throw badRequest(`${label} 的选项不正确`)
+          out[f.name] = v
+        }
+        break
+      }
+    }
+  }
+  return out
+}

+ 215 - 0
apps/server/src/services/mcp-content.ts

@@ -0,0 +1,215 @@
+// 第三方 MCP 服务的资源(Resources)与提示模板(Prompts,docs/design/16-mcp-support.md §6):
+// 模型经网关工具按需列出、读取资源;用户在会话中附加资源、使用提示模板。只涉及 Profile 勾选的服务
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import path from 'node:path'
+import { Readable } from 'node:stream'
+import type {
+  FileDto, McpPromptDto, McpPromptListDto, McpPromptResultDto, McpResourceDto, McpResourceListDto, McpResourceTemplateDto, McpSourceRef,
+} from '@agent-server/shared'
+import { z } from 'zod'
+import { badRequest, notFound } from '../lib/errors'
+import { fail, ok, type BuiltinTool } from '../mcp-gateway/builtin'
+import type { GatewayRunContext } from '../mcp-gateway/tokens'
+import type { McpServerRow, McpUpstream } from '../mcp-gateway/upstream'
+import type { FileService } from './files'
+import type { McpServerService } from './mcp-servers'
+import type { SettingsService } from './settings'
+
+/** 读取的文本资源超过这个长度时保存为文件,只返回开头和路径 */
+const MAX_INLINE_TEXT = 20_000
+const MAX_PAGES = 5
+
+type ReadContent = { uri: string; mimeType?: string; text?: string; blob?: string }
+
+const ref = (r: McpServerRow): McpSourceRef => ({ serverId: r.id, serverName: r.name, serverTitle: r.title })
+
+/** 资源 URI 的最后一段作为文件名(只保留安全字符) */
+function fileNameOf(uri: string, mimeType: string | undefined, fallback: string): string {
+  let last = uri.split(/[?#]/)[0]!.split('/').filter(Boolean).at(-1) ?? ''
+  try {
+    last = decodeURIComponent(last)
+  } catch {
+    // 保留原文
+  }
+  let safe = last.replace(/[^\p{L}\p{N}._-]+/gu, '_').replace(/^[._]+/, '').slice(0, 120) || fallback
+  if (!path.extname(safe)) {
+    const ext = mimeType === 'application/json' ? '.json' : mimeType?.startsWith('text/markdown') ? '.md' : mimeType?.startsWith('text/') ? '.txt' : ''
+    safe += ext
+  }
+  return safe
+}
+
+export class McpContentService {
+  constructor(private deps: { mcp: McpServerService; upstream: McpUpstream; files: FileService; settings: SettingsService }) {}
+
+  /** 提供资源 / 提示模板的服务(Profile 勾选、已启用) */
+  servers(ids: string[], feature: 'resources' | 'prompts'): McpServerRow[] {
+    return this.deps.mcp.rows(ids).filter(r => r.features[feature])
+  }
+
+  private server(ids: string[], serverId: string, feature: 'resources' | 'prompts'): McpServerRow {
+    const r = this.servers(ids, feature).find(x => x.id === serverId || x.name === serverId)
+    if (!r) throw notFound(feature === 'resources' ? '提供资源的 MCP 服务' : '提供提示模板的 MCP 服务')
+    return r
+  }
+
+  // ---- 资源 ----
+
+  /** 列出资源与资源模板:指定服务时分页;不指定时每个服务取第一页 */
+  async listResources(ids: string[], serverId?: string, cursor?: string): Promise<McpResourceListDto> {
+    const rows = serverId ? [this.server(ids, serverId, 'resources')] : this.servers(ids, 'resources')
+    const out: McpResourceListDto = { resources: [], templates: [], nextCursor: null, errors: [] }
+    await Promise.all(rows.map(async row => {
+      try {
+        const r = await this.deps.upstream.listResources(row, cursor)
+        out.resources.push(...r.resources.map((x): McpResourceDto => ({
+          ...ref(row), uri: x.uri, name: x.name, title: x.title ?? null, description: x.description ?? null, mimeType: x.mimeType ?? null, size: x.size ?? null,
+        })))
+        if (rows.length === 1) out.nextCursor = r.nextCursor ?? null
+        if (!cursor) {
+          const t = await this.deps.upstream.listResourceTemplates(row).catch(() => ({ resourceTemplates: [] }))
+          out.templates.push(...t.resourceTemplates.map((x): McpResourceTemplateDto => ({
+            ...ref(row), uriTemplate: x.uriTemplate, name: x.name, title: x.title ?? null, description: x.description ?? null, mimeType: x.mimeType ?? null,
+          })))
+        }
+      } catch (err) {
+        out.errors.push({ serverTitle: row.title, error: (err as Error).message })
+      }
+    }))
+    return out
+  }
+
+  private async read(row: McpServerRow, uri: string, sessionId: string): Promise<ReadContent[]> {
+    if (!/^[a-z][a-z0-9+.-]*:/i.test(uri)) throw badRequest('资源地址(URI)不正确')
+    const r = await this.deps.upstream.readResource(row, uri, { sessionId })
+    return r.contents as ReadContent[]
+  }
+
+  /** 用户附加资源:读取后保存为会话的上传文件,作为附件发出 */
+  async attach(ids: string[], session: { id: string; workspaceDir: string }, serverId: string, uri: string): Promise<FileDto[]> {
+    const row = this.server(ids, serverId, 'resources')
+    const contents = await this.read(row, uri, session.id)
+    if (!contents.length) throw badRequest('资源没有内容')
+    return Promise.all(contents.map(c => this.saveUpload(session, c, `${row.name}-resource`)))
+  }
+
+  private saveUpload(session: { id: string; workspaceDir: string }, c: ReadContent, fallback: string): Promise<FileDto> {
+    const data = typeof c.blob === 'string' ? Buffer.from(c.blob, 'base64') : Buffer.from(c.text ?? '', 'utf8')
+    const limitBytes = this.deps.settings.get().limits.uploadMaxMB * 1048576
+    if (data.length > limitBytes) throw badRequest(`资源超过上传大小上限 ${Math.round(limitBytes / 1048576)} MB`)
+    return this.deps.files.saveUpload({
+      sessionId: session.id, workspaceDir: session.workspaceDir, filename: fileNameOf(c.uri, c.mimeType, fallback),
+      stream: Readable.from(data), limitBytes, truncated: () => false,
+    })
+  }
+
+  // ---- 提示模板 ----
+
+  async listPrompts(ids: string[]): Promise<McpPromptListDto> {
+    const out: McpPromptListDto = { prompts: [], errors: [] }
+    await Promise.all(this.servers(ids, 'prompts').map(async row => {
+      try {
+        let cursor: string | undefined
+        for (let page = 0; page < MAX_PAGES; page++) {
+          const r = await this.deps.upstream.listPrompts(row, cursor)
+          out.prompts.push(...r.prompts.map((p): McpPromptDto => ({
+            ...ref(row), name: p.name, title: p.title ?? null, description: p.description ?? null,
+            arguments: (p.arguments ?? []).map(a => ({ name: a.name, description: a.description ?? null, required: a.required === true })),
+          })))
+          cursor = r.nextCursor
+          if (!cursor) break
+        }
+      } catch (err) {
+        out.errors.push({ serverTitle: row.title, error: (err as Error).message })
+      }
+    }))
+    return out
+  }
+
+  /** 展开提示模板:文本合并后放进输入框;嵌入的资源、图片保存为附件 */
+  async getPrompt(ids: string[], session: { id: string; workspaceDir: string }, serverId: string, name: string, args: Record<string, string>): Promise<McpPromptResultDto> {
+    const row = this.server(ids, serverId, 'prompts')
+    const r = await this.deps.upstream.getPrompt(row, name, args, { sessionId: session.id })
+    const texts: string[] = []
+    const attachments: FileDto[] = []
+    for (const m of r.messages) {
+      const c = m.content as { type: string; text?: string; data?: string; mimeType?: string; resource?: ReadContent; uri?: string }
+      if (c.type === 'text' && c.text) texts.push(c.text)
+      else if (c.type === 'resource' && c.resource) attachments.push(await this.saveUpload(session, c.resource, `${row.name}-${name}`))
+      else if ((c.type === 'image' || c.type === 'audio') && c.data) {
+        const ext = c.mimeType?.split('/')[1]?.replace(/\W/g, '') || 'bin'
+        attachments.push(await this.saveUpload(session, { uri: `${name}-${crypto.randomBytes(3).toString('hex')}.${ext}`, blob: c.data, mimeType: c.mimeType }, `${row.name}-${name}`))
+      } else if (c.type === 'resource_link' && c.uri) texts.push(c.uri)
+    }
+    return { text: texts.join('\n\n'), attachments }
+  }
+
+  // ---- 提供给模型的工具 ----
+
+  /** Profile 勾选的服务中有提供资源的,网关额外提供两个只读工具 */
+  gatewayTools(ctx: GatewayRunContext): BuiltinTool[] {
+    if (!this.servers(ctx.mcpServerIds, 'resources').length) return []
+    return [
+      {
+        name: 'mcp_resources', readOnly: true,
+        description: '列出 MCP 服务提供的资源(如文档、配置、数据表结构)和资源模板,只返回目录(地址、名称、说明),不返回内容。需要时再用 mcp_resource_read 读取。不指定 server 时列出每个服务的第一页。',
+        schema: z.object({
+          server: z.string().optional().describe('服务名(工具名前缀),不填为全部'),
+          cursor: z.string().optional().describe('翻页游标(指定 server 时有效)'),
+        }),
+        handler: async (args, c) => {
+          const r = await this.listResources(c.mcpServerIds, args.server as string | undefined, args.cursor as string | undefined)
+          const lines = [
+            ...r.resources.map(x => `- [${x.serverName}] ${x.title ?? x.name}  ${x.uri}${x.mimeType ? `  (${x.mimeType}${x.size ? `, ${x.size} B` : ''})` : ''}${x.description ? `\n  ${x.description}` : ''}`),
+            ...r.templates.map(x => `- [${x.serverName}] 模板 ${x.title ?? x.name}  ${x.uriTemplate}${x.description ? `\n  ${x.description}` : ''}(填好 {参数} 后用 mcp_resource_read 读取)`),
+          ]
+          if (!lines.length) lines.push('(没有资源)')
+          if (r.nextCursor) lines.push(`\n还有更多,翻页游标:${r.nextCursor}`)
+          for (const e of r.errors) lines.push(`\n(${e.serverTitle} 列出失败:${e.error})`)
+          return ok(lines.join('\n'))
+        },
+      },
+      {
+        name: 'mcp_resource_read', readOnly: true,
+        description: `读取 MCP 服务的一个资源。文本不超过 ${MAX_INLINE_TEXT} 字直接返回;更长的或二进制内容保存到工作区文件,返回路径,可以再分段读取或检索。`,
+        schema: z.object({
+          server: z.string().describe('服务名(工具名前缀)'),
+          uri: z.string().describe('资源地址(来自 mcp_resources 的列表,或填好参数的资源模板)'),
+        }),
+        handler: async (args, c) => {
+          const row = this.server(c.mcpServerIds, args.server as string, 'resources')
+          let contents: ReadContent[]
+          try {
+            contents = await this.read(row, args.uri as string, c.sessionId)
+          } catch (err) {
+            return fail(`读取失败:${(err as Error).message}`)
+          }
+          if (!contents.length) return ok('(资源没有内容)')
+          return ok(contents.map(x => this.inline(x, c.workspaceDir, row.name)).join('\n\n'))
+        },
+      },
+    ]
+  }
+
+  private inline(c: ReadContent, workspaceDir: string, server: string): string {
+    const dir = path.join(workspaceDir, '.agent', 'mcp', 'resources')
+    const save = (data: string | Buffer) => {
+      fs.mkdirSync(dir, { recursive: true })
+      const name = fileNameOf(c.uri, c.mimeType, `${server}-resource`)
+      const ext = path.extname(name)
+      let file = path.join(dir, name)
+      for (let i = 2; fs.existsSync(file); i++) file = path.join(dir, `${name.slice(0, name.length - ext.length)}-${i}${ext}`)
+      fs.writeFileSync(file, data)
+      return path.relative(workspaceDir, file)
+    }
+    const head = `${c.uri}${c.mimeType ? `(${c.mimeType})` : ''}`
+    if (typeof c.blob === 'string') {
+      const data = Buffer.from(c.blob, 'base64')
+      return `${head}\n二进制内容(${data.length} B)已保存到 ${save(data)}`
+    }
+    const text = c.text ?? ''
+    if (text.length <= MAX_INLINE_TEXT) return `${head}\n${text}`
+    return `${head}\n${text.slice(0, 2000)}\n…(共 ${text.length} 字,完整内容已保存到 ${save(text)})`
+  }
+}

+ 185 - 0
apps/server/src/services/mcp-oauth.ts

@@ -0,0 +1,185 @@
+// 第三方 MCP 服务的 OAuth 授权(按服务共享,docs/design/16-mcp-support.md §5):按 MCP 规范的授权流程
+// (OAuth 2.1 + PKCE,授权服务器元数据发现,支持动态注册),由管理员在后台发起;令牌保存在数据库中,过期前由 SDK 用刷新令牌续期
+import crypto from 'node:crypto'
+import { eq } from 'drizzle-orm'
+import { auth, type OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
+import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'
+import { APP_VERSION } from '../config'
+import type { DB } from '../db'
+import { mcpServers, type McpOAuthState } from '../db/schema'
+import { badRequest, notFound } from '../lib/errors'
+import { nowIso } from '../lib/ids'
+import type { McpServerRow, McpUpstream } from '../mcp-gateway/upstream'
+
+/** 授权网址的有效期 */
+const STATE_TTL_MS = 15 * 60_000
+export const OAUTH_CALLBACK_PATH = '/admin/v1/mcp/oauth/callback'
+
+/** SDK 使用的授权信息存取:直接读写数据库中这个服务的 oauth 字段 */
+class DbOAuthProvider implements OAuthClientProvider {
+  /** 发起授权时 SDK 给出的授权网址 */
+  authorizationUrl: URL | null = null
+
+  /** interactive:管理员在后台发起的授权;否则是运行中连接时由 SDK 使用(不能弹出授权) */
+  constructor(private db: DB, private id: string, private interactive = false) {}
+
+  private get s(): McpOAuthState {
+    const r = this.db.select({ oauth: mcpServers.oauth }).from(mcpServers).where(eq(mcpServers.id, this.id)).get()
+    return r?.oauth ?? { enabled: false, clientId: '', clientSecret: '', scope: '' }
+  }
+
+  private save(patch: Partial<McpOAuthState>) {
+    this.db.update(mcpServers).set({ oauth: { ...this.s, ...patch } }).where(eq(mcpServers.id, this.id)).run()
+  }
+
+  get redirectUrl() {
+    return this.s.redirectUrl
+  }
+
+  get clientMetadata(): OAuthClientMetadata {
+    const s = this.s
+    return {
+      client_name: `AgentServer ${APP_VERSION}`,
+      redirect_uris: s.redirectUrl ? [s.redirectUrl] : [],
+      grant_types: ['authorization_code', 'refresh_token'],
+      response_types: ['code'],
+      token_endpoint_auth_method: s.clientSecret ? 'client_secret_post' : 'none',
+      ...(s.scope ? { scope: s.scope } : {}),
+    }
+  }
+
+  state() {
+    const state = crypto.randomBytes(24).toString('base64url')
+    if (this.interactive) this.save({ state, stateExpiresAt: new Date(Date.now() + STATE_TTL_MS).toISOString() })
+    return state
+  }
+
+  clientInformation(): OAuthClientInformationMixed | undefined {
+    const s = this.s
+    if (s.clientId) return { client_id: s.clientId, ...(s.clientSecret ? { client_secret: s.clientSecret } : {}) }
+    return s.clientInfo as OAuthClientInformationMixed | undefined
+  }
+
+  saveClientInformation(info: OAuthClientInformationMixed) {
+    this.save({ clientInfo: info as Record<string, unknown> })
+  }
+
+  tokens(): OAuthTokens | undefined {
+    return this.s.tokens as OAuthTokens | undefined
+  }
+
+  saveTokens(tokens: OAuthTokens) {
+    this.save({ tokens: tokens as Record<string, unknown>, lastError: undefined })
+  }
+
+  redirectToAuthorization(url: URL) {
+    // 后台发起授权时由调用方取走网址;运行中需要重新授权时记录下来,后台显示"需要重新授权"
+    this.authorizationUrl = url
+    if (!this.interactive) this.save({ lastError: '授权已失效(令牌过期且无法续期,或被撤销),需要在后台重新授权' })
+  }
+
+  saveCodeVerifier(v: string) {
+    if (this.interactive) this.save({ codeVerifier: v })
+  }
+
+  codeVerifier() {
+    const v = this.s.codeVerifier
+    if (!v) throw new Error('没有进行中的授权')
+    return v
+  }
+
+  invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery') {
+    if (scope === 'all' || scope === 'tokens') this.save({ tokens: undefined })
+    if (scope === 'all' || scope === 'client') this.save({ clientInfo: undefined })
+    if (scope === 'all' || scope === 'verifier') this.save({ codeVerifier: undefined })
+  }
+}
+
+export class McpOAuthService {
+  constructor(private db: DB, private upstream: McpUpstream, private audit: (summary: string) => void, private onAuthorized: (id: string) => Promise<unknown>) {
+    upstream.authProvider = row => (row.transport === 'http' && row.oauth?.enabled ? new DbOAuthProvider(db, row.id) : undefined)
+  }
+
+  /** 发起授权:返回授权网址,由管理员在浏览器中打开;已有有效令牌时直接返回已授权 */
+  async start(id: string, origin: string): Promise<{ authorizationUrl: string | null }> {
+    const row = this.row(id)
+    if (row.transport !== 'http' || !row.oauth?.enabled) throw badRequest('这个服务没有启用 OAuth 授权')
+    const redirectUrl = `${origin}${OAUTH_CALLBACK_PATH}`
+    // 回调地址变了(换了访问后台的地址)时,动态注册的客户端要重新注册
+    const changed = row.oauth.redirectUrl !== redirectUrl
+    this.patch(id, { redirectUrl, tokens: undefined, codeVerifier: undefined, state: undefined, lastError: undefined, ...(changed ? { clientInfo: undefined } : {}) })
+    const provider = new DbOAuthProvider(this.db, id, true)
+    try {
+      const r = await auth(provider, { serverUrl: row.url, ...(row.oauth.scope ? { scope: row.oauth.scope } : {}) })
+      if (r === 'AUTHORIZED') {
+        await this.authorized(id)
+        return { authorizationUrl: null }
+      }
+      if (!provider.authorizationUrl) throw new Error('授权服务器没有返回授权网址')
+      this.audit(`MCP 服务 ${row.name} 发起 OAuth 授权`)
+      return { authorizationUrl: provider.authorizationUrl.toString() }
+    } catch (err) {
+      this.patch(id, { lastError: `发起授权失败:${(err as Error).message}`, state: undefined })
+      throw badRequest(`发起授权失败:${(err as Error).message}`)
+    }
+  }
+
+  /** 授权服务器回调:按 state 找到服务,用授权码换取令牌 */
+  async callback(q: { state?: string; code?: string; error?: string; error_description?: string }): Promise<{ ok: boolean; title: string; message: string }> {
+    const row = q.state ? this.db.select().from(mcpServers).all().find(r => r.oauth?.state && safeEqual(r.oauth.state, q.state!)) : undefined
+    if (!row?.oauth) return { ok: false, title: '', message: '授权请求无效或已过期,请回到后台重新发起授权' }
+    if (!row.oauth.stateExpiresAt || Date.parse(row.oauth.stateExpiresAt) < Date.now()) {
+      this.patch(row.id, { state: undefined })
+      return { ok: false, title: row.title, message: '授权请求已过期,请回到后台重新发起授权' }
+    }
+    // state 只能用一次
+    this.patch(row.id, { state: undefined, stateExpiresAt: undefined })
+    if (q.error || !q.code) {
+      const message = `授权未完成:${q.error_description ?? q.error ?? '没有授权码'}`
+      this.patch(row.id, { lastError: message })
+      return { ok: false, title: row.title, message }
+    }
+    try {
+      const r = await auth(new DbOAuthProvider(this.db, row.id, true), { serverUrl: row.url, authorizationCode: q.code })
+      if (r !== 'AUTHORIZED') throw new Error('没有得到令牌')
+      await this.authorized(row.id)
+      this.audit(`MCP 服务 ${row.name} OAuth 授权完成`)
+      return { ok: true, title: row.title, message: '授权完成,可以关闭这个窗口' }
+    } catch (err) {
+      const message = `换取令牌失败:${(err as Error).message}`
+      this.patch(row.id, { lastError: message, codeVerifier: undefined })
+      return { ok: false, title: row.title, message }
+    }
+  }
+
+  /** 撤销:清除令牌(授权服务器上的授权需要到对方平台撤销) */
+  async revoke(id: string) {
+    const row = this.row(id)
+    this.patch(id, { tokens: undefined, authorizedAt: undefined, codeVerifier: undefined, state: undefined, lastError: undefined })
+    await this.upstream.close(id)
+    this.audit(`MCP 服务 ${row.name} 清除 OAuth 令牌`)
+  }
+
+  private async authorized(id: string) {
+    this.patch(id, { authorizedAt: nowIso(), codeVerifier: undefined, lastError: undefined })
+    await this.upstream.close(id)
+    await this.onAuthorized(id).catch(() => {})
+  }
+
+  private row(id: string): McpServerRow {
+    const r = this.db.select().from(mcpServers).where(eq(mcpServers.id, id)).get()
+    if (!r) throw notFound('MCP 服务')
+    return r
+  }
+
+  private patch(id: string, p: Partial<McpOAuthState>) {
+    const cur = this.row(id).oauth
+    if (cur) this.db.update(mcpServers).set({ oauth: { ...cur, ...p } }).where(eq(mcpServers.id, id)).run()
+  }
+}
+
+function safeEqual(a: string, b: string) {
+  const x = Buffer.from(a)
+  const y = Buffer.from(b)
+  return x.length === y.length && crypto.timingSafeEqual(x, y)
+}

+ 100 - 16
apps/server/src/services/mcp-servers.ts

@@ -3,10 +3,10 @@ import fs from 'node:fs'
 import path from 'node:path'
 import { eq } from 'drizzle-orm'
 import { z } from 'zod'
-import type { AppSettings, McpSecretEntry, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolDto, McpToolPolicy } from '@agent-server/shared'
+import type { AppSettings, McpSecretEntry, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolDto, McpToolHints, McpToolPolicy } from '@agent-server/shared'
 import { config } from '../config'
 import type { DB } from '../db'
-import { mcpServers, profiles, type McpCachedTool } from '../db/schema'
+import { mcpServers, profiles, type McpCachedTool, type McpFeatures, type McpOAuthState } from '../db/schema'
 import { badRequest, notFound } from '../lib/errors'
 import { newId, nowIso } from '../lib/ids'
 import { isWithin, realpathLoose } from '../lib/paths'
@@ -27,6 +27,9 @@ const inputSchema = z.object({
   enabled: z.boolean().default(true),
   url: z.string().max(2000).default(''),
   headers: z.array(secretSchema).max(50).default([]),
+  oauth: z.object({
+    enabled: z.boolean(), clientId: z.string().trim().max(500).default(''), clientSecret: z.string().max(2000).nullable().default(null), scope: z.string().trim().max(1000).default(''),
+  }).optional(),
   command: z.string().max(500).default(''),
   args: z.array(z.string().max(2000)).max(100).default([]),
   env: z.array(secretSchema).max(100).default([]),
@@ -35,11 +38,48 @@ const inputSchema = z.object({
   writePaths: z.array(z.string()).max(50).default([]),
 })
 
-/** 没有单独设置时:服务声明为只读的工具直接调用,其余跟随权限模式 */
-export const defaultPolicy = (t: Pick<McpCachedTool, 'readOnly'>): McpToolPolicy => (t.readOnly ? 'auto' : 'ask')
+export interface EffectiveHints {
+  readOnly: boolean
+  /** 只认明确声明(或管理员设置)的 true:规范的默认值 true 不能用来强制审批 */
+  destructive: boolean
+  idempotent: boolean
+  openWorld: boolean
+}
+
+/** 生效的工具提示:管理员的覆盖优先,其次是服务的声明(docs/design/16-mcp-support.md §2.1) */
+export function effectiveHints(t: McpCachedTool, o: McpToolHints = {}): EffectiveHints {
+  const readOnly = o.readOnly ?? t.readOnly
+  return {
+    readOnly,
+    destructive: !readOnly && (o.destructive ?? t.annotations?.destructiveHint === true),
+    idempotent: readOnly || (o.idempotent ?? t.annotations?.idempotentHint === true),
+    openWorld: t.annotations?.openWorldHint !== false,
+  }
+}
+
+/** 没有单独设置时:只读工具直接调用;明确声明破坏性的总是审批;其余跟随权限模式 */
+export const defaultPolicy = (h: Pick<EffectiveHints, 'readOnly' | 'destructive'>): McpToolPolicy => (h.readOnly ? 'auto' : h.destructive ? 'confirm' : 'ask')
+
+/** 工具的生效提示与策略 */
+export function toolState(r: Pick<McpServerRow, 'toolHints' | 'toolPolicies'>, t: McpCachedTool): { hints: EffectiveHints; policy: McpToolPolicy } {
+  const hints = effectiveHints(t, r.toolHints[t.name])
+  return { hints, policy: r.toolPolicies[t.name] ?? defaultPolicy(hints) }
+}
 
 export class McpServerService {
-  constructor(private db: DB, private upstream: McpUpstream, private settings: () => AppSettings, private audit: (summary: string) => void) {}
+  private refreshing = new Map<string, NodeJS.Timeout>()
+
+  constructor(private db: DB, private upstream: McpUpstream, private settings: () => AppSettings, private audit: (summary: string) => void) {
+    // 服务通知工具列表变化,或新建了连接:重新列出工具(合并 2 秒内的多次通知,docs/design/16-mcp-support.md §2.3)
+    upstream.onToolsChanged = id => {
+      clearTimeout(this.refreshing.get(id))
+      this.refreshing.set(id, setTimeout(() => {
+        this.refreshing.delete(id)
+        const r = this.db.select().from(mcpServers).where(eq(mcpServers.id, id)).get()
+        if (r?.enabled) void this.listToolsFor(r).catch(() => {})
+      }, 2000))
+    }
+  }
 
   state(): McpStateDto {
     return { builtin: BUILTIN_TOOL_INFO, servers: this.list(), sandboxAvailable: !!BWRAP }
@@ -96,7 +136,7 @@ export class McpServerService {
     const r = this.row(id)
     await this.upstream.close(id)
     try {
-      await this.updateTools(r, await this.upstream.listTools(r))
+      await this.listToolsFor(r)
     } catch {
       // 错误记录在连接状态中,随 DTO 返回
     }
@@ -105,7 +145,8 @@ export class McpServerService {
 
   async listToolsFor(r: McpServerRow): Promise<McpCachedTool[]> {
     const tools = await this.upstream.listTools(r)
-    this.updateTools(r, tools)
+    const features = await this.upstream.features(r).catch(() => r.features)
+    this.updateTools(r, tools, features)
     return tools
   }
 
@@ -121,7 +162,22 @@ export class McpServerService {
   setToolPolicy(id: string, tool: string, policy: McpToolPolicy): McpServerDto {
     const r = this.row(id)
     this.db.update(mcpServers).set({ toolPolicies: { ...r.toolPolicies, [tool]: policy }, updatedAt: nowIso() }).where(eq(mcpServers.id, id)).run()
-    this.audit(`MCP 工具 ${r.name}__${tool} 设为 ${({ auto: '直接调用', ask: '跟随权限模式', off: '关闭' } as const)[policy]}`)
+    this.audit(`MCP 工具 ${r.name}__${tool} 设为 ${({ auto: '直接调用', ask: '跟随权限模式', confirm: '总是审批', off: '关闭' } as const)[policy]}`)
+    return this.get(id)
+  }
+
+  /** 覆盖工具提示;某一项为 undefined 表示沿用服务的声明 */
+  setToolHints(id: string, tool: string, hints: McpToolHints): McpServerDto {
+    const r = this.row(id)
+    const clean: McpToolHints = {}
+    for (const k of ['readOnly', 'destructive', 'idempotent'] as const) if (typeof hints[k] === 'boolean') clean[k] = hints[k]
+    const next = { ...r.toolHints }
+    if (Object.keys(clean).length) next[tool] = clean
+    else delete next[tool]
+    this.db.update(mcpServers).set({ toolHints: next, updatedAt: nowIso() }).where(eq(mcpServers.id, id)).run()
+    const label = { readOnly: '只读', destructive: '破坏性', idempotent: '幂等' } as const
+    const desc = Object.entries(clean).map(([k, v]) => `${label[k as keyof McpToolHints]}=${v ? '是' : '否'}`).join('、') || '沿用服务的声明'
+    this.audit(`MCP 工具 ${r.name}__${tool} 的提示:${desc}`)
     return this.get(id)
   }
 
@@ -133,7 +189,7 @@ export class McpServerService {
     const tempId = id ?? `test_${Date.now()}`
     try {
       const { tools, stderr } = await this.upstream.test({ ...(prev ?? emptyRow()), ...data, id: tempId })
-      return { ok: true, tools: tools.map(t => toToolDto(t, prev?.toolPolicies ?? {}, prev?.backgroundTools ?? [])), error: null, stderrTail: stderr.length ? stderr.join('\n') : null, ms: Date.now() - t0 }
+      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 }
     } finally {
@@ -142,8 +198,14 @@ export class McpServerService {
     }
   }
 
-  private updateTools(r: McpServerRow, tools: McpCachedTool[]) {
-    this.db.update(mcpServers).set({ toolsCache: tools, toolsUpdatedAt: nowIso() }).where(eq(mcpServers.id, r.id)).run()
+  /**
+   * 工具列表或能力有变化时才更新(toolsUpdatedAt 是 Codex 会话的工具版本之一,变化会触发分叉,ADR-0020);
+   * 第一次列出时也记录时间
+   */
+  private updateTools(r: McpServerRow, tools: McpCachedTool[], features: McpFeatures) {
+    const cur = this.db.select({ tools: mcpServers.toolsCache, features: mcpServers.features, at: mcpServers.toolsUpdatedAt }).from(mcpServers).where(eq(mcpServers.id, r.id)).get()
+    if (cur?.at && JSON.stringify(cur.tools) === JSON.stringify(tools) && JSON.stringify(cur.features) === JSON.stringify(features)) return
+    this.db.update(mcpServers).set({ toolsCache: tools, features, toolsUpdatedAt: nowIso() }).where(eq(mcpServers.id, r.id)).run()
   }
 
   private row(id: string): McpServerRow {
@@ -164,9 +226,15 @@ export class McpServerService {
     const masked = (rec: Record<string, string>): McpSecretEntry[] => Object.keys(rec).map(name => ({ name, value: null, isSet: rec[name] !== '' }))
     return {
       id: r.id, name: r.name, title: r.title, description: r.description, transport: r.transport, enabled: r.enabled,
-      url: r.url, headers: masked(r.headers), command: r.command, args: r.args, env: masked(r.env),
+      url: r.url, headers: masked(r.headers),
+      oauth: r.oauth ? { enabled: r.oauth.enabled, clientId: r.oauth.clientId, clientSecret: null, scope: r.oauth.scope } : { enabled: false, clientId: '', clientSecret: null, scope: '' },
+      oauthStatus: r.oauth?.enabled ? {
+        authorized: !!r.oauth.tokens, authorizedAt: r.oauth.authorizedAt ?? null, clientSecretSet: !!r.oauth.clientSecret,
+        registered: !!(r.oauth.clientId || r.oauth.clientInfo), lastError: r.oauth.lastError ?? null,
+      } : null,
+      command: r.command, args: r.args, env: masked(r.env),
       allowNetwork: r.allowNetwork, readPaths: r.readPaths, writePaths: r.writePaths,
-      tools: r.toolsCache.map(t => toToolDto(t, r.toolPolicies, r.backgroundTools)), toolsUpdatedAt: r.toolsUpdatedAt,
+      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,
     }
   }
@@ -196,6 +264,7 @@ export class McpServerService {
       name: d.name, title: d.title, description: d.description, transport: d.transport, enabled: d.enabled,
       url: d.transport === 'http' ? d.url.trim() : '',
       headers: d.transport === 'http' ? mergeSecrets(d.headers, prev?.headers ?? {}) : {},
+      oauth: d.transport === 'http' ? mergeOAuth(d.oauth, prev?.oauth ?? null) : null,
       command: d.transport === 'stdio' ? d.command.trim() : '',
       args: d.transport === 'stdio' ? d.args : [],
       env: d.transport === 'stdio' ? mergeSecrets(d.env, prev?.env ?? {}) : {},
@@ -224,6 +293,14 @@ export class McpServerService {
   }
 }
 
+/** OAuth 配置:密钥为 null 时沿用原值;Client ID 变化时清除注册信息与令牌 */
+function mergeOAuth(input: { enabled: boolean; clientId: string; clientSecret: string | null; scope: string } | undefined, prev: McpOAuthState | null): McpOAuthState | null {
+  if (!input) return prev
+  const clientSecret = input.clientSecret ?? (prev && prev.clientId === input.clientId ? prev.clientSecret : '')
+  const same = prev && prev.clientId === input.clientId && prev.scope === input.scope
+  return { ...(same ? prev : {}), enabled: input.enabled, clientId: input.clientId, clientSecret, scope: input.scope }
+}
+
 /** 密钥:value 为 null 表示保留原值 */
 function mergeSecrets(entries: { name: string; value: string | null }[], prev: Record<string, string>): Record<string, string> {
   const out: Record<string, string> = {}
@@ -235,14 +312,21 @@ function mergeSecrets(entries: { name: string; value: string | null }[], prev: R
   return out
 }
 
-function toToolDto(t: McpCachedTool, policies: Record<string, McpToolPolicy>, background: string[] = []): McpToolDto {
-  return { name: t.name, description: t.description, readOnly: t.readOnly, policy: policies[t.name] ?? defaultPolicy(t), background: background.includes(t.name) }
+function toToolDto(t: McpCachedTool, r: Pick<McpServerRow, 'toolPolicies' | 'toolHints' | 'backgroundTools'>): McpToolDto {
+  const { hints, policy } = toolState(r, t)
+  const a = t.annotations ?? {}
+  return {
+    name: t.name, title: t.title ?? null, description: t.description, ...hints,
+    declared: { readOnly: a.readOnlyHint ?? (t.readOnly || null), destructive: a.destructiveHint ?? null, idempotent: a.idempotentHint ?? null, openWorld: a.openWorldHint ?? null },
+    overrides: r.toolHints[t.name] ?? {}, policy, background: r.backgroundTools.includes(t.name),
+    hasOutputSchema: !!t.outputSchema, taskSupport: t.taskSupport ?? null,
+  }
 }
 
 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: {}, backgroundTools: [], toolsCache: [], toolsUpdatedAt: null, createdAt: now, updatedAt: now,
+    allowNetwork: false, readPaths: [], writePaths: [], toolPolicies: {}, toolHints: {}, backgroundTools: [], oauth: null, features: {}, toolsCache: [], toolsUpdatedAt: null, createdAt: now, updatedAt: now,
   }
 }

+ 2 - 2
apps/server/src/services/runs.ts

@@ -16,7 +16,7 @@ import { resolveAccess } from './access-policy'
 import { parse, type AuditService, type KnowledgeService, type ProfileService } from './catalog'
 import type { EventStore } from './event-store'
 import type { FileService } from './files'
-import type { InteractionService, NewApproval, NewQuestion } from './interactions'
+import type { InteractionService, NewApproval, NewForm, NewQuestion } from './interactions'
 import type { CommunitySkillService } from './community-skills'
 import type { McpServerService } from './mcp-servers'
 import type { CustomSkillService } from '../custom-skills/service'
@@ -197,7 +197,7 @@ export class RunManager {
       emit('run.started', { provider: session.provider, model: session.model })
       const sessionRow = session
       // 审批与提问:引擎与 MCP Gateway 共用,等待回复期间 Run 处于 waiting_input
-      const interact = async (req: NewApproval | NewQuestion) => {
+      const interact = async (req: NewApproval | NewQuestion | NewForm) => {
         a.status = 'waiting_input'
         deps.db.update(runs).set({ status: 'waiting_input' }).where(eq(runs.id, runId)).run()
         try {

+ 1 - 5
apps/server/src/teams/routes.ts

@@ -10,16 +10,12 @@ import { BROWSER_TOKEN_MAX_TTL } from '../services/browser-tokens'
 import { parse } from '../services/catalog'
 import { principal, requireBotClient } from '../http/auth'
 import { streamEvents } from '../http/sse'
+import { interactionResponseSchema } from '../services/interactions'
 
 type IdParams = { Params: { id: string } }
 type BotParams = { Params: { id: string; botId: string } }
 type JobParams = { Params: { id: string; jobId: string } }
 
-const interactionResponseSchema: z.ZodType<InteractionResponse> = z.union([
-  z.object({ decision: z.enum(['allow_once', 'allow_session']) }),
-  z.object({ decision: z.literal('deny'), reason: z.string().max(2000).optional() }),
-  z.object({ answers: z.record(z.string(), z.union([z.string(), z.array(z.string())])) }),
-])
 
 /** 已注册的 /bot/v1 路由(方法 + 路径),用于核对 API 协议文档是否完整(test/api-reference.test.ts) */
 export const registeredBotRoutes: { method: string; path: string }[] = []

+ 3 - 3
apps/server/test/codex.test.ts

@@ -7,7 +7,7 @@ import { config } from '../src/config'
 import { CodexProvider } from '../src/providers/codex/adapter'
 import { NO_CODEX_PLUGINS, type CodexPluginGrant, type RunContext } from '../src/providers/types'
 import { resolveAccess } from '../src/services/access-policy'
-import type { InteractionOutcome, NewApproval, NewQuestion } from '../src/services/interactions'
+import type { InteractionOutcome, NewApproval, NewForm, NewQuestion } from '../src/services/interactions'
 import { defaultSettings } from '../src/services/settings'
 
 const fake = path.resolve(__dirname, 'fixtures-fake-codex.mjs')
@@ -15,11 +15,11 @@ const settings = defaultSettings()
 settings.providers.codex = { ...settings.providers.codex, enabled: true, cliPath: fake }
 const silent = { info: () => {}, warn: () => {}, error: () => {} }
 
-function makeCtx(text: string, opts: { providerSessionId?: string; mode?: 'ask' | 'auto' | 'readonly'; answer?: (r: NewApproval | NewQuestion) => InteractionOutcome; signal?: AbortSignal; codexPlugins?: CodexPluginGrant } = {}) {
+function makeCtx(text: string, opts: { providerSessionId?: string; mode?: 'ask' | 'auto' | 'readonly'; answer?: (r: NewApproval | NewQuestion | NewForm) => InteractionOutcome; signal?: AbortSignal; codexPlugins?: CodexPluginGrant } = {}) {
   const ws = path.join(config.dataDir, 'workspaces', `ses_codex_${Math.random().toString(36).slice(2)}`)
   fs.mkdirSync(ws, { recursive: true })
   const events: { type: AgentEventType; payload: unknown }[] = []
-  const interactions: (NewApproval | NewQuestion)[] = []
+  const interactions: (NewApproval | NewQuestion | NewForm)[] = []
   const audits: string[] = []
   let sessionId: string | null = null
   const ctx: RunContext = {

+ 202 - 0
apps/server/test/mcp-elicitation.test.ts

@@ -0,0 +1,202 @@
+// MCP 支持完善 · 阶段 C:外部服务向用户询问信息(Elicitation,docs/design/16-mcp-support.md §4)
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import http from 'node:http'
+import type { AddressInfo } from 'node:net'
+import path from 'node:path'
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { z } from 'zod'
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import { InMemoryTaskMessageQueue, InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js'
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
+import type { ElicitResult } from '@modelcontextprotocol/sdk/types.js'
+import type { InteractionFormRequest } from '@agent-server/shared'
+import { createApp, type App } from '../src/app'
+import { config } from '../src/config'
+import { schemaToFields } from '../src/mcp-gateway/elicitation'
+import { buildServer } from '../src/http/server'
+import { taskTiming } from '../src/mcp-gateway/tasks'
+import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
+import { resolveAccess } from '../src/services/access-policy'
+import { validateFormContent, type InteractionOutcome, type NewForm } from '../src/services/interactions'
+
+let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+let base: string
+let upstream: http.Server
+let serverId: string
+
+const SCHEMA = {
+  type: 'object' as const,
+  properties: {
+    name: { type: 'string' as const, title: '收件人', minLength: 1 },
+    email: { type: 'string' as const, title: '邮箱', format: 'email' as const },
+    count: { type: 'integer' as const, title: '数量', minimum: 1, maximum: 10, default: 1 },
+    urgent: { type: 'boolean' as const, title: '加急' },
+    color: { type: 'string' as const, title: '颜色', oneOf: [{ const: 'red', title: '红' }, { const: 'blue', title: '蓝' }] },
+    tags: { type: 'array' as const, title: '标签', items: { type: 'string' as const, enum: ['a', 'b', 'c'] }, maxItems: 2 },
+  },
+  required: ['name', 'color'],
+}
+
+/** 有状态的测试服务:调用过程中询问用户;长任务中询问用户(任务按 MCP 会话归属) */
+const taskStore = new InMemoryTaskStore()
+function startUpstream(): Promise<string> {
+  const transports = new Map<string, StreamableHTTPServerTransport>()
+  const build = () => {
+    const mcp = new McpServer({ name: 'ask', version: '1.0.0' }, { capabilities: { tasks: { requests: { tools: { call: {} } } } }, taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() })
+    mcp.registerTool('order', { description: '下单(会询问收件信息)', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => {
+      const r = await mcp.server.elicitInput({ mode: 'form', message: '请填写收件信息', requestedSchema: SCHEMA })
+      return { content: [{ type: 'text', text: `${r.action}:${JSON.stringify(r.content ?? {})}` }] }
+    })
+    mcp.registerTool('login', { description: '需要授权', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => {
+      const r = await mcp.server.elicitInput({ mode: 'url', message: '请先授权访问日历', url: 'https://auth.example.com/authorize?x=1', elicitationId: 'el1' })
+      return { content: [{ type: 'text', text: r.action }] }
+    })
+    mcp.experimental.tasks.registerToolTask('approve_job', { description: '长任务中途需要确认', inputSchema: { key: z.string() }, execution: { taskSupport: 'required' } }, {
+      async createTask({ key }, { taskStore: store, taskRequestedTtl }) {
+        const task = await store.createTask({ ttl: taskRequestedTtl, pollInterval: 50 })
+        void (async () => {
+          await store.updateTaskStatus(task.taskId, 'input_required')
+          let result: ElicitResult | undefined
+          for await (const m of mcp.server.experimental.tasks.elicitInputStream({ mode: 'form', message: `继续执行 ${key} 吗?`, requestedSchema: { type: 'object', properties: { ok: { type: 'boolean', title: '继续' } }, required: ['ok'] } }, { relatedTask: { taskId: task.taskId } })) {
+            if (m.type === 'result') result = m.result as ElicitResult
+          }
+          await store.storeTaskResult(task.taskId, 'completed', { content: [{ type: 'text', text: `任务结束:${result?.action}:${JSON.stringify(result?.content ?? {})}` }] })
+        })()
+        return { task }
+      },
+      getTask: async (_a, { taskId, taskStore: store }) => (await store.getTask(taskId))!,
+      getTaskResult: async (_a, { taskId, taskStore: store }) => await store.getTaskResult(taskId) as never,
+    })
+    return mcp
+  }
+  upstream = http.createServer(async (req, res) => {
+    let body = ''
+    req.on('data', c => (body += c))
+    await new Promise(r => req.on('end', r))
+    const sid = req.headers['mcp-session-id'] as string | undefined
+    let t = sid ? transports.get(sid) : undefined
+    if (!t) {
+      t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: id => void transports.set(id, t!) })
+      await build().connect(t)
+    }
+    await t.handleRequest(req, res, body ? JSON.parse(body) : undefined)
+  })
+  return new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}/mcp`)))
+}
+
+const forms: NewForm[] = []
+let reply: (f: NewForm) => InteractionOutcome = () => ({ resolution: 'cancelled' })
+function runCtx(): GatewayRunContext {
+  const ws = path.join(config.dataDir, 'workspaces', `ses_ask_${Math.random().toString(36).slice(2)}`)
+  fs.mkdirSync(ws, { recursive: true })
+  const settings = app.settings.get()
+  return {
+    sessionId: `ses_${Math.random().toString(36).slice(2)}`, runId: 'r', provider: 'claude', workspaceDir: ws, permissionMode: 'auto', settings,
+    access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
+    knowledgeRoots: [], mcpServerIds: [serverId], audit: () => {},
+    interact: async req => {
+      if (req.kind !== 'form') return { resolution: 'allowed', scope: 'once' }
+      forms.push(req)
+      return reply(req)
+    },
+  }
+}
+async function gateway(ctx: GatewayRunContext) {
+  const client = new Client({ name: 'test', version: '1' })
+  await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${app.gatewayTokens.issue(ctx)}` } } }))
+  return client
+}
+const text = (r: unknown) => (r as { content: { text: string }[] }).content[0]!.text
+
+Object.assign(taskTiming, { minPollMs: 50, defaultPollMs: 100, foregroundPollMs: 50, tickMs: 50 })
+
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: {} })
+  server = await buildServer(app, { logger: log })
+  base = await server.listen({ port: 0, host: '127.0.0.1' })
+  const url = await startUpstream()
+  serverId = app.mcpServers.create({ name: 'ask', title: '下单服务', description: '', transport: 'http', enabled: true, url, headers: [], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [] }).id
+  await app.mcpServers.refresh(serverId)
+  app.mcpServers.setToolPolicy(serverId, 'approve_job', 'auto')
+})
+afterAll(async () => {
+  app.mcpTasks.stop()
+  app.backgroundCalls.stop()
+  await app.mcpUpstream.closeAll()
+  await server.close()
+  upstream.closeAllConnections()
+  upstream.close()
+})
+
+describe('表单字段', () => {
+  it('requestedSchema 转换为表单字段;提交时按字段校验', () => {
+    const fields = schemaToFields(SCHEMA)
+    expect(fields).toEqual([
+      { name: 'name', title: '收件人', required: true, type: 'string', minLength: 1 },
+      { name: 'email', title: '邮箱', required: false, type: 'string', format: 'email' },
+      { name: 'count', title: '数量', required: false, type: 'integer', minimum: 1, maximum: 10, default: 1 },
+      { name: 'urgent', title: '加急', required: false, type: 'boolean' },
+      { name: 'color', title: '颜色', required: true, type: 'choice', options: [{ value: 'red', label: '红' }, { value: 'blue', label: '蓝' }], multiple: false },
+      { name: 'tags', title: '标签', required: false, type: 'choice', options: ['a', 'b', 'c'].map(v => ({ value: v, label: v })), multiple: true, maxItems: 2 },
+    ])
+    expect(schemaToFields({ type: 'object', properties: { x: { type: 'object' } } })).toMatch(/不支持/)
+    if (typeof fields === 'string') throw new Error(fields)
+    expect(validateFormContent(fields, { name: '张三', color: 'red', count: '3', extra: 'x' })).toEqual({ name: '张三', color: 'red', count: 3 })
+    expect(() => validateFormContent(fields, { color: 'red' })).toThrow(/请填写:收件人/)
+    expect(() => validateFormContent(fields, { name: 'a', color: 'green' })).toThrow(/颜色 的选项不正确/)
+    expect(() => validateFormContent(fields, { name: 'a', color: 'red', count: 11 })).toThrow(/不能大于 10/)
+    expect(() => validateFormContent(fields, { name: 'a', color: 'red', email: 'nope' })).toThrow(/邮箱地址/)
+    expect(() => validateFormContent(fields, { name: 'a', color: 'red', tags: ['a', 'b', 'c'] })).toThrow(/最多选 2 项/)
+  })
+})
+
+describe('调用过程中询问', () => {
+  it('表单询问显示在这一轮,提交的内容回给服务;拒绝、关闭原样回给服务', async () => {
+    const c = await gateway(runCtx())
+    reply = () => ({ resolution: 'submitted', content: { name: '张三', color: 'blue' } })
+    expect(text(await c.callTool({ name: 'ask__order', arguments: {} }))).toBe('accept:{"name":"张三","color":"blue"}')
+    expect(forms[0]).toMatchObject({ kind: 'form', mode: 'form', source: '下单服务', message: '请填写收件信息' })
+    expect(forms[0]!.fields).toHaveLength(6)
+
+    reply = () => ({ resolution: 'denied' })
+    expect(text(await c.callTool({ name: 'ask__order', arguments: {} }))).toBe('decline:{}')
+    reply = () => ({ resolution: 'timeout' })
+    expect(text(await c.callTool({ name: 'ask__order', arguments: {} }))).toBe('cancel:{}')
+    await c.close()
+  })
+
+  it('网址询问:显示网址,用户完成后回复 accept', async () => {
+    const c = await gateway(runCtx())
+    reply = () => ({ resolution: 'submitted', content: {} })
+    expect(text(await c.callTool({ name: 'ask__login', arguments: {} }))).toBe('accept')
+    expect(forms.at(-1)).toMatchObject({ mode: 'url', url: 'https://auth.example.com/authorize?x=1', message: '请先授权访问日历' })
+    await c.close()
+  })
+
+  it('无法确定属于哪个会话的询问一律拒绝', async () => {
+    const row = app.mcpServers.rows([serverId])[0]!
+    const r = await app.elicitation.handle({ row, params: { mode: 'form', message: 'x', requestedSchema: { type: 'object', properties: {} } }, taskId: null, scope: null, owners: [{ sessionId: 'a' }, { sessionId: 'b' }] })
+    expect(r).toEqual({ action: 'decline' })
+  })
+})
+
+describe('长任务中询问', () => {
+  it('任务进入 input_required 时发出询问卡片(不属于某一轮),回答后任务继续并完成', async () => {
+    const session = await app.sessions.create('admin', {})
+    const ctx = { ...runCtx(), sessionId: session.id }
+    const c = await gateway(ctx)
+    const call = c.callTool({ name: 'ask__approve_job', arguments: { key: 'J1' } }, undefined, { timeout: 30_000 })
+    const pending = await expect.poll(() => app.interactions.pendingFor(ctx.sessionId)[0], { timeout: 8000 }).toBeTruthy().then(() => app.interactions.pendingFor(ctx.sessionId)[0] as InteractionFormRequest)
+    expect(pending).toMatchObject({ kind: 'form', message: '继续执行 J1 吗?', fields: [{ name: 'ok', type: 'boolean', required: true }] })
+    expect(() => app.interactions.respond(pending.interactionId, { action: 'accept', content: {} })).toThrow(/请填写/)
+    app.interactions.respond(pending.interactionId, { action: 'accept', content: { ok: true } })
+    expect(text(await call)).toBe('任务结束:accept:{"ok":true}')
+    await c.close()
+  }, 20_000)
+})

+ 198 - 0
apps/server/test/mcp-hints.test.ts

@@ -0,0 +1,198 @@
+// MCP 支持完善 · 阶段 A:工具提示、结构化输出、工具列表变化(docs/design/16-mcp-support.md §2)
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import http from 'node:http'
+import type { AddressInfo } from 'node:net'
+import path from 'node:path'
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
+import { z } from 'zod'
+import type { McpServerInput } from '@agent-server/shared'
+import { createApp, type App } from '../src/app'
+import { config } from '../src/config'
+import { buildServer } from '../src/http/server'
+import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
+import { resolveAccess } from '../src/services/access-policy'
+import type { InteractionOutcome, NewApproval } from '../src/services/interactions'
+
+let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+let base: string
+let upstream: http.Server
+let upstreamUrl: string
+let flakyCalls = 0
+const servers = new Set<McpServer>()
+
+/** 有状态的测试服务(会话 id),可以发出工具列表变化通知 */
+function startUpstream(): Promise<string> {
+  const transports = new Map<string, StreamableHTTPServerTransport>()
+  const build = () => {
+    const mcp = new McpServer({ name: 'fixture-hints', version: '1.0.0' })
+    mcp.registerTool('delete_all', { title: '删除全部', description: '删除全部数据', inputSchema: {}, annotations: { destructiveHint: true } }, async () => ({ content: [{ type: 'text', text: 'deleted' }] }))
+    mcp.registerTool('set_value', { description: '设置值', inputSchema: { v: z.string() }, annotations: { idempotentHint: true, destructiveHint: false, openWorldHint: false } }, async ({ v }) => ({ content: [{ type: 'text', text: `set:${v}` }] }))
+    mcp.registerTool('send_once', { description: '发送(不幂等)', inputSchema: {} }, async () => ({ content: [{ type: 'text', text: 'sent' }] }))
+    mcp.registerTool('stats', {
+      description: '统计', inputSchema: {}, annotations: { readOnlyHint: true },
+      outputSchema: { total: z.number(), items: z.array(z.string()) },
+    }, async () => {
+      const structuredContent = { total: 2, items: ['a', 'b'] }
+      return { content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent }
+    })
+    mcp.registerTool('stats_summary', { description: '统计(文本与结构化不同)', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => ({
+      content: [{ type: 'text', text: '共 2 条' }], structuredContent: { total: 2 },
+    }))
+    // 服务端自己的校验接受任何值,对外声明的 JSON Schema 却是 number:模拟返回结构与声明不符的服务
+    mcp.registerTool('stats_bad', { description: '结构不符', inputSchema: {}, annotations: { readOnlyHint: true }, outputSchema: { total: z.any().meta({ type: 'number' }) } }, async () => ({
+      content: [{ type: 'text', text: '坏结果' }], structuredContent: { total: 'two' },
+    }))
+    servers.add(mcp)
+    return mcp
+  }
+  upstream = http.createServer(async (req, res) => {
+    let body = ''
+    req.on('data', c => (body += c))
+    await new Promise(r => req.on('end', r))
+    const parsed = body ? JSON.parse(body) : undefined
+    // 偶发的网络错误:调用 set_value / send_once 的第一次直接断开连接
+    if (parsed?.method === 'tools/call' && ['set_value', 'send_once'].includes(parsed.params.name) && flakyCalls++ % 2 === 0) return req.socket.destroy()
+    const sid = req.headers['mcp-session-id'] as string | undefined
+    let t = sid ? transports.get(sid) : undefined
+    if (!t) {
+      t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: id => void transports.set(id, t!) })
+      await build().connect(t)
+    }
+    await t.handleRequest(req, res, parsed)
+  })
+  return new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}/mcp`)))
+}
+
+const input = (): McpServerInput => ({
+  name: 'hints', title: '提示服务', description: '', transport: 'http', enabled: true, url: upstreamUrl,
+  headers: [], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [],
+})
+
+const approvals: NewApproval[] = []
+const audits: { summary: string; detail: Record<string, unknown> }[] = []
+const answer: InteractionOutcome = { resolution: 'allowed', scope: 'once' }
+function runCtx(over: Partial<GatewayRunContext> = {}): GatewayRunContext {
+  const ws = path.join(config.dataDir, 'workspaces', `ses_hints_${Math.random().toString(36).slice(2)}`)
+  fs.mkdirSync(ws, { recursive: true })
+  const settings = app.settings.get()
+  return {
+    sessionId: `ses_${Math.random().toString(36).slice(2)}`, runId: 'r', provider: 'claude', workspaceDir: ws, permissionMode: 'auto', settings,
+    access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
+    knowledgeRoots: [], mcpServerIds: [], audit: (_k, summary, detail) => void audits.push({ summary, detail: detail as Record<string, unknown> }),
+    interact: async req => { approvals.push(req as NewApproval); return answer },
+    ...over,
+  }
+}
+async function gateway(ctx: GatewayRunContext) {
+  const client = new Client({ name: 'test', version: '1' })
+  await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${app.gatewayTokens.issue(ctx)}` } } }))
+  return client
+}
+const texts = (r: unknown) => (r as { content: { text: string }[] }).content.map(c => c.text)
+
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: {} })
+  server = await buildServer(app, { logger: log })
+  base = await server.listen({ port: 0, host: '127.0.0.1' })
+  upstreamUrl = await startUpstream()
+})
+afterAll(async () => {
+  await app.mcpUpstream.closeAll()
+  await server.close()
+  upstream.closeAllConnections()
+  upstream.close()
+})
+
+describe('工具提示', () => {
+  it('明确声明破坏性的工具默认总是审批(自动模式也问、每次都问);管理员可以覆盖提示', async () => {
+    const s = app.mcpServers.create(input())
+    const tools = (await app.mcpServers.refresh(s.id)).tools
+    const del = tools.find(t => t.name === 'delete_all')!
+    expect(del).toMatchObject({ title: '删除全部', destructive: true, policy: 'confirm', declared: { destructive: true, readOnly: null } })
+    // 没写提示的工具:规范默认值不用来强制审批
+    expect(tools.find(t => t.name === 'send_once')).toMatchObject({ destructive: false, idempotent: false, openWorld: true, policy: 'ask' })
+    expect(tools.find(t => t.name === 'set_value')).toMatchObject({ idempotent: true, openWorld: false, policy: 'ask' })
+    expect(tools.find(t => t.name === 'stats')).toMatchObject({ readOnly: true, policy: 'auto', hasOutputSchema: true })
+
+    const ctx = runCtx({ mcpServerIds: [s.id] })
+    const c = await gateway(ctx)
+    const listed = (await c.listTools()).tools.find(t => t.name === 'hints__delete_all')!
+    expect(listed.annotations).toMatchObject({ destructiveHint: true, readOnlyHint: false })
+    expect(listed.title).toBe('删除全部')
+    expect(texts(await c.callTool({ name: 'hints__delete_all', arguments: {} }))).toEqual(['deleted'])
+    expect(texts(await c.callTool({ name: 'hints__delete_all', arguments: {} }))).toEqual(['deleted'])
+    expect(approvals).toHaveLength(2)
+    expect(approvals[0]).toMatchObject({ options: ['allow_once', 'deny'], title: '提示服务 · 删除全部' })
+    expect(approvals[0]!.detail?.reason).toMatch(/破坏性/)
+
+    // 覆盖为非破坏性:回到跟随权限模式,自动模式下不再询问
+    const after = app.mcpServers.setToolHints(s.id, 'delete_all', { destructive: false })
+    expect(after.tools.find(t => t.name === 'delete_all')).toMatchObject({ destructive: false, policy: 'ask', overrides: { destructive: false } })
+    await c.callTool({ name: 'hints__delete_all', arguments: {} })
+    expect(approvals).toHaveLength(2)
+    // 覆盖为只读:只读模式下也提供
+    app.mcpServers.setToolHints(s.id, 'send_once', { readOnly: true })
+    const ro = await gateway(runCtx({ mcpServerIds: [s.id], permissionMode: 'readonly' }))
+    expect((await ro.listTools()).tools.map(t => t.name)).toEqual(expect.arrayContaining(['hints__send_once', 'hints__stats']))
+    await ro.close()
+    // 清除覆盖
+    expect(app.mcpServers.setToolHints(s.id, 'send_once', {}).tools.find(t => t.name === 'send_once')!.overrides).toEqual({})
+    await c.close()
+  })
+
+  it('网络错误:幂等工具重新连接后重试,不幂等的不重试', async () => {
+    const s = app.mcpServers.list().find(x => x.name === 'hints')!
+    const c = await gateway(runCtx({ mcpServerIds: [s.id] }))
+    flakyCalls = 0
+    expect(texts(await c.callTool({ name: 'hints__set_value', arguments: { v: '1' } }))).toEqual(['set:1'])
+    flakyCalls = 0
+    const r = await c.callTool({ name: 'hints__send_once', arguments: {} })
+    expect(r.isError).toBe(true)
+    expect(flakyCalls).toBe(1)
+    await c.close()
+  })
+})
+
+describe('结构化输出', () => {
+  it('文本已含同样的 JSON 时不重复;不同时附在后面;不符合 outputSchema 时照常返回并记录', async () => {
+    const s = app.mcpServers.list().find(x => x.name === 'hints')!
+    const ctx = runCtx({ mcpServerIds: [s.id] })
+    const c = await gateway(ctx)
+    const listed = (await c.listTools()).tools.find(t => t.name === 'hints__stats')!
+    expect(listed.description).toMatch(/返回结构(JSON Schema):.*"total"/)
+    expect(listed.outputSchema).toBeUndefined()
+
+    expect(texts(await c.callTool({ name: 'hints__stats', arguments: {} }))).toEqual(['{"total":2,"items":["a","b"]}'])
+    expect(texts(await c.callTool({ name: 'hints__stats_summary', arguments: {} }))).toEqual(['共 2 条', '结构化结果:\n{\n  "total": 2\n}'])
+
+    audits.length = 0
+    const bad = await c.callTool({ name: 'hints__stats_bad', arguments: {} })
+    expect(bad.isError).toBeFalsy()
+    expect(texts(bad)[0]).toBe('坏结果')
+    expect(audits.at(-1)!.detail).toMatchObject({ structured: { total: 'two' }, structuredError: expect.stringMatching(/outputSchema/) })
+    await c.close()
+  })
+})
+
+describe('工具列表变化', () => {
+  it('服务通知工具列表变化后自动刷新缓存', async () => {
+    const s = app.mcpServers.list().find(x => x.name === 'hints')!
+    const c = await gateway(runCtx({ mcpServerIds: [s.id] }))
+    await c.callTool({ name: 'hints__stats', arguments: {} }) // 建立上游连接
+    await new Promise(r => setTimeout(r, 2500))
+    for (const mcp of servers) {
+      if (mcp.isConnected()) mcp.registerTool('added_later', { description: '后来加的', inputSchema: {} }, async () => ({ content: [{ type: 'text', text: 'new' }] }))
+    }
+    await expect.poll(() => app.mcpServers.get(s.id).tools.map(t => t.name), { timeout: 8000 }).toContain('added_later')
+    expect((await c.listTools()).tools.map(t => t.name)).toContain('hints__added_later')
+    await c.close()
+  }, 15_000)
+})

+ 186 - 0
apps/server/test/mcp-oauth.test.ts

@@ -0,0 +1,186 @@
+// MCP 支持完善 · 阶段 D:OAuth 授权(按服务共享,docs/design/16-mcp-support.md §5)
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import http from 'node:http'
+import type { AddressInfo } from 'node:net'
+import path from 'node:path'
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
+import { createApp, type App } from '../src/app'
+import { config } from '../src/config'
+import { buildServer } from '../src/http/server'
+import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
+import { resolveAccess } from '../src/services/access-policy'
+
+let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+let base: string
+let upstream: http.Server
+let origin: string
+
+/** 测试用的授权服务器 + 受保护的 MCP 服务(同一个端口) */
+const valid = new Set<string>()
+const codes = new Map<string, { challenge: string; redirectUri: string }>()
+const refreshTokens = new Set<string>()
+const grants: string[] = []
+function startUpstream(): Promise<void> {
+  upstream = http.createServer(async (req, res) => {
+    const url = new URL(req.url!, origin)
+    let body = ''
+    req.on('data', c => (body += c))
+    await new Promise(r => req.on('end', r))
+    const json = (status: number, v: unknown) => res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(v))
+    if (url.pathname.startsWith('/.well-known/oauth-protected-resource')) return json(200, { resource: `${origin}/mcp`, authorization_servers: [origin] })
+    if (url.pathname === '/.well-known/oauth-authorization-server') {
+      return json(200, {
+        issuer: origin, authorization_endpoint: `${origin}/authorize`, token_endpoint: `${origin}/token`, registration_endpoint: `${origin}/register`,
+        response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'],
+        token_endpoint_auth_methods_supported: ['none', 'client_secret_post'],
+      })
+    }
+    if (url.pathname === '/register') return json(201, { ...JSON.parse(body), client_id: 'dyn-client', client_id_issued_at: Math.floor(Date.now() / 1000) })
+    if (url.pathname === '/authorize') {
+      // 模拟用户登录并同意:直接带授权码跳回
+      const code = crypto.randomBytes(8).toString('hex')
+      codes.set(code, { challenge: url.searchParams.get('code_challenge')!, redirectUri: url.searchParams.get('redirect_uri')! })
+      const back = new URL(url.searchParams.get('redirect_uri')!)
+      back.searchParams.set('code', code)
+      back.searchParams.set('state', url.searchParams.get('state')!)
+      return res.writeHead(302, { location: back.toString() }).end()
+    }
+    if (url.pathname === '/token') {
+      const p = new URLSearchParams(body)
+      grants.push(p.get('grant_type')!)
+      if (p.get('grant_type') === 'authorization_code') {
+        const c = codes.get(p.get('code')!)
+        const challenge = crypto.createHash('sha256').update(p.get('code_verifier') ?? '').digest('base64url')
+        if (!c || c.challenge !== challenge || c.redirectUri !== p.get('redirect_uri')) return json(400, { error: 'invalid_grant' })
+        codes.delete(p.get('code')!)
+      } else if (p.get('grant_type') === 'refresh_token') {
+        if (!refreshTokens.has(p.get('refresh_token')!)) return json(400, { error: 'invalid_grant' })
+      } else return json(400, { error: 'unsupported_grant_type' })
+      const access = crypto.randomBytes(8).toString('hex')
+      const refresh = crypto.randomBytes(8).toString('hex')
+      valid.add(access)
+      refreshTokens.add(refresh)
+      return json(200, { access_token: access, token_type: 'Bearer', expires_in: 3600, refresh_token: refresh })
+    }
+    if (url.pathname === '/mcp') {
+      const token = req.headers.authorization?.replace(/^Bearer /, '')
+      if (!token || !valid.has(token)) {
+        return res.writeHead(401, { 'www-authenticate': `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"` }).end()
+      }
+      const mcp = new McpServer({ name: 'secure', version: '1.0.0' })
+      mcp.registerTool('whoami', { description: '当前账号', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => ({ content: [{ type: 'text', text: 'account:alice' }] }))
+      const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
+      res.on('close', () => { void t.close(); void mcp.close() })
+      await mcp.connect(t)
+      return t.handleRequest(req, res, body ? JSON.parse(body) : undefined)
+    }
+    res.writeHead(404).end()
+  })
+  return new Promise(r => upstream.listen(0, '127.0.0.1', () => {
+    origin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`
+    r()
+  }))
+}
+
+function runCtx(serverId: string): GatewayRunContext {
+  const ws = path.join(config.dataDir, 'workspaces', `ses_oauth_${Math.random().toString(36).slice(2)}`)
+  fs.mkdirSync(ws, { recursive: true })
+  const settings = app.settings.get()
+  return {
+    sessionId: `ses_${Math.random().toString(36).slice(2)}`, runId: 'r', provider: 'claude', workspaceDir: ws, permissionMode: 'auto', settings,
+    access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
+    knowledgeRoots: [], mcpServerIds: [serverId], audit: () => {}, interact: async () => ({ resolution: 'allowed', scope: 'once' }),
+  }
+}
+async function callWhoami(serverId: string) {
+  const client = new Client({ name: 'test', version: '1' })
+  await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${app.gatewayTokens.issue(runCtx(serverId))}` } } }))
+  try {
+    return await client.callTool({ name: 'secure__whoami', arguments: {} }) as { content: { text: string }[]; isError?: boolean }
+  } finally {
+    await client.close()
+  }
+}
+
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: {} })
+  server = await buildServer(app, { logger: log })
+  base = await server.listen({ port: 0, host: '127.0.0.1' })
+  await startUpstream()
+})
+afterAll(async () => {
+  await app.mcpUpstream.closeAll()
+  await server.close()
+  upstream.closeAllConnections()
+  upstream.close()
+})
+
+describe('OAuth 授权', () => {
+  it('未授权时连接给出明确提示;发起授权(动态注册 + PKCE)→ 回调换取令牌 → 工具可用;令牌失效后自动续期;可以清除', async () => {
+    const s = app.mcpServers.create({
+      name: 'secure', title: '受保护服务', description: '', transport: 'http', enabled: true, url: `${origin}/mcp`, headers: [],
+      oauth: { enabled: true, clientId: '', clientSecret: null, scope: 'read' }, command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [],
+    })
+    expect(s.oauthStatus).toMatchObject({ authorized: false, registered: false })
+    const refreshed = await app.mcpServers.refresh(s.id)
+    expect(refreshed.lastError).toMatch(/需要 OAuth 授权/)
+
+    const { authorizationUrl } = await app.mcpOAuth.start(s.id, base)
+    const auth = new URL(authorizationUrl!)
+    expect(auth.origin).toBe(origin)
+    expect(auth.searchParams.get('client_id')).toBe('dyn-client')
+    expect(auth.searchParams.get('redirect_uri')).toBe(`${base}/admin/v1/mcp/oauth/callback`)
+    expect(auth.searchParams.get('code_challenge_method')).toBe('S256')
+    expect(auth.searchParams.get('scope')).toBe('read')
+
+    // 浏览器打开授权网址,授权服务器跳回回调地址(不带后台登录)
+    const redirect = await fetch(authorizationUrl!, { redirect: 'manual' })
+    const back = new URL(redirect.headers.get('location')!)
+    const cb = await server.inject({ method: 'GET', url: `${back.pathname}${back.search}` })
+    expect(cb.statusCode).toBe(200)
+    expect(cb.body).toContain('授权成功')
+    // state 只能用一次
+    expect((await server.inject({ method: 'GET', url: `${back.pathname}${back.search}` })).body).toContain('授权未完成')
+
+    const dto = app.mcpServers.get(s.id)
+    expect(dto.oauthStatus).toMatchObject({ authorized: true, registered: true, lastError: null })
+    expect(JSON.stringify(dto)).not.toMatch(/access_token|refresh_token|dyn-client/)
+    await expect.poll(() => app.mcpServers.get(s.id).tools.map(t => t.name)).toContain('whoami')
+    expect((await callWhoami(s.id)).content[0]!.text).toBe('account:alice')
+
+    // 访问令牌失效:SDK 用刷新令牌续期后重试
+    valid.clear()
+    await app.mcpUpstream.close(s.id)
+    expect((await callWhoami(s.id)).content[0]!.text).toBe('account:alice')
+    expect(grants).toEqual(['authorization_code', 'refresh_token'])
+
+    // 刷新令牌也失效:提示需要重新授权
+    valid.clear()
+    refreshTokens.clear()
+    await app.mcpUpstream.close(s.id)
+    const failed = await callWhoami(s.id)
+    expect(failed.isError).toBe(true)
+    expect(failed.content[0]!.text).toMatch(/需要 OAuth 授权/)
+    expect(app.mcpServers.get(s.id).oauthStatus!.lastError).toMatch(/重新授权/)
+
+    await app.mcpOAuth.revoke(s.id)
+    expect(app.mcpServers.get(s.id).oauthStatus).toMatchObject({ authorized: false })
+  })
+
+  it('回调参数无效或授权被拒绝时给出说明', async () => {
+    expect((await server.inject({ method: 'GET', url: '/admin/v1/mcp/oauth/callback?state=nope&code=x' })).body).toContain('授权请求无效或已过期')
+    const id = app.mcpServers.list().find(x => x.name === 'secure')!.id
+    const { authorizationUrl } = await app.mcpOAuth.start(id, base)
+    const state = new URL(authorizationUrl!).searchParams.get('state')!
+    const r = await server.inject({ method: 'GET', url: `/admin/v1/mcp/oauth/callback?state=${state}&error=access_denied&error_description=${encodeURIComponent('用户拒绝')}` })
+    expect(r.body).toContain('授权未完成:用户拒绝')
+  })
+})

+ 114 - 0
apps/server/test/mcp-reference.test.ts

@@ -0,0 +1,114 @@
+// 参考 MCP 服务(examples/mcp-reference-server)经网关的端到端检查:示例代码与平台的支持保持一致
+import { spawn, type ChildProcess } from 'node:child_process'
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import { createApp, type App } from '../src/app'
+import { config } from '../src/config'
+import { buildServer } from '../src/http/server'
+import { taskTiming } from '../src/mcp-gateway/tasks'
+import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
+import { resolveAccess } from '../src/services/access-policy'
+import type { NewForm } from '../src/services/interactions'
+
+const dir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../examples/mcp-reference-server')
+const PORT = 18_000 + Math.floor(Math.random() * 1000)
+let ref: ChildProcess
+let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+let base: string
+let serverId: string
+const forms: NewForm[] = []
+
+function runCtx(): GatewayRunContext {
+  const ws = path.join(config.dataDir, 'workspaces', `ses_ref_${Math.random().toString(36).slice(2)}`)
+  fs.mkdirSync(ws, { recursive: true })
+  const settings = app.settings.get()
+  return {
+    sessionId: `ses_${Math.random().toString(36).slice(2)}`, runId: 'r', provider: 'claude', workspaceDir: ws, permissionMode: 'auto', settings,
+    access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
+    knowledgeRoots: [], mcpServerIds: [serverId], audit: () => {},
+    interact: async req => {
+      if (req.kind !== 'form') return { resolution: 'allowed', scope: 'once' }
+      forms.push(req)
+      return { resolution: 'submitted', content: { name: '王五', phone: '13800000000', address: '深圳', speed: 'express', insured: true } }
+    },
+  }
+}
+const text = (r: unknown) => (r as { content: { text: string }[] }).content.map(c => c.text).join('\n')
+
+Object.assign(taskTiming, { minPollMs: 50, foregroundPollMs: 50, tickMs: 50 })
+
+beforeAll(async () => {
+  ref = spawn(process.execPath, ['server.mjs'], { cwd: dir, env: { ...process.env, PORT: String(PORT), REF_TOKEN: 'ref-test' }, stdio: ['ignore', 'pipe', 'inherit'] })
+  await new Promise<void>((resolve, reject) => {
+    ref.stdout!.on('data', () => resolve())
+    ref.on('exit', code => reject(new Error(`参考服务退出:${code}`)))
+  })
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: {} })
+  server = await buildServer(app, { logger: log })
+  base = await server.listen({ port: 0, host: '127.0.0.1' })
+  serverId = app.mcpServers.create({
+    name: 'orders', title: '订单系统', description: '', transport: 'http', enabled: true, url: `http://127.0.0.1:${PORT}/mcp`,
+    headers: [{ name: 'Authorization', value: 'Bearer ref-test' }], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [],
+  }).id
+})
+afterAll(async () => {
+  app.mcpTasks.stop()
+  app.backgroundCalls.stop()
+  await app.mcpUpstream.closeAll()
+  await server.close()
+  ref.kill()
+})
+
+describe('参考 MCP 服务', () => {
+  it('工具提示、结构化输出、询问用户、长任务、资源与提示模板都按指南工作', async () => {
+    const s = await app.mcpServers.refresh(serverId)
+    expect(s.lastError).toBeNull()
+    expect(s.features).toEqual({ resources: true, prompts: true })
+    const tool = (n: string) => s.tools.find(t => t.name === n)!
+    expect(tool('get_order')).toMatchObject({ readOnly: true, policy: 'auto', hasOutputSchema: true, openWorld: false })
+    expect(tool('cancel_order')).toMatchObject({ destructive: true, idempotent: true, policy: 'confirm' })
+    expect(tool('add_note')).toMatchObject({ destructive: false, idempotent: false, policy: 'ask' })
+    expect(tool('export_report')).toMatchObject({ taskSupport: 'optional' })
+    expect(tool('purge_cancelled')).toMatchObject({ taskSupport: 'required', destructive: true })
+
+    const session = await app.sessions.create('admin', {})
+    const ctx = { ...runCtx(), sessionId: session.id }
+    const c = new Client({ name: 'test', version: '1' })
+    await c.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${app.gatewayTokens.issue(ctx)}` } } }))
+    expect(JSON.parse(text(await c.callTool({ name: 'orders__get_order', arguments: { orderId: 'A1001' } })))).toMatchObject({ customer: '张三', amount: 1299 })
+    expect(text(await c.callTool({ name: 'orders__get_order', arguments: { orderId: 'A9999' } }))).toContain('可以先用 list_orders')
+
+    expect(text(await c.callTool({ name: 'orders__ship_order', arguments: { orderId: 'A1001' } }))).toContain('"name":"王五"')
+    expect(forms[0]!.fields.map(f => f.name)).toEqual(['name', 'phone', 'address', 'speed', 'insured'])
+
+    // 很快完成的长任务在前台返回,导出的 CSV 存入工作区
+    const exported = text(await c.callTool({ name: 'orders__export_report', arguments: { month: '2026-10', seconds: 1 } }, undefined, { timeout: 30_000 }))
+    expect(exported).toContain('2026-10 销售报表已生成')
+    expect(exported).toMatch(/已保存到 \.agent\/mcp\/sales-2026-10\.csv/)
+
+    // 长任务中询问用户:卡片属于会话(不属于某一轮)
+    await c.callTool({ name: 'orders__cancel_order', arguments: { orderId: 'A1002', reason: '测试' } })
+    const purge = c.callTool({ name: 'orders__purge_cancelled', arguments: {} }, undefined, { timeout: 30_000 })
+    await expect.poll(() => app.interactions.pendingFor(session.id).length, { timeout: 10_000 }).toBe(1)
+    const card = app.interactions.pendingFor(session.id)[0]!
+    expect(card.kind === 'form' && card.message).toContain('将永久删除 1 个已取消订单:A1002')
+    app.interactions.respond(card.interactionId, { action: 'accept', content: { confirm: true } })
+    expect(text(await purge)).toBe('已删除 1 个订单')
+
+    const tools = (await c.listTools()).tools.map(t => t.name)
+    expect(tools).toEqual(expect.arrayContaining(['mcp_resources', 'mcp_resource_read']))
+    expect(text(await c.callTool({ name: 'mcp_resource_read', arguments: { server: 'orders', uri: 'ref://docs/order-api.md' } }))).toContain('退款规则')
+    expect(text(await c.callTool({ name: 'mcp_resource_read', arguments: { server: 'orders', uri: 'ref://orders/A1001' } }))).toContain('"customer": "张三"')
+    await c.close()
+
+    const prompts = await app.mcpContent.listPrompts([serverId])
+    expect(prompts.prompts.map(p => p.name)).toEqual(['order_review'])
+  }, 60_000)
+})

+ 147 - 0
apps/server/test/mcp-resources.test.ts

@@ -0,0 +1,147 @@
+// MCP 支持完善 · 阶段 E:服务端的资源与提示模板(docs/design/16-mcp-support.md §6)
+import fs from 'node:fs'
+import http from 'node:http'
+import type { AddressInfo } from 'node:net'
+import path from 'node:path'
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { z } from 'zod'
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
+import type { FileDto, McpPromptListDto, McpPromptResultDto, McpResourceListDto, SessionDetailDto } from '@agent-server/shared'
+import { createApp, type App } from '../src/app'
+import { config } from '../src/config'
+import { buildServer } from '../src/http/server'
+import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
+import { resolveAccess } from '../src/services/access-policy'
+
+let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+let base: string
+let upstream: http.Server
+let serverId: string
+let apiKey: string
+const auth = () => ({ authorization: `Bearer ${apiKey}` })
+
+function startUpstream(): Promise<string> {
+  upstream = http.createServer(async (req, res) => {
+    const mcp = new McpServer({ name: 'docs', version: '1.0.0' })
+    mcp.registerResource('guide', 'docs://manual/guide.md', { title: '使用手册', description: '产品使用手册', mimeType: 'text/markdown' }, async uri => ({
+      contents: [{ uri: uri.href, mimeType: 'text/markdown', text: '# 手册\n第一章' }],
+    }))
+    mcp.registerResource('big', 'docs://manual/big.txt', { title: '大文件', mimeType: 'text/plain' }, async uri => ({ contents: [{ uri: uri.href, text: '行'.repeat(30_000) }] }))
+    mcp.registerResource('logo', 'docs://assets/logo.png', { title: '标志', mimeType: 'image/png' }, async uri => ({
+      contents: [{ uri: uri.href, mimeType: 'image/png', blob: Buffer.from([137, 80, 78, 71]).toString('base64') }],
+    }))
+    mcp.registerResource('topic', new ResourceTemplate('docs://topics/{product}/{topic}', { list: undefined }), { title: '主题文档', description: '按产品和主题读取' }, async (uri, v) => ({
+      contents: [{ uri: uri.href, text: `${v.product} 的 ${v.topic}` }],
+    }))
+    mcp.registerPrompt('weekly', { title: '周报', description: '生成周报', argsSchema: { week: z.string().describe('第几周'), team: z.string().optional() } }, async ({ week, team }) => ({
+      messages: [
+        { role: 'user', content: { type: 'text', text: `请为${team ?? '团队'}写第 ${week} 周的周报` } },
+        { role: 'user', content: { type: 'resource', resource: { uri: 'docs://templates/weekly.md', mimeType: 'text/markdown', 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))
+  })
+  return new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}/mcp`)))
+}
+
+function runCtx(ids: string[]): GatewayRunContext {
+  const ws = path.join(config.dataDir, 'workspaces', `ses_res_${Math.random().toString(36).slice(2)}`)
+  fs.mkdirSync(ws, { recursive: true })
+  const settings = app.settings.get()
+  return {
+    sessionId: `ses_${Math.random().toString(36).slice(2)}`, runId: 'r', provider: 'claude', workspaceDir: ws, permissionMode: 'readonly', settings,
+    access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
+    knowledgeRoots: [], mcpServerIds: ids, audit: () => {}, interact: async () => ({ resolution: 'allowed', scope: 'once' }),
+  }
+}
+async function gateway(ctx: GatewayRunContext) {
+  const client = new Client({ name: 'test', version: '1' })
+  await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${app.gatewayTokens.issue(ctx)}` } } }))
+  return client
+}
+const text = (r: unknown) => (r as { content: { text: string }[] }).content.map(c => c.text).join('\n')
+
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: {} })
+  server = await buildServer(app, { logger: log })
+  base = await server.listen({ port: 0, host: '127.0.0.1' })
+  const url = await startUpstream()
+  serverId = app.mcpServers.create({ name: 'docs', title: '文档服务', description: '', transport: 'http', enabled: true, url, headers: [], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [] }).id
+  const { id: _id, version: _v, updatedAt: _u, builtin: _b, builtinKey: _k, ...input } = app.profiles.getBuiltin()
+  const profile = app.profiles.create({ ...input, name: '带文档服务', isDefault: false, mcpServerIds: [serverId] })
+  apiKey = app.apiKeys.create('平台R', { allowedProfileIds: [profile.id], defaultProfileId: profile.id }).key
+})
+afterAll(async () => {
+  await app.mcpUpstream.closeAll()
+  await server.close()
+  upstream.close()
+})
+
+describe('资源(模型按需读取)', () => {
+  it('刷新时记录服务的能力;勾选了提供资源的服务时网关提供两个只读工具;读取的内容按大小内联或存为文件', async () => {
+    const before = await gateway(runCtx([serverId]))
+    expect((await before.listTools()).tools.map(t => t.name)).not.toContain('mcp_resources') // 还没连接过,不知道服务提供资源
+    await before.close()
+    expect((await app.mcpServers.refresh(serverId)).features).toEqual({ resources: true, prompts: true })
+
+    const ctx = runCtx([serverId])
+    const c = await gateway(ctx)
+    const names = (await c.listTools()).tools.map(t => t.name)
+    expect(names).toEqual(expect.arrayContaining(['mcp_resources', 'mcp_resource_read'])) // 只读模式下也提供
+    const list = text(await c.callTool({ name: 'mcp_resources', arguments: {} }))
+    expect(list).toContain('[docs] 使用手册  docs://manual/guide.md  (text/markdown)')
+    expect(list).toContain('模板 主题文档  docs://topics/{product}/{topic}')
+    expect(list).not.toContain('第一章')
+
+    expect(text(await c.callTool({ name: 'mcp_resource_read', arguments: { server: 'docs', uri: 'docs://manual/guide.md' } }))).toBe('docs://manual/guide.md(text/markdown)\n# 手册\n第一章')
+    expect(text(await c.callTool({ name: 'mcp_resource_read', arguments: { server: 'docs', uri: 'docs://topics/ess/warranty' } }))).toContain('ess 的 warranty')
+    const big = text(await c.callTool({ name: 'mcp_resource_read', arguments: { server: 'docs', uri: 'docs://manual/big.txt' } }))
+    const saved = /保存到 (\S+))/.exec(big)![1]!
+    expect(saved).toBe(path.join('.agent', 'mcp', 'resources', 'big.txt'))
+    expect(fs.readFileSync(path.join(ctx.workspaceDir, saved), 'utf8')).toHaveLength(30_000)
+    const logo = text(await c.callTool({ name: 'mcp_resource_read', arguments: { server: 'docs', uri: 'docs://assets/logo.png' } }))
+    expect(logo).toMatch(/二进制内容(4 B)已保存到 \.agent\/mcp\/resources\/logo\.png/)
+    await c.close()
+
+    const none = await gateway(runCtx([]))
+    expect((await none.listTools()).tools.map(t => t.name)).not.toContain('mcp_resources')
+    await none.close()
+  })
+})
+
+describe('资料与快捷指令(会话 API)', () => {
+  it('会话详情给出能力;列出资源、附加为会话文件;列出提示模板、展开后返回文本和附件', async () => {
+    const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id as string
+    expect((await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: auth() })).json<SessionDetailDto>().mcp).toEqual({ resources: true, prompts: true })
+
+    const list = (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}/mcp/resources`, headers: auth() })).json<McpResourceListDto>()
+    expect(list.resources.map(r => r.uri)).toEqual(['docs://manual/guide.md', 'docs://manual/big.txt', 'docs://assets/logo.png'])
+    expect(list.resources[0]).toMatchObject({ serverId, serverName: 'docs', serverTitle: '文档服务', title: '使用手册', description: '产品使用手册' })
+    expect(list.templates).toEqual([expect.objectContaining({ uriTemplate: 'docs://topics/{product}/{topic}', title: '主题文档' })])
+
+    const attached = (await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/mcp/resources/attach`, headers: auth(), payload: { server: 'docs', uri: 'docs://manual/guide.md' } })).json<FileDto[]>()
+    expect(attached).toEqual([expect.objectContaining({ path: 'uploads/guide.md', origin: 'upload', size: Buffer.byteLength('# 手册\n第一章') })])
+    const run = await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text: '看看这份手册', attachments: attached.map(f => f.id) } })
+    expect([202, 409]).toContain(run.statusCode) // 没有引擎时也能校验附件属于这个会话
+
+    const prompts = (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}/mcp/prompts`, headers: auth() })).json<McpPromptListDto>()
+    expect(prompts.prompts).toEqual([expect.objectContaining({ name: 'weekly', title: '周报', arguments: [{ name: 'week', description: '第几周', required: true }, { name: 'team', description: null, required: false }] })])
+    const got = (await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/mcp/prompts/get`, headers: auth(), payload: { server: serverId, name: 'weekly', arguments: { week: '44', team: '储能组' } } })).json<McpPromptResultDto>()
+    expect(got.text).toBe('请为储能组写第 44 周的周报')
+    expect(got.attachments).toEqual([expect.objectContaining({ path: 'uploads/weekly.md' })])
+
+    const missing = await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/mcp/resources/attach`, headers: auth(), payload: { server: 'nope', uri: 'docs://x' } })
+    expect(missing.statusCode).toBe(404)
+  })
+})

+ 203 - 0
apps/server/test/mcp-tasks.test.ts

@@ -0,0 +1,203 @@
+// MCP 支持完善 · 阶段 B:长任务(MCP Tasks)与后台调用合并(docs/design/16-mcp-support.md §3)
+import http from 'node:http'
+import type { AddressInfo } from 'node:net'
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { z } from 'zod'
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import { InMemoryTaskMessageQueue, InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js'
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
+import type { BackgroundCallDto, ProviderStatusDto } from '@agent-server/shared'
+import { createApp, type App } from '../src/app'
+import { buildServer } from '../src/http/server'
+import { taskTiming } from '../src/mcp-gateway/tasks'
+import type { AgentProvider, RunContext } from '../src/providers/types'
+
+type ToolCall = (tool: string, args?: Record<string, unknown>) => Promise<string>
+
+class ScriptedProvider implements AgentProvider {
+  readonly id = 'claude' as const
+  script: (input: string, tool: ToolCall) => Promise<string> = async () => 'ok'
+  inputs: { text: string; sessionId: string }[] = []
+  async run(ctx: RunContext) {
+    ctx.setProviderSessionId(`fake-${ctx.sessionId}`)
+    this.inputs.push({ text: ctx.input.text, sessionId: ctx.sessionId })
+    const url = new URL(ctx.gateway.url)
+    const client = new Client({ name: 'fake', version: '1' })
+    await client.connect(new StreamableHTTPClientTransport(new URL(`${url.pathname}${url.search}`, base), { requestInit: { headers: { Authorization: `Bearer ${ctx.gateway.token}` } } }))
+    const tool: ToolCall = async (name, args = {}) => {
+      const r = await client.callTool({ name: `jobs__${name}`, arguments: args }, undefined, { timeout: 60_000 })
+      return (r.content as { text: string }[]).map(c => c.text).join('\n')
+    }
+    try {
+      ctx.emit('message.completed', { messageId: `m${this.inputs.length}`, text: await this.script(ctx.input.text, tool) })
+    } finally {
+      await client.close()
+    }
+    return { usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0.001 } }
+  }
+  async status(): Promise<ProviderStatusDto> {
+    return { id: 'claude', enabled: true, available: true, loggedIn: true, account: 'test', cliPath: '', version: 'x', models: ['m'], defaultModel: 'm', message: null }
+  }
+}
+
+let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+let base: string
+let upstream: http.Server
+let apiKey: string
+const fake = new ScriptedProvider()
+const auth = () => ({ authorization: `Bearer ${apiKey}` })
+
+async function waitFor<T>(fn: () => T | undefined | false | null, ms = 8000): Promise<T> {
+  const end = Date.now() + ms
+  while (Date.now() < end) {
+    const v = fn()
+    if (v) return v
+    await new Promise(r => setTimeout(r, 20))
+  }
+  throw new Error('timeout')
+}
+
+/** 外部服务:任务不按 MCP 会话归属(无状态服务),重新连接后也能查询;任务由测试放行后完成 */
+const taskStore = new InMemoryTaskStore()
+const queue = new InMemoryTaskMessageQueue()
+const gates = new Map<string, (r: { text: string; isError?: boolean }) => void>()
+const created: string[] = []
+function startUpstream(): Promise<string> {
+  upstream = http.createServer(async (req, res) => {
+    const mcp = new McpServer({ name: 'jobs', version: '1.0.0' }, { capabilities: { tasks: { requests: { tools: { call: {} } }, cancel: {} } }, taskStore, taskMessageQueue: queue })
+    mcp.experimental.tasks.registerToolTask('run_job', {
+      description: '执行作业(可能很久)', inputSchema: { key: z.string() }, execution: { taskSupport: 'required' },
+    }, {
+      async createTask({ key }, { taskStore: store, taskRequestedTtl }) {
+        const task = await store.createTask({ ttl: taskRequestedTtl, pollInterval: 100 })
+        created.push(task.taskId)
+        const finish = (r: { text: string; isError?: boolean }) => void taskStore.storeTaskResult(task.taskId, r.isError ? 'failed' : 'completed', { content: [{ type: 'text', text: r.text }], ...(r.isError ? { isError: true } : {}) })
+        if (key.startsWith('quick')) setTimeout(() => finish({ text: `作业完成:${key}` }), 200)
+        else gates.set(key, finish)
+        return { task, _meta: { 'io.modelcontextprotocol/model-immediate-response': `作业 ${key} 已排队,预计 1 小时` } }
+      },
+      getTask: async (_args, { taskId, taskStore: store }) => (await store.getTask(taskId))!,
+      getTaskResult: async (_args, { taskId, taskStore: store }) => await store.getTaskResult(taskId) as never,
+    })
+    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))
+  })
+  return new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}/mcp`)))
+}
+const finish = async (key: string, r: { text: string; isError?: boolean }) => (await waitFor(() => gates.get(key)))(r)
+
+Object.assign(taskTiming, { minPollMs: 50, defaultPollMs: 100, foregroundPollMs: 50, tickMs: 50 })
+
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: { claude: fake } })
+  server = await buildServer(app, { logger: log })
+  base = await server.listen({ port: 0, host: '127.0.0.1' })
+  const url = await startUpstream()
+  const serverId = app.mcpServers.create({
+    name: 'jobs', title: '作业服务', description: '', transport: 'http', enabled: true, url, headers: [], command: '', args: [], env: [],
+    allowNetwork: false, readPaths: [], writePaths: [],
+  }).id
+  await app.mcpServers.refresh(serverId)
+  app.mcpServers.setToolPolicy(serverId, 'run_job', 'auto')
+  const { id: _id, version: _v, updatedAt: _u, builtin: _b, builtinKey: _k, ...input } = app.profiles.getBuiltin()
+  const profile = app.profiles.create({ ...input, name: '带作业服务', isDefault: false, mcpServerIds: [serverId] })
+  apiKey = app.apiKeys.create('平台T', { allowedProfileIds: [profile.id], defaultProfileId: profile.id }).key
+  app.settings.update({ backgroundCalls: { autoAfterSec: 1 } })
+})
+afterAll(async () => {
+  app.mcpTasks.stop()
+  app.backgroundCalls.stop()
+  await app.mcpUpstream.closeAll()
+  await server.close()
+  upstream.closeAllConnections()
+  upstream.close()
+})
+
+const newSession = async () => (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id as string
+const calls = async (sid: string) => (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}/background-calls`, headers: auth() })).json() as BackgroundCallDto[]
+const send = (sid: string, text: string) => server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text } })
+const idle = (sid: string) => !app.runs.activeForSession(sid)
+
+describe('长任务', () => {
+  it('工具列表标出长任务;很快完成的任务在前台直接返回结果', async () => {
+    expect(app.mcpServers.list().find(s => s.name === 'jobs')!.tools[0]).toMatchObject({ name: 'run_job', taskSupport: 'required' })
+    const sid = await newSession()
+    let reply = ''
+    fake.script = async (input, tool) => (reply = await tool('run_job', { key: 'quick1' }))
+    await send(sid, '跑一下')
+    await waitFor(() => idle(sid) && reply)
+    expect(reply).toBe('作业完成:quick1')
+    expect(await calls(sid)).toHaveLength(0)
+  })
+
+  it('超过时限的任务转为后台(带上服务的说明),按任务 id 查询,完成后把结果发到会话', async () => {
+    const sid = await newSession()
+    let reply = ''
+    fake.script = async (input, tool) => (input.startsWith('【后台调用') ? '收到' : (reply = await tool('run_job', { key: 'slow1' })))
+    await send(sid, '跑个大的')
+    await waitFor(() => idle(sid) && reply, 10_000)
+    expect(reply).toMatch(/已转为后台执行(后台调用 bg_.+)/)
+    expect(reply).toContain('服务的说明:作业 slow1 已排队,预计 1 小时')
+    const [running] = await calls(sid)
+    expect(running).toMatchObject({ status: 'running', mode: 'task', tool: 'run_job' })
+
+    const before = fake.inputs.length
+    await finish('slow1', { text: '报表已生成:1024 行' })
+    const delivered = await waitFor(() => fake.inputs.slice(before).find(i => i.sessionId === sid))
+    expect(delivered.text).toMatch(/^【后台调用完成】作业服务 · run_job/)
+    expect(delivered.text).toContain('报表已生成:1024 行')
+    await waitFor(() => idle(sid))
+    expect((await calls(sid))[0]).toMatchObject({ status: 'completed', delivered: true })
+  }, 20_000)
+
+  it('任务失败按失败通知;平台取消时通知服务取消任务', async () => {
+    const sid = await newSession()
+    fake.script = async (input, tool) => (input.startsWith('【后台调用') ? '收到' : tool('run_job', { key: input }))
+    await send(sid, 'fail1')
+    await waitFor(() => idle(sid) && app.backgroundCalls.list({ sessionId: sid })[0], 10_000)
+    await finish('fail1', { text: '数据源不可用', isError: true })
+    await waitFor(() => app.backgroundCalls.list({ sessionId: sid })[0]?.status === 'failed')
+    expect(app.backgroundCalls.list({ sessionId: sid })[0]!.error).toContain('数据源不可用')
+
+    await waitFor(() => idle(sid))
+    await send(sid, 'cancel1')
+    const row = await waitFor(() => idle(sid) && app.backgroundCalls.list({ sessionId: sid }).find(c => c.status === 'running'), 10_000)
+    const res = await server.inject({ method: 'POST', url: `/v1/background-calls/${row.id}/cancel`, headers: auth() })
+    expect(res.statusCode).toBe(200)
+    const taskId = created.at(-1)!
+    await expect.poll(async () => (await taskStore.getTask(taskId))?.status).toBe('cancelled')
+  }, 25_000)
+
+  it('服务重启后按任务 id 继续查询', async () => {
+    const sid = await newSession()
+    fake.script = async (input, tool) => (input.startsWith('【后台调用') ? '收到' : tool('run_job', { key: 'restart1' }))
+    await send(sid, '跑')
+    await waitFor(() => idle(sid) && app.backgroundCalls.list({ sessionId: sid })[0], 10_000)
+
+    // 模拟重启:旧实例停止查询、断开连接,新实例接手同一个数据库
+    app.mcpTasks.stop()
+    app.backgroundCalls.stop()
+    await app.mcpUpstream.closeAll()
+    const app2 = createApp(pino({ level: 'silent' }), { db: app.db, providers: { claude: fake } })
+    try {
+      expect(app2.backgroundCalls.list({ sessionId: sid })[0]).toMatchObject({ status: 'running', mode: 'task' })
+      const before = fake.inputs.length
+      await finish('restart1', { text: '重启后也拿到了' })
+      const delivered = await waitFor(() => fake.inputs.slice(before).find(i => i.sessionId === sid), 10_000)
+      expect(delivered.text).toContain('重启后也拿到了')
+    } finally {
+      app2.mcpTasks.stop()
+      app2.backgroundCalls.stop()
+      await app2.mcpUpstream.closeAll()
+    }
+  }, 25_000)
+})

+ 1 - 1
apps/server/test/mcp.test.ts

@@ -70,7 +70,7 @@ function runCtx(over: Partial<GatewayRunContext> = {}): GatewayRunContext {
     sessionId: `ses_${Math.random().toString(36).slice(2)}`, runId: 'r', provider: 'claude', workspaceDir: ws, permissionMode: 'ask', settings,
     access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
     knowledgeRoots: [], mcpServerIds: [], audit: () => {},
-    interact: async req => { approvals.push(req); return answer },
+    interact: async req => { approvals.push(req as NewApproval); return answer },
     ...over,
   }
 }

+ 5 - 1
docs/README.md

@@ -10,6 +10,8 @@ AgentServer 是一个自托管的 Agent 服务:在服务端接入 **Claude Cod
 docs/
 ├─ README.md                  本文件:索引、术语、文档约定
 ├─ integration.md             业务平台接入指南(SDK 安装、浏览器令牌、<AgentChat>、<TeamChat> 组件)
+├─ guides/
+│  └─ mcp-guide.md            MCP 接入指南:给 MCP 服务开发者(工具设计、长任务、询问用户、资源、认证)
 ├─ design/                    设计文档(随设计演进持续更新)
 │  ├─ 01-requirements.md      需求与范围
 │  ├─ 02-architecture.md      总体架构、模块划分、运行时模型
@@ -25,7 +27,8 @@ docs/
 │  ├─ 12-custom-skills.md     自定义技能(导入或在后台编写,两个引擎共用)
 │  ├─ 13-bot-teams.md         Bot 工作组:多岗位机器人、组长分派任务、Bot API(一期至三期已实施)
 │  ├─ 14-schedules.md         定时任务:在对话中创建,到点自动在会话或工作组中执行
-│  └─ 15-background-calls.md  后台调用:外部工具的长调用由服务端等待,完成后通知 AI 继续
+│  ├─ 15-background-calls.md  后台调用:外部工具的长调用由服务端等待,完成后通知 AI 继续
+│  └─ 16-mcp-support.md       MCP 支持完善:工具提示、结构化输出、长任务、询问用户、OAuth、资源与提示模板
 ├─ adr/                       架构决策记录(一事一档,只追加不改写;推翻时新增一条并标注 Superseded)
 └─ research/                  调研材料(选型依据、参考实现分析)
 ```
@@ -58,6 +61,7 @@ docs/
 | [0022](adr/0022-managed-skill-dirs-readable-in-sandbox.md) | 服务管理的技能目录在沙箱中只读可见;Codex 的禁止规则绕开可读路径 | Accepted |
 | [0023](adr/0023-bot-teams-collaboration-model.md) | Bot 工作组:协调者模式,每个机器人一个独立会话 | Accepted |
 | [0024](adr/0024-bot-api-separate-keys.md) | Bot API 使用独立路径和独立的 Bot Key | Accepted |
+| [0025](adr/0025-mcp-spec-features.md) | 按 MCP 规范补齐工具以外的能力(长任务、询问用户、OAuth、资源等) | Accepted |
 
 ## 调研
 

+ 4 - 0
docs/adr/0021-third-party-mcp-via-gateway.md

@@ -36,3 +36,7 @@
 - 后台新增"MCP 服务"页面:内置工具只读展示;第三方服务的添加、测试连接、刷新工具、逐个工具策略、启停、删除
 - 本地进程以服务账号运行在沙箱中,"允许联网"后不限制目标地址;只应添加可信的 MCP 程序,建议写明版本号
 - 已实测:官方示例服务 `@modelcontextprotocol/server-everything`(npx,沙箱内)与公开的 DeepWiki 远程服务,Claude 对话中两类工具都能调用,非只读工具会弹出审批
+
+## 后续(2026-10-29)
+按 MCP 规范补齐工具以外的能力(ADR-0025、docs/design/16-mcp-support.md):工具提示与"总是审批"、结构化输出、工具列表变化、长任务(Tasks)、询问用户(Elicitation)、OAuth 授权、资源与提示模板。上文"需要 OAuth 登录的服务暂不支持"、"多个对话共用连接"、"只读工具重试"分别由按服务共享的 OAuth、HTTP 服务按会话连接、只读或幂等工具重试取代。
+

+ 21 - 0
docs/adr/0025-mcp-spec-features.md

@@ -0,0 +1,21 @@
+# ADR-0025 按 MCP 规范补齐工具以外的能力
+
+- 状态:Accepted · 日期:2026-10-29 · 补充 ADR-0021 · 设计见 [16-mcp-support](../design/16-mcp-support.md),接入指南见 [mcp-guide](../guides/mcp-guide.md)
+
+## 背景
+网关(ADR-0021)只用到了 MCP 的工具部分:工具提示只认只读,结构化结果在有文本时被丢弃,没有长任务、询问用户、OAuth、资源与提示模板。业务方的 MCP 工具有运行一小时以上的作业,原来的做法是拆成"触发"和"查询"两个工具让模型轮询,既花 token 又不可靠;后台调用(docs/design/15)保持连接等待,服务重启或断线就失败。
+
+## 决策
+- **只实现规范中定义的机制,不自定义协议字段。** 曾考虑在工具结果里约定 `async_job` 字段由平台代为轮询,放弃:MCP 2025-11-25 已有 Tasks,自定义字段只在本平台有效。
+- **长任务用 MCP Tasks**,与后台调用合并为同一套记录、唤醒、取消与界面,只多一个"等待方式"。服务不支持 Tasks 时仍按原来的方式保持连接等待。Tasks 在规范中是实验性的,相关代码集中在 `mcp-gateway/tasks.ts`。
+- **破坏性提示只认明确声明的 true**:规范中非只读工具的 `destructiveHint` 默认为 true,若据此强制审批,所有没写提示的工具在自动模式下都要审批。明确声明的工具默认"总是审批";管理员可以逐个工具覆盖提示。
+- **询问用户(Elicitation)必须能确定属于哪个会话**:规范的询问请求不带发起调用的信息,因此 HTTP 服务按"服务 × 会话"建立连接;本地进程只在连接上的调用都属于同一个会话时才接受询问,否则拒绝。长任务中的询问按任务 id 找发起方。
+- **OAuth 按服务共享**:管理员授权一次,所有会话共用这个账号,与请求头令牌一致;不引入"终端使用者"概念(01-requirements 中明确不做)。
+- **资源由模型按需读取,或由用户附加**;不做 Profile 固定附加与资源订阅。提示模板展开后放进输入框,由用户修改后发送。
+- **不做 Sampling**(服务借用平台的模型):花平台的 token,外部服务可以借此驱动模型,安全与费用难以控制。
+
+## 后果
+- `interactions` 增加 `form` 类交互,`runId` 可以为空;Agent API / Bot API 的回复增加 `{ action, content }`,`<InteractionCard>` 增加表单与网址卡片。
+- 后台调用增加 `mode`、任务 id 等字段;长任务在服务重启后继续查询(前提是服务按认证身份而不是 MCP 会话归属任务)。
+- HTTP 服务的连接数从"每个服务一个"变为"每个服务 × 会话一个"(空闲 10 分钟关闭);长任务进行中占用的连接不关闭。
+- 新增《MCP 接入指南》与参考服务 `examples/mcp-reference-server`,测试 `mcp-reference.test.ts` 保证两者与平台一致。

+ 1 - 1
docs/design/09-admin-ui.md

@@ -44,7 +44,7 @@
 | 🗃 文件管理 | 位置 + 常用目录(各资料库、本地技能目录、已安装技能、Python 运行环境、服务数据、日志) | 以管理员身份浏览与管理文件:会话工作区与资料库可写(新建文件/目录、上传与拖放、重命名/移动、删除、目录打包下载);**整个服务目录**(`/srv/agent-server`:程序、运行环境、技能、数据、日志、引擎 home)只读浏览。文本文件用 Monaco 编辑或查看(Ctrl/⌘+S 保存,打开后被改动则拒绝覆盖),图片预览,其他文件下载。登录凭证与密钥(内置全局禁止项中的 `~/.claude/.credentials.json`、`~/.codex/auth.json`、`~/.ssh` 等)不显示;数据库及其备份能看到但不能读取或下载(含 MCP 服务密钥等只写不读的配置)。路径不能越出位置,外部符号链接不跟随;会话目录本身不能在这里删除或移动;修改会话文件后同步该会话的文件记录。实现:`apps/server/src/services/file-manager.ts`、`/admin/v1/fs/*` |
 | 🔑 引擎状态 | Claude / Codex | 登录状态、账户摘要、额度、CLI 路径与版本、生效的工具清单(用于确认个人插件已被禁用);未登录时提示在终端执行 `claude` / `codex login` |
 | 🗝 API Keys | Key 列表 | 签发(明文只显示一次)、吊销、最近使用时间 |
-| 🔌 MCP 服务 | — | 内置工具(只读展示);第三方服务:添加远程 HTTP 或本地进程(沙箱)服务、测试连接、刷新工具、逐个工具设置策略(直接调用 / 跟随权限模式 / 关闭)、启停、删除;密钥只写不读;显示使用它的 Profile 与连接状态(ADR-0021) |
+| 🔌 MCP 服务 | — | 内置工具(只读展示);第三方服务:添加远程 HTTP 或本地进程(沙箱)服务、测试连接、刷新工具、逐个工具设置策略(直接调用 / 跟随权限模式 / 总是审批 / 关闭)、后台执行、查看与覆盖工具提示(只读、破坏性、幂等)、启停、删除;工具标出结构化输出、长任务;服务标出提供资源、提示模板;远程服务可以启用 OAuth(填写或自动注册客户端,"授权"在新窗口中完成,可以清除);密钥只写不读;显示使用它的 Profile 与连接状态(ADR-0021、ADR-0025) |
 | 📖 API 协议 | 右侧:调试身份 + 目录(随滚动高亮) | 对外接口(/v1)的完整协议:由 `apps/server/src/api-reference/reference.ts` 生成(测试保证与实际路由一致),跟随亮/暗主题。每个接口可以**调试**:填写参数与请求体后以"后台管理员"或某个平台的身份调用(平台身份由服务端签发 10 分钟的浏览器令牌,API Key 不离开服务端,记入审计),写操作发送前确认,发送消息会提示消耗订阅额度;事件流实时显示(最多 2 分钟 / 200 条,可停止);响应中的 ID 自动预填到其他接口的路径参数。下载 PDF 由服务端无头 Chrome 生成(A4,服务器没有 Chrome 时改用浏览器打印) |
 | 🔌 连接器 | (暂缓) | — |
 | 📜 审计 | 筛选条件 | 命令执行、连接器调用、审批、后台操作 |

+ 1 - 1
docs/design/15-background-calls.md

@@ -45,7 +45,7 @@
 ## 5. 服务重启
 
 - 后台调用依赖与外部服务的连接,服务重启后无法接续:进行中的调用标记为失败,并通知发起方("服务重启,后台调用中断"),由 AI 决定是否重试。
-- 外部服务本身是异步作业(提交后返回作业 id、可以查询状态)的,建议提供"提交"和"查询"两个工具;MCP 规范中的异步任务机制成熟后再直接对接。
+- 外部服务本身是异步作业(提交后返回作业 id、可以查询状态)的,用 MCP 的长任务(Tasks)实现:平台按任务 id 查询,服务重启后继续(2026-10-29 已实施,见 docs/design/16-mcp-support.md §3,接入指南 §5)。
 - 连接管理:有调用在执行的上游连接不会因空闲被关闭(之前空闲 10 分钟会关闭,长调用会被中断)。
 
 ## 6. 数据模型

+ 171 - 0
docs/design/16-mcp-support.md

@@ -0,0 +1,171 @@
+# 16 MCP 支持完善
+
+> 状态:**已实施(2026-10-29)**,五个阶段全部完成,实施记录见 §9。决策见 ADR-0025。面向 MCP 服务开发者的说明见《MCP 接入指南》(`docs/guides/mcp-guide.md`)。
+
+## 1. 目标
+
+第三方 MCP 服务经 MCP Gateway 转接给两个引擎(ADR-0021)。此前只用到了 MCP 的"工具"部分,本次按 MCP 2025-11-25 规范补齐:
+
+| 阶段 | 内容 |
+|---|---|
+| A | 工具提示(annotations)、结构化输出(`structuredContent` / `outputSchema`)、工具列表变化通知(`list_changed`) |
+| B | 长任务(Tasks),与后台调用合并 |
+| C | 向用户询问信息(Elicitation) |
+| D | OAuth 授权(按服务共享) |
+| E | 服务端的资源(Resources)与提示模板(Prompts) |
+
+不做:Sampling(服务借用平台的模型)——花平台的 token、外部服务可以借此驱动模型,安全与费用都难以控制。
+
+原则:只实现规范中定义的机制,不自定义协议字段;规范中的可选项按本文选定的方式使用,写进《MCP 接入指南》(`docs/guides/mcp-guide.md`)。
+
+## 2. 阶段 A:工具提示、结构化输出、列表变化
+
+### 2.1 工具提示
+
+服务在工具定义中声明的提示(规范中均为"提示",不保证可信):
+
+| 提示 | 规范默认值 | 平台的用法 |
+|---|---|---|
+| `readOnlyHint` | false | 只读:默认直接调用;只读模式下只提供只读工具(原有) |
+| `destructiveHint` | true(仅对非只读工具有意义) | **明确声明为 true** 的工具默认"总是审批":自动模式下也要审批 |
+| `idempotentHint` | false | 只读或幂等的工具遇到网络错误时重新连接后重试一次;其他工具不重试,避免重复执行 |
+| `openWorldHint` | true | 后台工具列表中标出;审计记录中带上 |
+| `title` | — | 后台显示名 |
+
+- 默认值为 true 的 `destructiveHint` 不能直接用来强制审批,否则所有没写提示的工具在自动模式下都要审批;所以只认**明确声明**的 true。
+- 工具策略增加一档"总是审批"(`confirm`):不论权限模式都询问(只读模式下非只读工具仍然不提供)。明确声明破坏性的工具默认为这一档,管理员可以改。
+- 外部服务的提示不一定可信:管理员可以在"MCP 服务"页面逐个工具覆盖只读、破坏性、幂等三个提示(`toolHints`),覆盖值优先。
+- 网关提供给引擎的工具定义带上生效的提示(覆盖后的值)。
+
+### 2.2 结构化输出
+
+- 工具结果同时有文本和 `structuredContent` 时:文本中已经包含同样的 JSON(规范建议服务这样做)就不重复;否则把 JSON 附在文本后面交给模型。只有 `structuredContent` 时转为文本(原有)。超过长度上限的保存为工作区文件,交给模型路径。
+- 工具声明了 `outputSchema` 时,按 schema 校验 `structuredContent`(规范要求客户端校验)。不合格时:结果照常交给模型,审计记录中带上校验错误。
+- 网关**不**把 `outputSchema` 原样转给引擎(引擎的 MCP 客户端会据此严格校验,网关改写过的结果可能通不过),而是在工具说明末尾附上返回结构的简述(截断到 1500 字),让模型事先知道返回什么。
+- 审计记录中保存结构化结果(截断到 20 KB)。
+
+### 2.3 工具列表变化
+
+- 连接上游服务时声明支持 `notifications/tools/list_changed`;收到后(合并 2 秒内的多次通知)重新列出工具并更新缓存。
+- 每次建立新连接时也在后台刷新一次工具缓存,缓存不会长期过时。工具列表和能力**有变化时**才更新 `toolsUpdatedAt`:它是 Codex 会话的工具版本之一,变化会让会话在下一轮分叉、重新获取工具列表(ADR-0020、ADR-0021),没有变化时不应触发。
+- Claude 每一轮重新列出工具;Codex 在下一轮(分叉后)看到新的工具列表。正在进行的这一轮看不到新增的工具,调用已删除的工具时返回"工具不存在"。
+
+## 3. 阶段 B:长任务(Tasks)
+
+### 3.1 与后台调用合并
+
+后台调用(docs/design/15-background-calls.md)增加"等待方式":
+
+| 等待方式 | 条件 | 平台怎么等 | 服务重启 |
+|---|---|---|---|
+| `wait`(原有) | 工具不支持任务 | 保持连接,等工具返回 | 标记失败,通知发起方 |
+| `task`(新增) | 服务声明 `tasks.requests.tools.call`,工具的 `execution.taskSupport` 为 `required` 或 `optional` | 保存任务 id,按 `pollInterval` 调用 `tasks/get` | 继续查询 |
+
+记录表、唤醒消息、取消、顶部的后台调用栏、后台"后台调用"页面、工作组子任务等待都共用。
+
+### 3.2 流程
+
+1. 网关以任务方式调用工具(`tools/call` 带 `task: { ttl }`,ttl 为后台调用的最长时间),立即得到任务 id。
+2. 先在前台查询状态,最多等 `backgroundCalls.autoAfterSec`(默认 180 秒):期间完成的,取回结果直接返回给模型,和普通调用一样。
+3. 超过时间(或工具被标记为"总是后台执行"),转为后台:记录任务 id,告诉模型"已转为后台执行"并结束本轮。服务在 `_meta["io.modelcontextprotocol/model-immediate-response"]` 中提供的说明一并交给模型。
+4. 服务端按 `pollInterval`(最短 5 秒、最长 5 分钟,未提供时 30 秒)查询;到达终态(`completed` / `failed` / `cancelled`)后用 `tasks/result` 取回结果,经过与普通调用相同的结果处理,作为一条新消息唤醒发起方。
+5. 状态为 `input_required` 时调用 `tasks/result`,服务借此发出询问(阶段 C),用户回答后任务继续。
+6. 取消:调用 `tasks/cancel`(尽力而为),记录为已取消。
+7. 查询出错:网络错误下次再查;连续失败超过 30 分钟,或服务返回任务不存在(已过期),按失败处理。
+8. 后台调用已达上限(`maxPerTarget`)时:在前台查询直到完成,最多 10 分钟(与普通调用相同)。
+
+相关代码集中在 `mcp-gateway/tasks.ts`:Tasks 仍是规范中的实验性功能,以后版本调整时只改这一处。
+
+## 4. 阶段 C:向用户询问信息(Elicitation)
+
+### 4.1 能力与形式
+
+连接上游服务时声明 `elicitation: { form: {}, url: {} }`。
+
+| 模式 | 平台的处理 |
+|---|---|
+| 表单(`form`) | 按 `requestedSchema` 生成表单卡片:文本(含 email / uri / date / date-time 格式)、数字、是否、单选、多选,带默认值、必填、长度与范围限制;提交时服务端再校验一次 |
+| 网址(`url`) | 链接卡片:显示说明和完整域名,用户点开后自行完成,再点"已完成"或"拒绝";平台不自动打开网址 |
+
+用户的选择对应规范的三种结果:提交(`accept`,带内容)、拒绝(`decline`)、关闭(`cancel`)。
+
+### 4.2 卡片
+
+- 交互增加一种:`form`(`InteractionFormRequest`),走现有的交互流程:会话事件 `interaction.required`、Agent API / Bot API 的回复接口、超时(`limits.interactionTimeoutSec`)按"关闭"处理。
+- 回复:`{ action: 'accept', content }`、`{ action: 'decline' }`、`{ action: 'cancel' }`。
+- 无人值守(定时任务、没人在看的 API 会话)与审批相同:卡片照常发出,超时后按"关闭"处理。
+- 询问与权限模式无关:自动模式、只读模式下同样询问。
+- 不能用于索要密码、密钥(规范禁止):接入指南中说明,需要授权的用网址模式。
+
+### 4.3 询问属于哪个会话
+
+规范中询问请求本身不带"由哪次调用发起"的信息,而上游连接是多个会话共用的。为了不把一个会话的询问显示到另一个会话:
+
+- **HTTP 服务**:连接按"服务 × 会话"建立(HTTP 连接开销很小),询问一定属于这个会话。
+- **本地(stdio)服务**:一个进程多个会话共用。询问到达时,如果这个连接上正在执行的调用都属于同一个会话,归到这个会话;否则拒绝这次询问(`decline`),审计中记录原因。
+- **长任务**:询问带有 `io.modelcontextprotocol/related-task`,按任务 id 找到发起方;后台查询使用的是不属于任何会话的连接,只认任务 id。
+- 后台阶段的询问发生时会话可能没有在执行,卡片不属于任何一轮(`runId` 为空),照常显示和回复。
+
+## 5. 阶段 D:OAuth 授权(按服务共享)
+
+- HTTP 服务的认证方式:无 / 请求头令牌(原有)/ OAuth。
+- OAuth 按 MCP 规范的授权流程(OAuth 2.1 + PKCE,授权服务器元数据发现):
+  1. 管理员在"MCP 服务"页面点"授权",平台发现授权服务器、注册客户端(支持动态注册;也可以填写预先申请的 Client ID / Secret 和 scope),返回授权网址。
+  2. 管理员在弹出的窗口中登录并同意,授权服务器回调 `<后台地址>/admin/v1/mcp/oauth/callback`,平台用授权码换取令牌。
+  3. 令牌由服务端保存(与请求头令牌一样存放在数据库中,后台和接口只显示"已授权",不返回令牌;数据库文件在文件管理中不可下载),访问令牌过期前用刷新令牌自动续期。
+- 授权按服务共享:所有使用这个服务的会话用同一个账号,与请求头令牌的方式一致。
+- 刷新失败(授权被撤销、刷新令牌过期):服务状态显示"需要重新授权",调用返回明确的错误,模型据此告诉用户。
+- 回调地址取管理员发起授权时访问后台的地址(经反向代理时依赖 `AGENT_SERVER_TRUST_PROXY`)。
+
+## 6. 阶段 E:资源与提示模板
+
+### 6.1 资源(Resources)
+
+| 用法 | 说明 | token 消耗 |
+|---|---|---|
+| 模型按需读取 | Profile 勾选的服务中有声明资源能力的,网关提供两个工具:`mcp_resources(server?, cursor?)` 列出资源和资源模板(分页,只有目录信息);`mcp_resource_read(server, uri)` 读取 | 只在读取时,只有读到的部分 |
+| 用户附加 | 会话输入框"附加资料":选择服务和资源,平台读取后保存到会话的 `uploads/`,作为附件发出 | 这一轮附件的篇幅 |
+
+- 读取结果:文本不超过 20,000 字直接返回,超过的保存到工作区 `.agent/mcp/` 并返回开头和路径;二进制一律保存为文件。
+- 资源模板(如 `docs://{product}/{topic}`):列出时给出模板和说明,模型填好参数后用 `mcp_resource_read` 读取。
+- 规范中没有搜索资源的方法:资料多的服务应另外提供搜索工具(接入指南)。
+- 不做:订阅资源变化(`resources/subscribe`)、Profile 固定附加资源。需要时再加。
+
+### 6.2 提示模板(Prompts)
+
+- 会话输入框"快捷指令":列出 Profile 勾选的服务提供的提示模板,选择后填写参数,平台调用 `prompts/get`,把得到的内容放进输入框,用户可以修改后发送。
+- 模板内容中的嵌入资源保存为附件,图片保存为文件。
+- Agent API:`GET /v1/sessions/:id/mcp/prompts`、`POST /v1/sessions/:id/mcp/prompts/get`;资源:`GET /v1/sessions/:id/mcp/resources`、`POST /v1/sessions/:id/mcp/resources/attach`。
+
+## 7. 参考 MCP 服务
+
+`examples/mcp-reference-server/`:用官方 SDK 写的示例服务(虚构的订单系统),实现本文的工具提示、结构化输出、长任务(含任务中询问)、表单与网址询问、资源与模板、提示模板、工具列表变化。用于线上端到端验收,也作为接入指南的示例代码;`test/mcp-reference.test.ts` 启动它并经网关逐项检查,保证示例与平台的支持一致。
+
+OAuth 不在参考服务中(需要一个授权服务器):由 `test/mcp-oauth.test.ts` 中的模拟授权服务器覆盖(元数据发现、动态注册、PKCE、刷新令牌)。
+
+## 8. 数据模型变化
+
+- `mcp_servers`:`toolHints`(工具名 → 覆盖的提示)、`authType`(`none` / `headers` / `oauth`)、`oauth`(客户端信息、令牌、PKCE 校验码、状态;不出现在 DTO 中)。
+- `McpCachedTool`:增加 `title`、`annotations`、`outputSchema`、`taskSupport`。
+- `background_calls`:`mode`(`wait` / `task`)、`taskId`、`pollAfter`、`workspaceDir`、`pollErrorSince`。
+- `interactions`:`runId` 可为空;`kind` 增加 `form`。
+- `McpToolPolicy` 增加 `confirm`。
+
+## 9. 实施记录(2026-10-29)
+
+| 阶段 | 代码 | 测试 |
+|---|---|---|
+| A 工具提示、结构化输出、列表变化 | `services/mcp-servers.ts`(生效提示、`confirm` 策略、`toolHints` 覆盖)、`mcp-gateway/results.ts`(结果处理、按 `outputSchema` 校验)、`mcp-gateway/upstream.ts`(`list_changed`) | `mcp-hints.test.ts` |
+| B 长任务 | `mcp-gateway/tasks.ts`、`mcp-gateway/background.ts`(`mode = task`) | `mcp-tasks.test.ts`(前台完成、转后台、失败、取消、重启后继续) |
+| C 询问用户 | `mcp-gateway/elicitation.ts`、`services/interactions.ts`(`form` 交互、`runId` 可为空、内容校验)、`chat-ui/InteractionCard.vue` | `mcp-elicitation.test.ts` |
+| D OAuth | `services/mcp-oauth.ts`、回调 `GET /admin/v1/mcp/oauth/callback`(不需要后台登录,凭一次性 state) | `mcp-oauth.test.ts` |
+| E 资源与提示模板 | `services/mcp-content.ts`、`/v1/sessions/:id/mcp/*`、`chat-ui/McpPicker.vue`(输入框的"资料"、"快捷指令") | `mcp-resources.test.ts` |
+
+实施中的调整:
+
+- **上游调用不用 `client.callTool`**:SDK 的这个方法在返回结构与 `outputSchema` 不符时直接报错,改为 `client.request` 后自行校验,不合格时照常返回、记录在审计中(§2.2)。
+- **只提供资源或提示模板的服务**:没有 `tools` 能力,列出工具时直接返回空列表,不再当作连接失败。
+- **任务归属**:SDK 自带的内存任务存储把任务绑定在创建它的 MCP 会话上,平台重连(或重启)后查不到。所以平台用创建任务的那个连接查询,并在任务进行中保持这个连接不因空闲关闭;服务端则应按认证身份归属任务(接入指南 §5.3,参考服务按此实现)。按会话绑定的服务在平台重启后,查询时会得到"找不到任务",按失败通知。
+- **询问的卡片**:调用过程中的询问显示在这一轮(Run 进入 waiting_input);长任务中的询问不属于某一轮(`runId` 为空),不改变 Run 状态,会话删除时取消。
+- **OAuth 未授权时**:连接前就给出"需要 OAuth 授权"的提示,不让 SDK 在运行中尝试动态注册(没有回调地址时 SDK 会当作客户端凭证流程处理)。
+- **数据库迁移**:0014(`tool_hints`)、0015(后台调用的任务字段)、0016(`interactions.run_id` 可为空、`kind` 增加 `form`)、0017(`oauth`)、0018(`features`)。

+ 477 - 0
docs/guides/mcp-guide.md

@@ -0,0 +1,477 @@
+# MCP 接入指南
+
+> 写给:要把自己系统的能力做成 MCP 服务、接入 AgentServer 的开发者。
+> 依据:MCP 规范 2025-11-25;平台的实现见 [design/16-mcp-support.md](../design/16-mcp-support.md)。
+> 示例代码:[`examples/mcp-reference-server/server.mjs`](../../examples/mcp-reference-server/server.mjs)(虚构的订单系统,覆盖本指南的全部写法)。
+> 最后更新:2026-10-29
+
+本指南不另立标准,只回答两个问题:MCP 规范里的可选项,在 AgentServer 上**哪些有效、怎么用**;工具怎么设计,**模型才好用**。
+
+---
+
+## 1. 先读这一页
+
+**五条原则**
+
+1. **工具是写给模型用的。** 名称、说明、参数、错误信息的读者都是模型,要让它不看文档也知道什么时候用、怎么填、出错了下一步做什么。
+2. **一个工具做一件事。** 宁可多几个小工具,也不要一个工具带十几个可选参数和模式开关。
+3. **长时间的事交给平台等。** 超过 3 分钟的操作用长任务(§5),不要让模型轮询。
+4. **如实声明工具提示。** 只读、破坏性、幂等这几个提示决定平台是否审批、能否自动重试(§3.3)。
+5. **返回少而准的结果。** 大结果分页、给摘要,文件以资源形式返回,不要把几万字塞进模型的上下文(§3.5、§4.3)。
+
+**平台支持情况速查**
+
+| MCP 能力 | 平台的处理 | 章节 |
+|---|---|---|
+| 工具(tools) | ✅ 转接给 Claude 与 Codex,工具名为 `服务名__工具名` | §3 |
+| 工具提示(annotations) | ✅ 决定默认的审批策略和自动重试,管理员可以覆盖 | §3.3 |
+| 结构化输出(outputSchema / structuredContent) | ✅ 按 schema 校验;结构说明附在工具说明后告诉模型 | §4.2 |
+| 进度通知(progress) | ✅ 收到进度时延长等待时间 | §5.1 |
+| 长任务(Tasks,实验性) | ✅ 平台按任务 id 查询,完成后唤醒会话;服务重启后继续 | §5 |
+| 询问用户(Elicitation,表单 / 网址) | ✅ 变成会话里的卡片 | §6 |
+| 工具列表变化(listChanged) | ✅ 自动刷新 | §7 |
+| 资源(Resources)、资源模板 | ✅ 模型按需读取;用户可以附加到消息 | §8.1 |
+| 提示模板(Prompts) | ✅ 用户在输入框"快捷指令"中使用 | §8.3 |
+| 认证:请求头令牌、OAuth 2.1 | ✅ 令牌由服务端保存;OAuth 按服务共享一个账号 | §9 |
+| Sampling(服务借用平台的模型) | ❌ 不支持 | §10 |
+| 资源订阅、Roots、补全(completion)、日志(logging) | ❌ 暂不支持,声明了也不会使用 | §10 |
+
+---
+
+## 2. 接入方式
+
+### 2.1 传输
+
+| 方式 | 适合 | 说明 |
+|---|---|---|
+| **Streamable HTTP(推荐)** | 独立部署的服务 | 平台按"服务 × 会话"建立连接;旧版 SSE 服务自动兼容 |
+| 本地进程(stdio) | 命令行工具、只在本机使用 | 在沙箱中运行:系统目录只读、独立的 home、**默认不能联网**、只能访问管理员开放的路径;密钥只通过环境变量传入。所有会话共用一个进程 |
+
+需要询问用户(§6)的服务请用 HTTP:本地进程被多个会话同时使用时,平台无法确定询问属于哪个会话,会拒绝询问。
+
+### 2.2 登记
+
+管理员在后台"MCP 服务"页面添加服务、测试连接,再到 Profile 中勾选;勾选了这个服务的 Profile,对话中才会出现它的工具。
+
+- **服务名**(工具名前缀):小写字母、数字、`_`、`-`,最多 32 位。模型看到的工具名是 `服务名__工具名`,**整体最多 64 个字符**,超出部分被截掉。所以工具名要短:`get_order` 好于 `get_order_detail_by_order_number`。
+- 服务的 `instructions`(初始化时返回的服务说明)目前不会转给模型,要让模型知道的内容写在各个工具的说明里。
+
+### 2.3 SDK
+
+推荐用官方 SDK:TypeScript `@modelcontextprotocol/sdk`(1.32 及以上,参考服务使用 1.32.1)、Python `mcp`。长任务和询问用户需要支持 2025-11-25 规范的版本。
+
+---
+
+## 3. 工具设计
+
+### 3.1 粒度与命名
+
+- 按**用户要完成的事**划分工具,而不是照搬后端接口:`cancel_order` 好于 `update_order(status='cancelled')`。
+- 动词开头、`snake_case`:`get_`、`list_`、`search_`、`create_`、`update_`、`delete_`、`export_`。
+- 读和写分成不同的工具。只读工具可以免审批、在只读模式下使用;混在一起,整个工具都要按写操作对待。
+- 一个服务的工具控制在 **20 个以内**。工具越多,模型选错的概率越高,每一轮也要多花 token 列出它们。
+- 设置 `title`(显示名),后台和审批卡片会显示它。
+
+### 3.2 说明与参数
+
+工具说明(`description`)回答三件事:**做什么、什么时候用、有什么限制**。
+
+```
+按订单号查询订单的客户、金额、状态和商品。订单号形如 A1001。
+不知道订单号时,先用 list_orders 按状态查找。
+```
+
+- 前 2000 字以内说清楚(平台截断到 2000 字)。
+- 不要写"你必须……"、"忽略其他工具"之类的指令。这是工具投毒的常见手法,平台审计时会被当作风险。
+- 参数用 JSON Schema 写全:每个参数都有 `description`;能枚举的用 `enum`;格式用 `pattern` / `format`;给出 `default`。
+- 必填参数尽量少,3 到 5 个为宜。可选参数太多时拆成两个工具。
+- 不要让模型传大段内容。要处理文件时,参数里放文件地址或 id。
+
+### 3.3 工具提示(annotations)
+
+| 提示 | 含义 | 平台的用法 | 不写时 |
+|---|---|---|---|
+| `readOnlyHint: true` | 不修改任何东西 | 默认**直接调用**;只读模式下只提供这类工具 | 按非只读处理 |
+| `destructiveHint: true` | 删除、覆盖、不可恢复 | 默认**总是审批**:自动模式下也要用户确认,而且每次都问 | 平台**不**据此强制审批(规范的默认值是 true,平台只认明确写出的 true) |
+| `idempotentHint: true` | 同样的参数重复调用,结果相同 | 遇到网络错误时自动重新连接并**重试一次** | 不重试,避免重复执行 |
+| `openWorldHint: false` | 只操作本系统,不与外部交互 | 后台和审计中不标"外部交互" | 视为与外部交互 |
+
+- 提示必须准确。写错的后果:只读工具写成非只读,用户被多问;破坏性工具漏写,自动模式下不经确认就执行;非幂等工具写成幂等,网络抖动时可能**重复下单**。
+- 管理员可以在后台逐个工具覆盖这三个提示,但不要依赖管理员替你纠正。
+
+### 3.4 错误
+
+- **业务错误**用工具结果返回:`isError: true`,加一段写给模型的说明,讲清原因和下一步怎么做。
+
+  ```json
+  { "isError": true, "content": [{ "type": "text", "text": "订单 A9999 不存在。可以先用 list_orders 查看现有订单号。" }] }
+  ```
+
+- **协议错误**(参数不符合 schema、工具不存在)用 JSON-RPC 错误返回,SDK 会处理。
+- 不要把堆栈、SQL、内部地址放进错误信息:模型会原样转述给用户。
+- 权限不足、令牌失效时说明清楚("令牌没有导出权限"),平台和用户才知道该找谁。
+
+### 3.5 分页与结果大小
+
+- 列表类工具要分页:接收 `cursor`,返回 `nextCursor`,每页 20 到 50 条。
+- 默认只返回关键字段,详情另用一个工具查。
+- 平台的处理:文本超过 **10 万字**时只给模型前一部分,完整内容存成工作区文件;图片、音频超过 **5 MB** 时存成文件。但长结果本身就会消耗模型的上下文,最好在服务端控制在几千字以内。
+- 需要大量数据时,生成文件并以资源返回(§4.3),让模型去读文件、做检索。
+
+### 3.6 幂等与重复提交
+
+- 会产生副作用的操作,尽量支持调用方传入去重键(例如 `requestId`),或者在服务端按业务键去重。
+- 提交类操作(创建作业、下单)做成幂等的:同样的任务已经在执行时,返回已有的结果或作业 id,不要再执行一次。这样就不需要模型先查一次状态再决定要不要提交。
+
+---
+
+## 4. 返回结果
+
+### 4.1 文本
+
+给模型读的结论放在最前面,例如"共 3 条,其中 1 条已逾期"。表格用 Markdown 或 CSV,不要用 HTML。
+
+### 4.2 结构化输出
+
+需要模型(或平台)**准确使用字段**时,声明 `outputSchema` 并返回 `structuredContent`,**同时**把同样的 JSON 序列化成一段文本放进 `content`(兼容只读文本的客户端):
+
+```js
+server.registerTool('get_order', {
+  description: '按订单号查询订单……',
+  inputSchema: { orderId: z.string() },
+  outputSchema: { id: z.string(), customer: z.string(), amount: z.number(), status: z.enum(['paid', 'shipped', 'cancelled']) },
+  annotations: { readOnlyHint: true },
+}, async ({ orderId }) => {
+  const o = await db.getOrder(orderId)
+  return { content: [{ type: 'text', text: JSON.stringify(o) }], structuredContent: o }
+})
+```
+
+平台的处理:
+
+- 文本里已经有同样的 JSON 时不重复;没有时把 JSON 附在文本后面交给模型。
+- 按 `outputSchema` 校验 `structuredContent`。不符合时结果照常交给模型,审计中记录校验错误,管理员能看到。
+- 在工具说明后附上返回结构的简述(最多 1500 字),模型调用前就知道会得到什么。所以 schema 要精简,字段加 `description`。
+
+### 4.3 文件
+
+报表、导出、图片等文件,以**嵌入资源**返回:
+
+```js
+return { content: [
+  { type: 'text', text: '10 月销售报表已生成:共 1024 行' },
+  { type: 'resource', resource: { uri: 'ref://reports/sales-2026-10.csv', mimeType: 'text/csv', text: csv } }, // 二进制用 blob(base64)
+] }
+```
+
+- 平台把嵌入资源**一律**保存到会话工作区的 `.agent/mcp/`。文件名取 URI 的最后一段,所以 URI 要以有意义的文件名结尾。
+- 模型只看到文件路径、大小和类型,看不到内容。需要时它会自己去读或分析这个文件,用户也可以下载。
+- 同时给一句文字摘要(行数、时间范围),模型不用读文件就能回答简单的问题。
+
+---
+
+## 5. 长任务
+
+### 5.1 什么时候用
+
+| 执行时间 | 做法 |
+|---|---|
+| 几秒到 3 分钟 | 普通工具。超过 30 秒的,建议发进度通知(`notifications/progress`),平台会延长等待时间 |
+| 3 分钟以上,或者不确定 | **长任务(Tasks)** |
+
+不支持 Tasks 的工具运行超过 3 分钟时,平台也会转到后台,保持连接等待(最长 24 小时)。但服务重启、网络断开都会让这次调用失败,所以超过几分钟的操作请用 Tasks。
+
+**不要再做"提交 + 查询"两个工具让模型轮询。** 模型没有时钟,只能连续查询(浪费 token),或者这一轮结束后就没人再查了。查询进度的工具可以保留,供用户问"进度怎么样"时使用。
+
+### 5.2 实现
+
+```js
+const server = new McpServer({ name: 'orders', version: '1.0.0' }, {
+  capabilities: { tasks: { requests: { tools: { call: {} } }, cancel: {} } },
+  taskStore,                                   // 见 §5.3
+  taskMessageQueue: new InMemoryTaskMessageQueue(),
+})
+
+server.experimental.tasks.registerToolTask('export_report', {
+  description: '导出指定月份的销售报表。很慢,以长任务方式执行,完成后平台会把结果发给你。',
+  inputSchema: { month: z.string().regex(/^\d{4}-\d{2}$/) },
+  execution: { taskSupport: 'optional' },      // required:只能以任务方式调用
+  annotations: { readOnlyHint: true },
+}, {
+  async createTask({ month }, { taskStore, taskRequestedTtl }) {
+    const task = await taskStore.createTask({ ttl: taskRequestedTtl, pollInterval: 30_000 })
+    startExport(month, {                        // 在后台执行,不要等它完成
+      progress: pct => taskStore.updateTaskStatus(task.taskId, 'working', `已完成 ${pct}%`),
+      done: result => taskStore.storeTaskResult(task.taskId, 'completed', result),
+      fail: err => taskStore.storeTaskResult(task.taskId, 'failed', { isError: true, content: [{ type: 'text', text: err.message }] }),
+    })
+    return { task, _meta: { 'io.modelcontextprotocol/model-immediate-response': `${month} 报表已开始导出,预计 20 分钟` } }
+  },
+  getTask: async (_args, { taskId, taskStore }) => taskStore.getTask(taskId),
+  getTaskResult: async (_args, { taskId, taskStore }) => taskStore.getTaskResult(taskId),
+})
+```
+
+平台的处理:
+
+1. 以任务方式调用(任务保留时间 `ttl` 为 24 小时),先在前台查询最多 3 分钟。期间完成的,结果直接交给模型,和普通工具一样。
+2. 超过时间后转为后台:模型收到"已转为后台执行"和 `model-immediate-response` 中的说明,结束这一轮,告诉用户已提交。
+3. 平台按 `pollInterval` 查询状态(最短 5 秒,最长 5 分钟,不提供时为 30 秒),`statusMessage` 显示在后台调用栏和后台页面。
+4. 到达终态后取回结果,作为一条新消息发到会话(或工作组中发起调用的机器人),唤醒模型继续处理。结果的处理与普通工具相同(§4)。
+5. 用户或管理员取消时,平台调用 `tasks/cancel`。请实现取消,停止后台的执行。
+
+`createTask` 要**立即返回**(几秒以内),实际工作在后台进行。
+
+### 5.3 任务归属与保留
+
+- **按认证身份(令牌或 OAuth 账号)归属任务,不要绑定在 MCP 会话上。** 平台重连或重启后,用新的 MCP 会话查询原来的任务;绑定在会话上的任务会查不到,平台只能按失败通知用户。SDK 自带的 `InMemoryTaskStore` 默认按会话绑定,参考服务给出了改为共享的写法(`SharedTaskStore`)。
+- 任务状态要持久化(数据库或 Redis),否则服务重启后任务就丢了。内存存储只适合示例。
+- 至少保留到 `ttl` 到期,不要早于平台的最长等待时间(24 小时)。
+- 任务 id 要随机、不可猜测;按身份隔离,A 的令牌查不到 B 的任务。
+
+### 5.4 任务失败
+
+- 业务失败:`storeTaskResult(taskId, 'failed', { isError: true, content: [...] })`,说明写给模型看。
+- 平台查询连续失败超过 30 分钟,或者服务返回任务不存在时,按失败处理。
+
+---
+
+## 6. 询问用户(Elicitation)
+
+### 6.1 什么时候用
+
+工具执行到一半,缺少信息或需要确认时,可以向用户提问,不必直接报错再让模型来回追问:
+
+- 有歧义:"有两个叫张三的客户,选哪一个?"
+- 需要补充:"请填写收件地址"
+- 需要授权、付款等在别的页面完成的事:网址模式
+
+不要把**每个**写操作都做成询问确认:审批由平台负责(§3.3 的工具提示)。
+
+### 6.2 两种模式
+
+**表单模式**:
+
+```js
+const r = await server.server.elicitInput({
+  mode: 'form',
+  message: '请填写订单 A1001 的收件信息',
+  requestedSchema: {
+    type: 'object',
+    properties: {
+      name: { type: 'string', title: '收件人', minLength: 1 },
+      speed: { type: 'string', title: '配送方式', oneOf: [{ const: 'standard', title: '普通' }, { const: 'express', title: '加急' }], default: 'standard' },
+      insured: { type: 'boolean', title: '保价', default: false },
+    },
+    required: ['name'],
+  },
+})
+if (r.action === 'accept') { /* r.content.name … */ }
+else if (r.action === 'decline') { /* 用户拒绝 */ }
+else { /* cancel:用户暂不处理或超时 */ }
+```
+
+平台支持的字段(规范限定为一层对象):
+
+| 类型 | 写法 | 卡片 |
+|---|---|---|
+| 文本 | `type: 'string'`,可加 `minLength`、`maxLength`、`format`(`email` / `uri` / `date` / `date-time`) | 输入框 |
+| 数字 | `type: 'number'` 或 `'integer'`,可加 `minimum`、`maximum` | 数字输入框 |
+| 是否 | `type: 'boolean'` | 勾选框 |
+| 单选 | `type: 'string'` + `enum`(可加 `enumNames`)或 `oneOf: [{ const, title }]` | 下拉框 |
+| 多选 | `type: 'array'` + `items.enum` 或 `items.anyOf: [{ const, title }]`,可加 `minItems`、`maxItems` | 多选按钮 |
+
+每个字段都写 `title`,可以加 `description` 和 `default`。不支持嵌套对象。平台在用户提交时按这些约束校验一次,服务端仍要自己校验。
+
+**网址模式**:
+
+```js
+const r = await server.server.elicitInput({ mode: 'url', message: '请登录 CRM 并授权', url: 'https://crm.example.com/oauth/authorize?…', elicitationId: crypto.randomUUID() })
+```
+
+平台显示完整网址和域名,用户自己点开,完成后点"我已完成"(`accept`)。平台不会自动打开网址。
+
+### 6.3 注意
+
+- **不要用表单索要密码、密钥、验证码**(规范禁止)。需要授权的,用网址模式让用户在你的页面完成。
+- 三种结果都要处理。用户 10 分钟(后台可调)没有处理时,结果为 `cancel`。
+- 定时任务等无人值守的场景,询问会等到超时。需要无人值守执行的工具,要提供不询问的默认行为(例如参数中提供足够的信息时就不询问)。
+- 长任务中询问:先把任务状态设为 `input_required`,再发出询问,并带上 `relatedTask`(参考服务的 `purge_cancelled`)。平台会显示卡片,用户回答后任务继续。
+
+---
+
+## 7. 工具列表变化
+
+工具会随配置或权限变化时,声明 `capabilities.tools.listChanged: true`(官方 SDK 在注册工具时自动声明并发送通知)。工具增减后发送 `notifications/tools/list_changed`,平台会重新获取工具列表:
+
+- Claude 在下一轮看到新的工具列表;Codex 的会话在下一轮切换到新的工具列表。
+- 正在进行的这一轮看不到新增的工具。工具删除后再被调用,会返回"工具不存在"。
+
+工具列表最好保持稳定。频繁变化会让模型的行为不稳定,Codex 的会话也会频繁切换。
+
+---
+
+## 8. 资源与提示模板
+
+### 8.1 资源(Resources)
+
+适合做成资源的:**参考资料**,例如产品手册、接口说明、数据字典、表结构、配置、运行状态。它们由模型按需读取,或者由用户附加到对话,而不是"去做一件事"。
+
+```js
+server.registerResource('order-api', 'ref://docs/order-api.md',
+  { title: '订单系统接口说明', description: '订单状态的含义、退款规则', mimeType: 'text/markdown' },
+  async uri => ({ contents: [{ uri: uri.href, mimeType: 'text/markdown', text: markdown }] }))
+
+// 资源模板:按参数读取
+server.registerResource('order', new ResourceTemplate('ref://orders/{orderId}', { list: listOrders }),
+  { title: '订单详情', mimeType: 'application/json' },
+  async (uri, { orderId }) => ({ contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(await getOrder(orderId)) }] }))
+```
+
+平台怎么用:
+
+| 方式 | 说明 | token 消耗 |
+|---|---|---|
+| 模型按需读取 | 网关提供 `mcp_resources`(列出目录)和 `mcp_resource_read`(读取)两个只读工具 | 列目录时只有标题和说明;只有读取时才消耗内容的篇幅 |
+| 用户附加 | 输入框的"资料"按钮:选中的资源保存为会话文件,作为附件发出 | 这一轮附件的篇幅 |
+
+写法建议:
+
+- `title`、`description` 写清楚内容和用途:模型和用户都是**只看目录**来挑选的。
+- 提供 `mimeType` 和 `size`。文本超过 2 万字时,平台只给模型开头,完整内容存成文件。所以大文档按章节拆成多个资源。
+- URI 用稳定的、可读的路径,以有意义的文件名结尾(用户附加时会保存成这个文件名)。
+- **规范中没有搜索资源的方法。** 资料多(几十篇以上)的服务,另外提供一个 `search_docs` 工具,返回匹配的资源 URI 和片段。
+- 资源模板的参数尽量是简单值(id、日期),并在 `description` 中说明取值。
+
+### 8.2 资源与工具怎么选
+
+| 内容 | 做成 |
+|---|---|
+| 不需要参数、偶尔变化的参考资料 | 资源 |
+| 按 id 读取一条记录 | 资源模板,或只读工具(两者都可以;需要筛选、分页时用工具) |
+| 查询、搜索、统计 | 只读工具 |
+| 有副作用的操作 | 工具 |
+
+### 8.3 提示模板(Prompts)
+
+适合固定的工作流程,例如"订单复盘"、"生成周报"。用户在输入框的"快捷指令"中选择、填写参数后,展开的内容放进输入框,用户可以修改后发送。
+
+```js
+server.registerPrompt('order_review', {
+  title: '订单复盘', description: '针对一个订单写复盘:时间线、问题、改进建议',
+  argsSchema: { orderId: z.string().describe('订单号'), focus: z.string().optional().describe('关注点') },
+}, async ({ orderId, focus }) => ({
+  messages: [
+    { role: 'user', content: { type: 'text', text: `请对订单 ${orderId} 做一次复盘……` } },
+    { role: 'user', content: { type: 'resource', resource: { uri: `ref://orders/${orderId}`, mimeType: 'application/json', text: json } } },
+  ],
+}))
+```
+
+- 参数都写 `description`,它会显示为输入框的提示。参数值都是字符串。
+- 文本部分合并后放进输入框;嵌入的资源、图片保存为附件,一起发送。
+- 不需要附带资料时,只返回文本消息即可。
+
+---
+
+## 9. 认证与安全
+
+### 9.1 请求头令牌
+
+最简单的方式:管理员在后台为服务填写请求头(如 `Authorization: Bearer …`)。令牌只保存在服务端,后台和接口不会回显。
+
+### 9.2 OAuth 2.1
+
+需要登录授权的服务,按 MCP 的授权规范实现,平台作为 OAuth 客户端:
+
+1. MCP 端点在未授权时返回 `401`,带 `WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource…"`。
+2. 提供受保护资源元数据(`/.well-known/oauth-protected-resource`),其中的 `authorization_servers` 指向授权服务器。
+3. 授权服务器提供元数据(`/.well-known/oauth-authorization-server`),必须支持 **PKCE(S256)**,并在 `code_challenge_methods_supported` 中声明。
+4. 客户端注册,两种方式任选:
+   - 支持**动态注册**(`registration_endpoint`),平台会自动注册;
+   - 或者预先为平台登记一个客户端,回调地址为 `https://<平台地址>/admin/v1/mcp/oauth/callback`,把 Client ID / Secret 交给管理员填写。
+5. 签发**刷新令牌**:平台在访问令牌过期后自动续期。没有刷新令牌时,令牌一过期,管理员就要重新授权。
+6. 支持 `resource` 参数(RFC 8707),令牌只对这个 MCP 服务有效。
+
+平台的授权**按服务共享**:管理员授权一次,所有使用这个服务的会话都用这个账号。所以请为平台单独建一个权限合适的账号(最小权限),不要用个人管理员账号授权。
+
+### 9.3 安全要求
+
+- 每个请求都校验令牌,包括 `tasks/get`、`tasks/result`、资源读取。
+- 只信任令牌,不信任参数里的身份字段。
+- 参数一律在服务端校验,不要依赖模型"会填对"。
+- 工具说明、资源内容、返回结果都会进入模型的上下文:不要在其中放入指令性的文字,也不要回显用户可控的内容而不加标注(提示词注入)。
+- 本地进程服务在沙箱中运行,默认不能联网、只能写自己的 home 目录。需要联网或访问额外目录时,在登记时说明,由管理员开放。
+
+---
+
+## 10. 不支持或不建议
+
+| 能力 | 原因 |
+|---|---|
+| Sampling(服务请平台调用模型) | 花平台的 token,外部服务可以借此驱动模型。需要模型能力的服务,请自己接入模型 |
+| 资源订阅(`resources/subscribe`) | 暂不支持。资源变化后,下次读取时就是新的内容 |
+| Roots | 平台不向服务暴露本地文件系统,文件通过资源或参数传递 |
+| 补全(`completion/complete`) | 暂不支持。参数的取值写在说明或 `enum` 中 |
+| 服务端日志(`notifications/message`) | 不会显示给用户。排查问题请看服务自己的日志 |
+| 服务的 `instructions` | 不转给模型,内容写进工具说明 |
+
+---
+
+## 11. 上线前自查
+
+**工具**
+- [ ] 工具名短(`服务名__工具名` 不超过 64 个字符),动词开头,一个工具一件事
+- [ ] 说明写清做什么、什么时候用、限制;没有指令性文字
+- [ ] 参数都有说明;能枚举的用 `enum`;必填参数少
+- [ ] 只读 / 破坏性 / 幂等提示准确
+- [ ] 错误用 `isError` 返回,说明原因和下一步
+- [ ] 列表分页,结果控制在几千字以内
+
+**结果**
+- [ ] 需要准确字段的工具有 `outputSchema` 和 `structuredContent`,并同时返回 JSON 文本
+- [ ] 文件以嵌入资源返回,URI 以文件名结尾,并附一句摘要
+
+**长任务与询问**
+- [ ] 超过 3 分钟的操作用 Tasks;`createTask` 立即返回;提供 `pollInterval` 和进度说明;实现了取消
+- [ ] 任务按认证身份归属、持久化保存、至少保留 24 小时
+- [ ] 询问处理了 accept / decline / cancel 三种结果;不索要密码和密钥
+- [ ] 无人值守时工具有不询问的执行方式
+
+**资源、提示模板与安全**
+- [ ] 资源有清楚的标题、说明、`mimeType`;大文档已拆分;资料多时提供搜索工具
+- [ ] 提示模板的参数都有说明
+- [ ] 每个请求都校验令牌;OAuth 支持 PKCE(S256)和刷新令牌;平台使用最小权限的账号
+
+---
+
+## 12. 参考服务与验证
+
+参考服务 [`examples/mcp-reference-server`](../../examples/mcp-reference-server/server.mjs) 用一个虚构的订单系统演示了本指南的写法:
+
+| 工具 / 资源 | 演示内容 |
+|---|---|
+| `get_order`、`list_orders` | 只读、结构化输出、分页、写给模型的错误信息 |
+| `add_note`、`cancel_order` | 不幂等的写操作;破坏性 + 幂等(自动模式下也要确认) |
+| `ship_order`、`connect_crm` | 表单询问;网址询问 |
+| `export_report` | 长任务:进度、即时说明、结果中返回 CSV 文件 |
+| `purge_cancelled` | 长任务中询问用户确认 |
+| `enable_beta` | 工具列表变化 |
+| `ref://docs/order-api.md`、`ref://orders/{orderId}` | 资源、资源模板 |
+| `order_review` | 提示模板(附带订单资源) |
+
+```bash
+cd examples/mcp-reference-server
+pnpm install            # 或 npm install
+PORT=8790 REF_TOKEN=换成随机字符串 node server.mjs
+```
+
+在后台"MCP 服务"中添加:远程 HTTP,地址 `http://<主机>:8790/mcp`,请求头 `Authorization: Bearer <REF_TOKEN>`。点"刷新工具"后,工具列表中应当显示只读、破坏性、幂等、结构化输出、长任务等标记,服务旁显示"资源"、"提示模板"。在 Profile 中勾选这个服务,到 Playground 中试一试:
+
+- "查一下订单 A1001":直接返回,不审批
+- "取消订单 A1002":弹出审批,且没有"本会话不再询问"的选项
+- "给 A1001 安排发货":弹出收件信息表单
+- "导出 2026-10 的销售报表,执行 300 秒":转为后台,5 分钟后结果作为新消息出现,附带 CSV 文件
+- 输入框的"资料"中附加"订单系统接口说明","快捷指令"中使用"订单复盘"
+
+自己的服务可以按同样的步骤验证。平台的自动化测试 `apps/server/test/mcp-reference.test.ts` 会启动参考服务并逐项检查,保证本指南、示例与平台的实际行为一致。

+ 25 - 3
docs/integration.md

@@ -47,9 +47,9 @@ app.post('/api/agent-token', requireLogin, async (req, res) => {
 SDK 由 AgentServer 自己提供,三个包需要一起安装:
 
 ```bash
-pnpm add https://agent.zenpower.cc/sdk/agent-server-shared-0.5.0.tgz \
-         https://agent.zenpower.cc/sdk/agent-server-client-0.5.0.tgz \
-         https://agent.zenpower.cc/sdk/agent-server-chat-ui-0.5.0.tgz
+pnpm add https://agent.zenpower.cc/sdk/agent-server-shared-0.6.0.tgz \
+         https://agent.zenpower.cc/sdk/agent-server-client-0.6.0.tgz \
+         https://agent.zenpower.cc/sdk/agent-server-chat-ui-0.6.0.tgz
 ```
 
 | 包 | 内容 |
@@ -163,6 +163,28 @@ await bots.teams.schedules(teamId)                         // 工作组的定时
 - 自己渲染界面时:`client.sessions.backgroundCalls(id)`、`client.backgroundCalls.cancel(callId)`;工作组用 `bots.teams.backgroundCalls(teamId)`、`bots.teams.cancelBackgroundCall(teamId, callId)`。
 - 超过多少秒转后台、最长多久,在后台"设置 › 后台调用"中调整;执行时间一定很长的工具,可以在"MCP 服务"中标记为"后台执行"。
 
+## 5.3 外部服务的询问、资料与快捷指令(SDK 0.6.0)
+
+会话的 Profile 勾选的 MCP 服务(设计见 [design/16-mcp-support.md](design/16-mcp-support.md))可以:
+
+- **向用户询问信息**:时间线中出现表单卡片(`interaction.required`,`kind` 为 `form`)。`mode` 为 `form` 时按 `fields` 填写;为 `url` 时请用户自己打开网址(授权、付款等)后点"我已完成",不要自动打开。长任务中的询问不属于某一轮(事件的 `runId` 为 null),会话可能是空闲状态。
+- **提供资料(资源)和快捷指令(提示模板)**:`<AgentChat>` 的输入框会多出"资料"、"快捷指令"两个按钮(只在有相应服务时显示)。附加的资料作为附件发出;快捷指令填好参数后放进输入框,用户可以修改后再发送。
+
+自己渲染界面时:
+
+```ts
+// 表单卡片的回复:提交(带内容;url 模式表示已完成)、拒绝、暂不处理
+await client.interactions.reply(itx.interactionId, { action: 'accept', content: { name: '张三', count: 2 } })
+await client.interactions.reply(itx.interactionId, { action: 'decline' })
+
+const { mcp } = await client.sessions.get(sessionId)                 // { resources: boolean, prompts: boolean }
+const list = await client.sessions.mcpResources(sessionId)            // 资源目录(不含内容)
+const files = await client.sessions.attachMcpResource(sessionId, 'docs', 'docs://manual/guide.md')
+await client.sessions.send(sessionId, { text: '看看这份手册', attachments: files.map(f => f.id) })
+const { prompts } = await client.sessions.mcpPrompts(sessionId)
+const { text, attachments } = await client.sessions.getMcpPrompt(sessionId, 'docs', 'weekly', { week: '44' })
+```
+
 ## 6. 外观
 
 - 亮色/暗色默认跟随系统;在 `<html>` 上设置 `data-theme="light"` 或 `"dark"` 可以强制指定。

+ 12 - 0
examples/mcp-reference-server/README.md

@@ -0,0 +1,12 @@
+# 参考 MCP 服务
+
+按《MCP 接入指南》([docs/guides/mcp-guide.md](../../docs/guides/mcp-guide.md))编写的示例:一个虚构的订单系统,演示 AgentServer 支持的 MCP 能力。工具提示、结构化输出、长任务(含任务中询问)、表单与网址询问、资源与资源模板、提示模板、工具列表变化都有示例。
+
+```bash
+pnpm install   # 或 npm install
+PORT=8790 REF_TOKEN=换成随机字符串 node server.mjs
+```
+
+在 AgentServer 后台"MCP 服务"中添加:远程 HTTP,地址 `http://<主机>:8790/mcp`,请求头 `Authorization: Bearer <REF_TOKEN>`。各工具的用途和试用方法见指南 §12。
+
+数据在内存中,重启后恢复初始状态;长任务也保存在内存中,只用于演示,正式服务要持久化(指南 §5.3)。

+ 13 - 0
examples/mcp-reference-server/package.json

@@ -0,0 +1,13 @@
+{
+  "name": "agent-server-mcp-reference",
+  "private": true,
+  "type": "module",
+  "description": "按《MCP 接入指南》编写的参考 MCP 服务:工具提示、结构化输出、长任务、询问用户、资源、提示模板、工具列表变化",
+  "scripts": {
+    "start": "node server.mjs"
+  },
+  "dependencies": {
+    "@modelcontextprotocol/sdk": "1.32.1",
+    "zod": "^4.6.7"
+  }
+}

+ 276 - 0
examples/mcp-reference-server/server.mjs

@@ -0,0 +1,276 @@
+// 参考 MCP 服务:按《MCP 接入指南》(docs/guides/mcp-guide.md)编写,演示 AgentServer 支持的各项 MCP 能力。
+// 业务是一个虚构的"订单系统",数据在内存中,重启后恢复初始状态。
+//
+//   PORT=8790 REF_TOKEN=secret node server.mjs
+//
+// 在 AgentServer 后台"MCP 服务"中添加:远程 HTTP,地址 http://<主机>:8790/mcp,请求头 Authorization: Bearer secret
+import crypto from 'node:crypto'
+import http from 'node:http'
+import { z } from 'zod'
+import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
+import { InMemoryTaskMessageQueue, InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js'
+
+const PORT = Number(process.env.PORT ?? 8790)
+const HOST = process.env.HOST ?? '127.0.0.1'
+const TOKEN = process.env.REF_TOKEN ?? 'ref-secret'
+
+// ---- 业务数据 ----
+const orders = new Map([
+  ['A1001', { id: 'A1001', customer: '张三', amount: 1299, status: 'paid', items: ['储能电池 5kWh'] }],
+  ['A1002', { id: 'A1002', customer: '李四', amount: 399, status: 'shipped', items: ['智能网关'] }],
+])
+const notes = []
+
+/**
+ * 长任务的存储:所有连接共用。
+ * 指南 §5.3:任务要按认证身份归属,不要绑定在 MCP 会话上,否则平台重连(或重启)后查不到。
+ * 这里只有一个令牌,所以直接共用;有多个调用方时,按令牌区分。
+ */
+class SharedTaskStore extends InMemoryTaskStore {
+  createTask(params, requestId, request) { return super.createTask(params, requestId, request, undefined) }
+  getTask(taskId) { return super.getTask(taskId, undefined) }
+  storeTaskResult(taskId, status, result) { return super.storeTaskResult(taskId, status, result, undefined) }
+  getTaskResult(taskId) { return super.getTaskResult(taskId, undefined) }
+  updateTaskStatus(taskId, status, message) { return super.updateTaskStatus(taskId, status, message, undefined) }
+  listTasks(cursor) { return super.listTasks(cursor, undefined) }
+}
+const taskStore = new SharedTaskStore()
+const taskQueue = new InMemoryTaskMessageQueue()
+
+let betaEnabled = false
+
+function buildServer() {
+  const server = new McpServer(
+    { name: 'order-reference', version: '1.0.0' },
+    {
+      capabilities: { tasks: { requests: { tools: { call: {} } }, cancel: {}, list: {} } },
+      taskStore,
+      taskMessageQueue: taskQueue,
+      // 写给模型看的服务说明:什么时候用哪个工具
+      instructions: '订单系统。查订单用 get_order;导出报表很慢,用 export_report(长任务);删除类操作会要求确认。',
+    },
+  )
+
+  // ---- 1. 只读工具 + 结构化输出(指南 §3、§4) ----
+  server.registerTool('get_order', {
+    title: '查询订单',
+    description: '按订单号查询订单的客户、金额、状态和商品。订单号形如 A1001。',
+    inputSchema: { orderId: z.string().regex(/^A\d{4}$/).describe('订单号,例如 A1001') },
+    outputSchema: { id: z.string(), customer: z.string(), amount: z.number(), status: z.enum(['paid', 'shipped', 'cancelled']), items: z.array(z.string()) },
+    annotations: { readOnlyHint: true, openWorldHint: false },
+  }, async ({ orderId }) => {
+    const o = orders.get(orderId)
+    // 错误写给模型看:说清楚原因和下一步怎么做(指南 §3.4)
+    if (!o) return { isError: true, content: [{ type: 'text', text: `订单 ${orderId} 不存在。可以先用 list_orders 查看现有订单号。` }] }
+    // 结构化结果同时序列化成一段文本,兼容只看文本的客户端
+    return { content: [{ type: 'text', text: JSON.stringify(o) }], structuredContent: o }
+  })
+
+  server.registerTool('list_orders', {
+    title: '订单列表',
+    description: '分页列出订单(每页最多 20 条),可以按状态筛选。',
+    inputSchema: { status: z.enum(['paid', 'shipped', 'cancelled']).optional(), cursor: z.string().optional().describe('上一页返回的 nextCursor') },
+    annotations: { readOnlyHint: true, openWorldHint: false },
+  }, async ({ status, cursor }) => {
+    const all = [...orders.values()].filter(o => !status || o.status === status)
+    const start = cursor ? Number(cursor) : 0
+    const page = all.slice(start, start + 20)
+    const next = start + 20 < all.length ? String(start + 20) : null
+    const structuredContent = { orders: page.map(o => ({ id: o.id, customer: o.customer, status: o.status })), nextCursor: next }
+    return { content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent }
+  })
+
+  // ---- 2. 写操作与提示(指南 §3.3) ----
+  server.registerTool('add_note', {
+    title: '添加备注',
+    description: '给订单添加一条备注。每次调用都会新增一条(不幂等)。',
+    inputSchema: { orderId: z.string(), text: z.string().max(500) },
+    annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
+  }, async ({ orderId, text }) => {
+    notes.push({ orderId, text, at: new Date().toISOString() })
+    return { content: [{ type: 'text', text: `已为 ${orderId} 添加备注(共 ${notes.filter(n => n.orderId === orderId).length} 条)` }] }
+  })
+
+  server.registerTool('cancel_order', {
+    title: '取消订单',
+    description: '取消订单。已取消的订单再次取消不会有变化(幂等)。这是破坏性操作,平台会要求用户确认。',
+    inputSchema: { orderId: z.string(), reason: z.string().describe('取消原因') },
+    annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
+  }, async ({ orderId, reason }) => {
+    const o = orders.get(orderId)
+    if (!o) return { isError: true, content: [{ type: 'text', text: `订单 ${orderId} 不存在` }] }
+    o.status = 'cancelled'
+    return { content: [{ type: 'text', text: `订单 ${orderId} 已取消,原因:${reason}` }] }
+  })
+
+  // ---- 3. 调用过程中询问用户(指南 §6) ----
+  server.registerTool('ship_order', {
+    title: '安排发货',
+    description: '为订单安排发货。会请用户填写收件信息。',
+    inputSchema: { orderId: z.string() },
+    annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
+  }, async ({ orderId }) => {
+    const r = await server.server.elicitInput({
+      mode: 'form',
+      message: `请填写订单 ${orderId} 的收件信息`,
+      requestedSchema: {
+        type: 'object',
+        properties: {
+          name: { type: 'string', title: '收件人', minLength: 1 },
+          phone: { type: 'string', title: '手机号', minLength: 11, maxLength: 11 },
+          address: { type: 'string', title: '地址' },
+          speed: { type: 'string', title: '配送方式', oneOf: [{ const: 'standard', title: '普通' }, { const: 'express', title: '加急' }], default: 'standard' },
+          insured: { type: 'boolean', title: '保价', default: false },
+        },
+        required: ['name', 'phone', 'address'],
+      },
+    })
+    // 三种结果都要处理:提交、拒绝、关闭
+    if (r.action === 'accept') return { content: [{ type: 'text', text: `已安排发货:${JSON.stringify(r.content)}` }] }
+    if (r.action === 'decline') return { content: [{ type: 'text', text: '用户拒绝提供收件信息,未发货' }] }
+    return { content: [{ type: 'text', text: '用户暂未处理,未发货。可以稍后再试' }] }
+  })
+
+  server.registerTool('connect_crm', {
+    title: '连接 CRM',
+    description: '连接外部 CRM。需要用户在浏览器中授权(网址模式),不在对话中索要密码。',
+    inputSchema: {},
+    annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
+  }, async () => {
+    const r = await server.server.elicitInput({ mode: 'url', message: '请在打开的页面中登录 CRM 并授权订单系统访问', url: 'https://crm.example.com/oauth/authorize?client=orders', elicitationId: crypto.randomUUID() })
+    return { content: [{ type: 'text', text: r.action === 'accept' ? '用户已完成授权' : '用户没有授权' }] }
+  })
+
+  // ---- 4. 长任务(指南 §5) ----
+  server.experimental.tasks.registerToolTask('export_report', {
+    title: '导出销售报表',
+    description: '导出指定月份的销售报表。很慢(演示中按 seconds 参数计时),以长任务方式执行,完成后平台会把结果发给你。',
+    inputSchema: { month: z.string().regex(/^\d{4}-\d{2}$/).describe('月份,例如 2026-10'), seconds: z.number().int().min(1).max(7200).default(30).describe('演示用:执行多少秒') },
+    execution: { taskSupport: 'optional' },
+    annotations: { readOnlyHint: true },
+  }, {
+    async createTask({ month, seconds }, { taskStore: store, taskRequestedTtl }) {
+      const task = await store.createTask({ ttl: taskRequestedTtl ?? 24 * 3600_000, pollInterval: 5000 })
+      const started = Date.now()
+      const timer = setInterval(() => {
+        const pct = Math.min(99, Math.round(((Date.now() - started) / (seconds * 1000)) * 100))
+        void taskStore.updateTaskStatus(task.taskId, 'working', `已完成 ${pct}%`).catch(() => clearInterval(timer))
+      }, 5000)
+      setTimeout(() => {
+        clearInterval(timer)
+        void taskStore.storeTaskResult(task.taskId, 'completed', {
+          content: [
+            { type: 'text', text: `${month} 销售报表已生成:订单 ${orders.size} 笔` },
+            // 文件以嵌入资源返回:平台保存到会话工作区,不放进模型上下文(指南 §4.3)
+            { type: 'resource', resource: { uri: `ref://reports/sales-${month}.csv`, mimeType: 'text/csv', text: ['id,customer,amount,status', ...[...orders.values()].map(o => `${o.id},${o.customer},${o.amount},${o.status}`)].join('\n') } },
+          ],
+        }).catch(() => {})
+      }, seconds * 1000)
+      // 给模型的即时说明:提交后马上返回
+      return { task, _meta: { 'io.modelcontextprotocol/model-immediate-response': `${month} 报表已开始导出,预计 ${seconds} 秒` } }
+    },
+    getTask: async (_args, { taskId }) => taskStore.getTask(taskId),
+    getTaskResult: async (_args, { taskId }) => taskStore.getTaskResult(taskId),
+  })
+
+  server.experimental.tasks.registerToolTask('purge_cancelled', {
+    title: '清理已取消订单',
+    description: '永久删除所有已取消的订单。执行中会请用户确认(长任务中的询问)。',
+    inputSchema: {},
+    execution: { taskSupport: 'required' },
+    annotations: { readOnlyHint: false, destructiveHint: true },
+  }, {
+    async createTask(_args, { taskStore: store, taskRequestedTtl }) {
+      const task = await store.createTask({ ttl: taskRequestedTtl ?? 3600_000, pollInterval: 2000 })
+      void (async () => {
+        const victims = [...orders.values()].filter(o => o.status === 'cancelled').map(o => o.id)
+        await taskStore.updateTaskStatus(task.taskId, 'input_required', '等待用户确认')
+        let answer
+        for await (const m of server.server.experimental.tasks.elicitInputStream(
+          { mode: 'form', message: `将永久删除 ${victims.length} 个已取消订单:${victims.join('、') || '(无)'}。确认吗?`, requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean', title: '确认删除' } }, required: ['confirm'] } },
+          { relatedTask: { taskId: task.taskId } },
+        )) if (m.type === 'result') answer = m.result
+        const ok = answer?.action === 'accept' && answer.content?.confirm === true
+        if (ok) for (const id of victims) orders.delete(id)
+        await taskStore.storeTaskResult(task.taskId, 'completed', { content: [{ type: 'text', text: ok ? `已删除 ${victims.length} 个订单` : '用户没有确认,未删除' }] })
+      })().catch(err => taskStore.storeTaskResult(task.taskId, 'failed', { isError: true, content: [{ type: 'text', text: String(err) }] }).catch(() => {}))
+      return { task }
+    },
+    getTask: async (_args, { taskId }) => taskStore.getTask(taskId),
+    getTaskResult: async (_args, { taskId }) => taskStore.getTaskResult(taskId),
+  })
+
+  // ---- 5. 工具列表变化(指南 §7) ----
+  server.registerTool('enable_beta', {
+    title: '开启测试功能',
+    description: '开启测试功能后会多出一个 beta_forecast 工具(演示工具列表变化通知)。',
+    inputSchema: {},
+    annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false },
+  }, async () => {
+    betaEnabled = true
+    for (const s of sessions.values()) s.addBeta()
+    return { content: [{ type: 'text', text: '已开启,工具列表已更新' }] }
+  })
+  const addBeta = () => {
+    if (server._registeredTools?.beta_forecast) return
+    server.registerTool('beta_forecast', { title: '销量预测(测试)', description: '预测下个月的销量(测试功能)', inputSchema: {}, annotations: { readOnlyHint: true } },
+      async () => ({ content: [{ type: 'text', text: '预计下月订单 120 笔' }] }))
+  }
+  if (betaEnabled) addBeta()
+
+  // ---- 6. 资源与资源模板(指南 §8) ----
+  server.registerResource('api-guide', 'ref://docs/order-api.md', {
+    title: '订单系统接口说明', description: '订单状态的含义、退款规则', mimeType: 'text/markdown',
+  }, async uri => ({
+    contents: [{ uri: uri.href, mimeType: 'text/markdown', text: '# 订单状态\n- paid:已支付,未发货\n- shipped:已发货\n- cancelled:已取消\n\n# 退款规则\n发货前全额退款;发货后 7 天内可退。' }],
+  }))
+  server.registerResource('order', new ResourceTemplate('ref://orders/{orderId}', {
+    list: async () => ({ resources: [...orders.values()].map(o => ({ uri: `ref://orders/${o.id}`, name: o.id, title: `订单 ${o.id}`, mimeType: 'application/json' })) }),
+  }), { title: '订单详情', description: '单个订单的完整数据(JSON)', mimeType: 'application/json' }, async (uri, { orderId }) => {
+    const o = orders.get(String(orderId))
+    if (!o) throw new Error(`订单 ${orderId} 不存在`)
+    return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ ...o, notes: notes.filter(n => n.orderId === o.id) }, null, 2) }] }
+  })
+
+  // ---- 7. 提示模板(指南 §8) ----
+  server.registerPrompt('order_review', {
+    title: '订单复盘',
+    description: '针对一个订单写复盘:时间线、问题、改进建议',
+    argsSchema: { orderId: z.string().describe('订单号'), focus: z.string().optional().describe('关注点(可选)') },
+  }, async ({ orderId, focus }) => ({
+    messages: [
+      { role: 'user', content: { type: 'text', text: `请对订单 ${orderId} 做一次复盘,列出时间线、发现的问题和改进建议${focus ? `,重点关注:${focus}` : ''}。` } },
+      { role: 'user', content: { type: 'resource', resource: { uri: `ref://orders/${orderId}`, mimeType: 'application/json', text: JSON.stringify(orders.get(orderId) ?? {}, null, 2) } } },
+    ],
+  }))
+
+  return { server, addBeta }
+}
+
+// ---- HTTP:Streamable HTTP,有状态(询问用户需要在同一个 MCP 会话中收发) ----
+const sessions = new Map()
+const httpServer = http.createServer(async (req, res) => {
+  const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
+  if (url.pathname !== '/mcp') return res.writeHead(404).end()
+  // 认证(指南 §9):每个请求都检查令牌
+  if (req.headers.authorization !== `Bearer ${TOKEN}`) return res.writeHead(401, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'unauthorized' }))
+  let body = ''
+  for await (const chunk of req) body += chunk
+  const sid = req.headers['mcp-session-id']
+  let s = sid ? sessions.get(sid) : undefined
+  if (!s) {
+    if (sid) return res.writeHead(404).end() // 会话已失效:客户端会重新初始化
+    const { server, addBeta } = buildServer()
+    const transport = new StreamableHTTPServerTransport({
+      sessionIdGenerator: () => crypto.randomUUID(),
+      onsessioninitialized: id => sessions.set(id, s),
+    })
+    transport.onclose = () => { if (transport.sessionId) sessions.delete(transport.sessionId) }
+    s = { server, transport, addBeta }
+    await server.connect(transport)
+  }
+  await s.transport.handleRequest(req, res, body ? JSON.parse(body) : undefined)
+})
+
+httpServer.listen(PORT, HOST, () => console.log(`MCP 参考服务:http://${HOST}:${PORT}/mcp(Authorization: Bearer ${TOKEN === 'ref-secret' ? 'ref-secret' : '***'})`))

+ 3 - 3
examples/platform-demo/package.json

@@ -9,9 +9,9 @@
     "typecheck": "vue-tsc --noEmit"
   },
   "dependencies": {
-    "@agent-server/chat-ui": "AGENT_SERVER_URL/sdk/agent-server-chat-ui-0.5.0.tgz",
-    "@agent-server/client": "AGENT_SERVER_URL/sdk/agent-server-client-0.5.0.tgz",
-    "@agent-server/shared": "AGENT_SERVER_URL/sdk/agent-server-shared-0.5.0.tgz",
+    "@agent-server/chat-ui": "AGENT_SERVER_URL/sdk/agent-server-chat-ui-0.6.0.tgz",
+    "@agent-server/client": "AGENT_SERVER_URL/sdk/agent-server-client-0.6.0.tgz",
+    "@agent-server/shared": "AGENT_SERVER_URL/sdk/agent-server-shared-0.6.0.tgz",
     "vue": "^3.5.44"
   },
   "devDependencies": {

+ 1 - 1
packages/chat-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/chat-ui",
-  "version": "0.5.0",
+  "version": "0.6.0",
   "private": true,
   "type": "module",
   "exports": {

+ 5 - 1
packages/chat-ui/src/components/AgentChat.vue

@@ -6,6 +6,7 @@ import { downloadFile, provideClient } from '../context'
 import type { ChatSession } from '../store'
 import BackgroundBar from './BackgroundBar.vue'
 import Composer from './Composer.vue'
+import McpPicker from './McpPicker.vue'
 import ScheduleBar from './ScheduleBar.vue'
 import Timeline from './Timeline.vue'
 
@@ -89,7 +90,10 @@ defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => com
         ref="composer" :running="running" :placeholder="placeholder" :upload="session.upload"
         @send="onSend" @interrupt="session.interrupt()"
       >
-        <template #toolbar><slot name="toolbar" /></template>
+        <template #toolbar>
+          <McpPicker :session="session" @attach="f => composer?.attach(f)" @insert="(t, f) => { composer?.setText(t); composer?.attach(f); composer?.focus() }" />
+          <slot name="toolbar" />
+        </template>
       </Composer>
     </div>
   </div>

+ 6 - 1
packages/chat-ui/src/components/Composer.vue

@@ -111,7 +111,12 @@ onBeforeUnmount(() => {
   for (const f of files.value) if (f.preview) URL.revokeObjectURL(f.preview)
 })
 
-defineExpose({ focus: () => input.value?.focus(), setText: (t: string) => { text.value = t; void nextTick(autosize) } })
+/** 加入已经保存好的文件(例如附加的 MCP 资源) */
+function attach(list: FileDto[]) {
+  files.value.push(...list.map(f => ({ key: ++seq, name: f.path.split('/').pop() ?? f.path, size: f.size, preview: null, status: 'done' as const, file: f })))
+}
+
+defineExpose({ focus: () => input.value?.focus(), setText: (t: string) => { text.value = t; void nextTick(autosize) }, attach })
 </script>
 
 <template>

+ 157 - 2
packages/chat-ui/src/components/InteractionCard.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import { computed, nextTick, onMounted, reactive, ref } from 'vue'
-import type { InteractionResponse } from '@agent-server/shared'
+import type { InteractionFormField, InteractionFormValue, InteractionResponse } from '@agent-server/shared'
 import type { TimelineItem } from '../store'
 import { stringifyInput, toolLabel } from '../util'
 
@@ -20,6 +20,7 @@ const active = computed(() => !props.item.resolution && !props.readonly && !!pro
 const req = computed(() => props.item.request)
 const approval = computed(() => (req.value.kind === 'approval' ? req.value : null))
 const question = computed(() => (req.value.kind === 'question' ? req.value : null))
+const form = computed(() => (req.value.kind === 'form' ? req.value : null))
 
 const detail = computed(() => {
   const a = approval.value
@@ -30,6 +31,60 @@ const detail = computed(() => {
 const RESOLUTION_TEXT: Record<string, string> = {
   allowed: '已允许', denied: '已拒绝', answered: '已回答', timeout: '已超时(按拒绝处理)', cancelled: '已取消',
 }
+const resolutionText = computed(() => {
+  const r = props.item.resolution
+  if (!r) return ''
+  if (form.value) return ({ answered: form.value.mode === 'url' ? '已完成' : '已提交', denied: '已拒绝', timeout: '已超时(未处理)', cancelled: '已关闭' } as Record<string, string>)[r] ?? r
+  return RESOLUTION_TEXT[r]
+})
+
+// 表单询问(外部服务通过 MCP 询问):按字段填写,默认值预先填好
+const values = reactive<Record<string, InteractionFormValue | undefined>>({})
+for (const f of form.value?.fields ?? []) {
+  if (f.default !== undefined) values[f.name] = Array.isArray(f.default) ? [...f.default] : f.default
+  else if (f.type === 'choice' && f.multiple) values[f.name] = []
+}
+const urlHost = computed(() => {
+  try {
+    return form.value?.url ? new URL(form.value.url).host : ''
+  } catch {
+    return ''
+  }
+})
+const canSubmitForm = computed(() => form.value?.fields.every(f => !f.required || isFilled(values[f.name])) ?? false)
+function isFilled(v: InteractionFormValue | undefined) {
+  return v !== undefined && v !== '' && !(Array.isArray(v) && !v.length)
+}
+function toggleMulti(name: string, value: string) {
+  const cur = (values[name] as string[] | undefined) ?? []
+  values[name] = cur.includes(value) ? cur.filter(v => v !== value) : [...cur, value]
+}
+function submitForm() {
+  const f = form.value
+  if (!f) return
+  if (f.mode === 'url') return void send({ action: 'accept' })
+  const content: Record<string, InteractionFormValue> = {}
+  for (const field of f.fields) {
+    const v = values[field.name]
+    if (!isFilled(v)) continue
+    content[field.name] = (field.type === 'number' || field.type === 'integer') && typeof v === 'string' ? Number(v) : v!
+  }
+  void send({ action: 'accept', content })
+}
+function shownValue(field: InteractionFormField, v: InteractionFormValue | undefined): string {
+  if (v === undefined) return '—'
+  if (field.type === 'boolean') return v ? '是' : '否'
+  if (field.type === 'choice') {
+    const label = (x: string) => field.options.find(o => o.value === x)?.label ?? x
+    return Array.isArray(v) ? v.map(label).join('、') || '—' : label(String(v))
+  }
+  return String(v)
+}
+const FORMAT_INPUT = { email: 'email', uri: 'url', date: 'date', 'date-time': 'datetime-local' } as const
+function inputType(f: InteractionFormField) {
+  if (f.type === 'number' || f.type === 'integer') return 'number'
+  return f.type === 'string' && f.format ? FORMAT_INPUT[f.format] : 'text'
+}
 
 // 提问:每个问题的选择与"其他"输入
 const answers = reactive<Record<string, string[]>>({})
@@ -155,7 +210,61 @@ onMounted(() => {
       </div>
     </template>
 
-    <div v-if="item.resolution" class="resolved" :class="item.resolution">{{ RESOLUTION_TEXT[item.resolution] }}</div>
+    <template v-else-if="form">
+      <div class="header">
+        <span class="codicon" :class="form.mode === 'url' ? 'codicon-link-external' : 'codicon-edit'" />
+        {{ form.mode === 'url' ? `「${form.source}」请你打开网址完成操作` : `「${form.source}」需要你提供信息` }}
+      </div>
+      <div v-if="form.message" class="desc form-message">{{ form.message }}</div>
+      <template v-if="form.mode === 'url'">
+        <div class="url-box">
+          <div class="url-host">{{ urlHost }}</div>
+          <div class="url-full">{{ form.url }}</div>
+        </div>
+        <div v-if="active" class="buttons row">
+          <a class="submit link-btn" :href="form.url" target="_blank" rel="noopener noreferrer"><span class="codicon codicon-link-external" />打开网址</a>
+          <button type="button" class="opt-inline" :disabled="submitting" @click="submitForm">我已完成</button>
+          <button type="button" class="opt-inline" :disabled="submitting" @click="send({ action: 'decline' })">拒绝</button>
+        </div>
+        <div v-if="active" class="hint">请确认网址的域名可信再打开;完成后点"我已完成"。</div>
+      </template>
+      <template v-else>
+        <form v-if="active" class="itx-fields" @submit.prevent="submitForm">
+          <div v-for="f in form.fields" :key="f.name" class="itx-field">
+            <label class="itx-label" :for="`${item.id}-${f.name}`">{{ f.title }}<span v-if="f.required" class="req">*</span></label>
+            <div v-if="f.description" class="q-desc">{{ f.description }}</div>
+            <label v-if="f.type === 'boolean'" class="check">
+              <input :id="`${item.id}-${f.name}`" type="checkbox" :checked="values[f.name] === true" @change="values[f.name] = ($event.target as HTMLInputElement).checked">是
+            </label>
+            <select v-else-if="f.type === 'choice' && !f.multiple" :id="`${item.id}-${f.name}`" v-model="values[f.name]" class="f-input">
+              <option :value="undefined">请选择</option>
+              <option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</option>
+            </select>
+            <div v-else-if="f.type === 'choice'" :id="`${item.id}-${f.name}`" class="q-options">
+              <button v-for="o in f.options" :key="o.value" type="button" class="q-opt" :class="{ picked: (values[f.name] as string[] | undefined)?.includes(o.value) }" @click="toggleMulti(f.name, o.value)">
+                <span class="codicon" :class="(values[f.name] as string[] | undefined)?.includes(o.value) ? 'codicon-pass-filled' : 'codicon-circle-large'" />
+                <span class="q-label">{{ o.label }}</span>
+              </button>
+            </div>
+            <input
+              v-else :id="`${item.id}-${f.name}`" v-model="values[f.name]" class="f-input" :type="inputType(f)"
+              :min="f.type !== 'string' ? f.minimum : undefined" :max="f.type !== 'string' ? f.maximum : undefined" :step="f.type === 'integer' ? 1 : f.type === 'number' ? 'any' : undefined"
+              :minlength="f.type === 'string' ? f.minLength : undefined" :maxlength="f.type === 'string' ? f.maxLength : undefined"
+            >
+          </div>
+          <div class="buttons row">
+            <button type="submit" class="submit" :disabled="!canSubmitForm || submitting">提交</button>
+            <button type="button" class="opt-inline" :disabled="submitting" @click="send({ action: 'decline' })">拒绝</button>
+            <button type="button" class="opt-inline" :disabled="submitting" @click="send({ action: 'cancel' })">暂不处理</button>
+          </div>
+        </form>
+        <div v-else-if="item.content" class="itx-fields done-values">
+          <div v-for="f in form.fields" :key="f.name" class="kv"><span class="k">{{ f.title }}</span><span>{{ shownValue(f, item.content[f.name]) }}</span></div>
+        </div>
+      </template>
+    </template>
+
+    <div v-if="item.resolution" class="resolved" :class="item.resolution">{{ resolutionText }}</div>
     <div v-if="error" class="err">{{ error }}</div>
   </div>
 </template>
@@ -267,4 +376,50 @@ onMounted(() => {
 .resolved.allowed, .resolved.answered { color: var(--app-success); }
 .resolved.denied, .resolved.timeout { color: var(--app-danger); }
 .err { font-size: 12px; color: var(--app-error-foreground); }
+.form-message { white-space: pre-wrap; }
+/* 类名带前缀:后台的全局样式中有 field(固定高度)等通用类,同名会把字段压扁 */
+.itx-fields { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
+.itx-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
+/* 表单输入框:纵向排列时不能用 flex: 1(会把高度压扁、控件重叠) */
+.f-input {
+  box-sizing: border-box;
+  width: 100%;
+  max-width: 100%;
+  height: 28px;
+  padding: 0 8px;
+  border: 1px solid var(--app-input-border);
+  border-radius: var(--corner-radius-small);
+  background: var(--app-input-background);
+  color: inherit;
+  font: inherit;
+  outline: none;
+}
+.f-input:focus { border-color: var(--app-input-active-border); }
+.q-options > .deny-input { flex: none; box-sizing: border-box; width: 100%; }
+.itx-field .q-desc { overflow-wrap: anywhere; }
+.itx-label { font-weight: 500; }
+.req { color: var(--app-danger); margin-left: 2px; }
+.check { display: flex; align-items: center; gap: 6px; cursor: pointer; }
+.buttons.row { flex-direction: row; flex-wrap: wrap; align-items: center; gap: 6px; }
+.opt-inline {
+  height: 26px;
+  padding: 0 10px;
+  border: 1px solid var(--app-input-border);
+  border-radius: var(--corner-radius-small);
+  background: transparent;
+  cursor: pointer;
+}
+.opt-inline:hover:not(:disabled) { background: var(--app-list-hover-background); }
+.link-btn { display: inline-flex; align-items: center; gap: 4px; text-decoration: none; line-height: 26px; }
+.url-box {
+  padding: 6px 8px;
+  background: var(--app-code-background);
+  border-radius: var(--corner-radius-medium);
+  word-break: break-all;
+}
+.url-host { font-weight: 700; }
+.url-full { font-size: 12px; color: var(--app-secondary-foreground); font-family: var(--app-monospace-font-family); }
+.done-values { gap: 2px; font-size: 12px; }
+.kv { display: flex; gap: 8px; }
+.kv .k { color: var(--app-secondary-foreground); min-width: 72px; }
 </style>

+ 285 - 0
packages/chat-ui/src/components/McpPicker.vue

@@ -0,0 +1,285 @@
+<script setup lang="ts">
+// 输入框的"资料"与"快捷指令"(docs/design/16-mcp-support.md §6):MCP 服务提供的资源可以附加到消息,
+// 提示模板填好参数后放进输入框,由用户修改后发送
+import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
+import type { FileDto, McpPromptDto, McpPromptListDto, McpResourceDto, McpResourceListDto, McpResourceTemplateDto } from '@agent-server/shared'
+import type { ChatSession } from '../store'
+import { formatSize } from '../util'
+
+const props = defineProps<{ session: ChatSession }>()
+const emit = defineEmits<{ attach: [files: FileDto[]]; insert: [text: string, files: FileDto[]] }>()
+
+const available = ref<{ resources: boolean; prompts: boolean }>({ resources: false, prompts: false })
+const open = ref<'resources' | 'prompts' | null>(null)
+const loading = ref(false)
+const error = ref<string | null>(null)
+const busyKey = ref<string | null>(null)
+const filter = ref('')
+const resources = ref<McpResourceListDto | null>(null)
+const prompts = ref<McpPromptListDto | null>(null)
+const root = ref<HTMLElement>()
+
+/** 正在填写的资源模板或提示模板 */
+const editing = ref<{ kind: 'template'; item: McpResourceTemplateDto } | { kind: 'prompt'; item: McpPromptDto } | null>(null)
+const params = reactive<Record<string, string>>({})
+
+onMounted(async () => {
+  try {
+    available.value = await props.session.mcp.available()
+  } catch {
+    // 拿不到时不显示
+  }
+  document.addEventListener('mousedown', onOutside)
+})
+onBeforeUnmount(() => document.removeEventListener('mousedown', onOutside))
+
+function onOutside(e: MouseEvent) {
+  if (open.value && root.value && !root.value.contains(e.target as Node)) close()
+}
+
+function close() {
+  open.value = null
+  editing.value = null
+  error.value = null
+}
+
+async function toggle(kind: 'resources' | 'prompts') {
+  if (open.value === kind) return close()
+  open.value = kind
+  editing.value = null
+  filter.value = ''
+  error.value = null
+  loading.value = true
+  try {
+    if (kind === 'resources') resources.value = await props.session.mcp.resources()
+    else prompts.value = await props.session.mcp.prompts()
+  } catch (err) {
+    error.value = (err as Error).message
+  } finally {
+    loading.value = false
+  }
+}
+
+const match = (...xs: (string | null)[]) => {
+  const q = filter.value.trim().toLowerCase()
+  return !q || xs.some(x => x?.toLowerCase().includes(q))
+}
+const shownResources = computed(() => resources.value?.resources.filter(r => match(r.title, r.name, r.uri, r.description, r.serverTitle)) ?? [])
+const shownTemplates = computed(() => resources.value?.templates.filter(r => match(r.title, r.name, r.uriTemplate, r.description, r.serverTitle)) ?? [])
+const shownPrompts = computed(() => prompts.value?.prompts.filter(p => match(p.title, p.name, p.description, p.serverTitle)) ?? [])
+const errors = computed(() => (open.value === 'resources' ? resources.value?.errors : prompts.value?.errors) ?? [])
+
+/** 资源模板中的参数:{name}、{+path} 等 */
+const templateVars = (t: string) => [...new Set([...t.matchAll(/\{[+#./;?&]?([^}]+)\}/g)].flatMap(m => m[1]!.split(',').map(v => v.replace(/\*$|:\d+$/, '').trim())))]
+const expand = (t: string) => t.replace(/\{([+#./;?&]?)([^}]+)\}/g, (_m, op: string, names: string) => names.split(',').map(n => {
+  const v = params[n.replace(/\*$|:\d+$/, '').trim()] ?? ''
+  return op === '+' || op === '#' ? encodeURI(v) : encodeURIComponent(v)
+}).join(','))
+
+function startEdit(e: NonNullable<typeof editing.value>) {
+  for (const k of Object.keys(params)) delete params[k]
+  editing.value = e
+}
+
+async function attach(server: string, uri: string, key: string) {
+  busyKey.value = key
+  error.value = null
+  try {
+    emit('attach', await props.session.mcp.attach(server, uri))
+    close()
+  } catch (err) {
+    error.value = (err as Error).message
+  } finally {
+    busyKey.value = null
+  }
+}
+
+async function usePrompt(p: McpPromptDto) {
+  busyKey.value = `${p.serverId}:${p.name}`
+  error.value = null
+  try {
+    const args = Object.fromEntries(Object.entries(params).filter(([, v]) => v.trim() !== ''))
+    const r = await props.session.mcp.prompt(p.serverId, p.name, args)
+    emit('insert', r.text, r.attachments)
+    close()
+  } catch (err) {
+    error.value = (err as Error).message
+  } finally {
+    busyKey.value = null
+  }
+}
+
+function pickPrompt(p: McpPromptDto) {
+  if (p.arguments.length) startEdit({ kind: 'prompt', item: p })
+  else void usePrompt(p)
+}
+
+const canSubmit = computed(() => {
+  const e = editing.value
+  if (!e) return false
+  if (e.kind === 'template') return templateVars(e.item.uriTemplate).every(v => (params[v] ?? '').trim())
+  return e.item.arguments.every(a => !a.required || (params[a.name] ?? '').trim())
+})
+</script>
+
+<template>
+  <div v-if="available.resources || available.prompts" ref="root" class="mcp-picker">
+    <button v-if="available.resources" type="button" class="icon-btn" :class="{ on: open === 'resources' }" title="附加 MCP 服务提供的资料" @click="toggle('resources')">
+      <span class="codicon codicon-library" />
+    </button>
+    <button v-if="available.prompts" type="button" class="icon-btn" :class="{ on: open === 'prompts' }" title="快捷指令(MCP 服务提供的提示模板)" @click="toggle('prompts')">
+      <span class="codicon codicon-symbol-snippet" />
+    </button>
+
+    <div v-if="open" class="panel" role="dialog" :aria-label="open === 'resources' ? '资料' : '快捷指令'">
+      <div class="panel-head">
+        <span class="title">{{ open === 'resources' ? '附加资料' : '快捷指令' }}</span>
+        <input v-if="!editing" v-model="filter" class="search" placeholder="搜索" aria-label="搜索">
+        <button type="button" class="icon-btn" aria-label="关闭" @click="close"><span class="codicon codicon-close" /></button>
+      </div>
+
+      <div v-if="loading" class="hint pad"><span class="codicon codicon-loading codicon-modifier-spin" /> 加载中…</div>
+
+      <!-- 填写参数 -->
+      <form v-else-if="editing" class="edit pad" @submit.prevent="editing.kind === 'template' ? attach(editing.item.serverId, expand(editing.item.uriTemplate), 'tpl') : usePrompt(editing.item)">
+        <div class="item-title">{{ editing.item.title ?? editing.item.name }}</div>
+        <div v-if="editing.item.description" class="desc">{{ editing.item.description }}</div>
+        <template v-if="editing.kind === 'template'">
+          <label v-for="v in templateVars(editing.item.uriTemplate)" :key="v" class="param">
+            <span>{{ v }}<span class="req">*</span></span>
+            <input v-model="params[v]" class="search">
+          </label>
+          <div class="mono hint">{{ expand(editing.item.uriTemplate) }}</div>
+        </template>
+        <template v-else>
+          <label v-for="a in editing.item.arguments" :key="a.name" class="param">
+            <span>{{ a.name }}<span v-if="a.required" class="req">*</span></span>
+            <input v-model="params[a.name]" class="search" :placeholder="a.description ?? ''">
+          </label>
+        </template>
+        <div class="row">
+          <button type="submit" class="primary" :disabled="!canSubmit || !!busyKey">{{ busyKey ? '处理中…' : editing.kind === 'template' ? '附加' : '放进输入框' }}</button>
+          <button type="button" class="plain" @click="editing = null">返回</button>
+        </div>
+      </form>
+
+      <div v-else class="list">
+        <template v-if="open === 'resources'">
+          <button v-for="r in shownResources" :key="`${r.serverId}:${r.uri}`" type="button" class="entry" :disabled="!!busyKey" @click="attach(r.serverId, r.uri, `${r.serverId}:${r.uri}`)">
+            <span class="codicon" :class="busyKey === `${r.serverId}:${r.uri}` ? 'codicon-loading codicon-modifier-spin' : 'codicon-file'" />
+            <span class="entry-body">
+              <span class="item-title">{{ r.title ?? r.name }}<span class="src">{{ r.serverTitle }}</span></span>
+              <span v-if="r.description" class="desc">{{ r.description }}</span>
+              <span class="mono hint">{{ r.uri }}<template v-if="r.size"> · {{ formatSize(r.size) }}</template></span>
+            </span>
+          </button>
+          <button v-for="t in shownTemplates" :key="`${t.serverId}:${t.uriTemplate}`" type="button" class="entry" @click="startEdit({ kind: 'template', item: t })">
+            <span class="codicon codicon-symbol-parameter" />
+            <span class="entry-body">
+              <span class="item-title">{{ t.title ?? t.name }}<span class="src">{{ t.serverTitle }} · 模板</span></span>
+              <span v-if="t.description" class="desc">{{ t.description }}</span>
+              <span class="mono hint">{{ t.uriTemplate }}</span>
+            </span>
+          </button>
+          <div v-if="!shownResources.length && !shownTemplates.length && !errors.length" class="hint pad">没有可附加的资料</div>
+          <div v-if="resources?.nextCursor" class="hint pad">只显示了前一部分</div>
+        </template>
+        <template v-else>
+          <button v-for="p in shownPrompts" :key="`${p.serverId}:${p.name}`" type="button" class="entry" :disabled="!!busyKey" @click="pickPrompt(p)">
+            <span class="codicon" :class="busyKey === `${p.serverId}:${p.name}` ? 'codicon-loading codicon-modifier-spin' : 'codicon-symbol-snippet'" />
+            <span class="entry-body">
+              <span class="item-title">{{ p.title ?? p.name }}<span class="src">{{ p.serverTitle }}</span></span>
+              <span v-if="p.description" class="desc">{{ p.description }}</span>
+              <span v-if="p.arguments.length" class="hint">参数:{{ p.arguments.map(a => a.name + (a.required ? '*' : '')).join('、') }}</span>
+            </span>
+          </button>
+          <div v-if="!shownPrompts.length && !errors.length" class="hint pad">没有快捷指令</div>
+        </template>
+      </div>
+      <div v-for="e in errors" :key="e.serverTitle" class="err pad">{{ e.serverTitle }}:{{ e.error }}</div>
+      <div v-if="error" class="err pad">{{ error }}</div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.mcp-picker { display: contents; }
+.icon-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 26px;
+  height: 26px;
+  border: none;
+  border-radius: 5px;
+  background: transparent;
+  color: var(--app-secondary-foreground);
+  cursor: pointer;
+}
+.icon-btn:hover, .icon-btn.on { background: var(--app-list-hover-background); color: var(--app-primary-foreground); }
+.panel {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: calc(100% + 6px);
+  z-index: 20;
+  display: flex;
+  flex-direction: column;
+  max-height: min(420px, 60vh);
+  border: 1px solid var(--app-input-border);
+  border-radius: var(--corner-radius-large);
+  background: var(--app-input-secondary-background);
+  box-shadow: var(--app-shadow);
+  overflow: hidden;
+}
+.panel-head { display: flex; align-items: center; gap: 8px; padding: 6px 6px 6px 12px; border-bottom: 1px solid var(--app-input-border); }
+.title { font-weight: 700; flex: none; }
+.search {
+  flex: 1;
+  min-width: 0;
+  height: 26px;
+  padding: 0 8px;
+  border: 1px solid var(--app-input-border);
+  border-radius: var(--corner-radius-small);
+  background: var(--app-input-background);
+  outline: none;
+}
+.search:focus { border-color: var(--app-input-active-border); }
+.list { overflow-y: auto; padding: 4px; }
+.entry {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+  width: 100%;
+  padding: 6px 8px;
+  border: none;
+  border-radius: var(--corner-radius-small);
+  background: transparent;
+  text-align: left;
+  cursor: pointer;
+}
+.entry:hover:not(:disabled) { background: var(--app-list-hover-background); }
+.entry .codicon { margin-top: 2px; color: var(--app-secondary-foreground); }
+.entry-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
+.item-title { font-weight: 500; }
+.src { margin-left: 8px; font-size: 11px; font-weight: 400; color: var(--app-secondary-foreground); }
+.desc { font-size: 12px; color: var(--app-secondary-foreground); }
+.hint { font-size: 12px; color: var(--app-secondary-foreground); }
+.mono { font-family: var(--app-monospace-font-family); overflow-wrap: anywhere; }
+.pad { padding: 8px 12px; }
+.edit { display: flex; flex-direction: column; gap: 8px; overflow-y: auto; }
+.param { display: flex; flex-direction: column; gap: 3px; font-size: 12px; }
+.param .search { flex: none; }
+.req { color: var(--app-danger); margin-left: 2px; }
+.row { display: flex; gap: 6px; }
+.primary, .plain {
+  height: 26px;
+  padding: 0 12px;
+  border-radius: var(--corner-radius-small);
+  cursor: pointer;
+}
+.primary { border: none; background: var(--app-button-background); color: var(--app-button-foreground); }
+.primary:disabled { opacity: 0.5; cursor: not-allowed; }
+.plain { border: 1px solid var(--app-input-border); background: transparent; }
+.err { font-size: 12px; color: var(--app-error-foreground); }
+</style>

+ 13 - 3
packages/chat-ui/src/store.ts

@@ -5,6 +5,7 @@ import type {
   AgentEvent,
   Attachment,
   FileDto,
+  InteractionFormValue,
   InteractionRequest,
   InteractionResolution,
   InteractionResponse,
@@ -18,7 +19,7 @@ export type TimelineItem =
   | { kind: 'text'; id: string; seq: number; text: string; streaming: boolean }
   | { kind: 'reasoning'; id: string; seq: number; text: string; streaming: boolean }
   | { kind: 'tool'; id: string; seq: number; name: string; title: string; input: unknown; status: ToolStatus; output: string; truncated: boolean }
-  | { kind: 'interaction'; id: string; seq: number; request: InteractionRequest; resolution: InteractionResolution | null; answers?: Record<string, string | string[]>; reason?: string }
+  | { kind: 'interaction'; id: string; seq: number; request: InteractionRequest; resolution: InteractionResolution | null; answers?: Record<string, string | string[]>; reason?: string; content?: Record<string, InteractionFormValue> }
   | { kind: 'file'; id: string; seq: number; fileId: string; path: string; change: 'created' | 'modified'; size: number; mime: string }
   | { kind: 'status'; id: string; seq: number; level: 'error' | 'warning'; message: string }
 
@@ -123,11 +124,12 @@ export function applyEvent(s: ChatState, e: AgentEvent) {
     }
     case 'interaction.required':
       s.items.push({ kind: 'interaction', id: e.payload.interactionId, seq: e.seq, request: e.payload, resolution: null })
-      s.runState = 'waiting_input'
+      // 不属于某一轮的交互(后台长任务中服务发来的询问)不改变运行状态
+      if (e.runId) s.runState = 'waiting_input'
       break
     case 'interaction.resolved': {
       const item = find('interaction', e.payload.interactionId)
-      if (item) Object.assign(item, { resolution: e.payload.resolution, answers: e.payload.answers, reason: e.payload.reason })
+      if (item) Object.assign(item, { resolution: e.payload.resolution, answers: e.payload.answers, reason: e.payload.reason, content: e.payload.content })
       if (!s.items.some(i => i.kind === 'interaction' && !i.resolution) && s.runState === 'waiting_input') s.runState = 'running'
       break
     }
@@ -215,6 +217,14 @@ export function useChatSession(client: AgentClient, sessionId: string, opts: { l
     schedules: () => client.sessions.schedules(sessionId),
     setScheduleStatus: (id: string, status: 'active' | 'paused') => client.schedules.setStatus(id, status),
     removeSchedule: (id: string) => client.schedules.remove(id),
+    /** MCP 服务的资源与提示模板(docs/design/16-mcp-support.md §6) */
+    mcp: {
+      available: async () => (await client.sessions.get(sessionId)).mcp,
+      resources: (q: { server?: string; cursor?: string } = {}) => client.sessions.mcpResources(sessionId, q),
+      attach: (server: string, uri: string) => client.sessions.attachMcpResource(sessionId, server, uri),
+      prompts: () => client.sessions.mcpPrompts(sessionId),
+      prompt: (server: string, name: string, args: Record<string, string>) => client.sessions.getMcpPrompt(sessionId, server, name, args),
+    },
     dispose() {
       unsubscribe?.()
     },

+ 3 - 2
packages/chat-ui/src/team-store.ts

@@ -2,7 +2,7 @@
 import { reactive } from 'vue'
 import type { BotClient } from '@agent-server/client'
 import type {
-  FileDto, InteractionRequest, InteractionResolution, InteractionResponse, JobDto, StubTaskBody, TaskDto, TeamDetailDto, TeamEvent, TeamMessage, TeamStepDto,
+  FileDto, InteractionFormValue, InteractionRequest, InteractionResolution, InteractionResponse, JobDto, StubTaskBody, TaskDto, TeamDetailDto, TeamEvent, TeamMessage, TeamStepDto,
 } from '@agent-server/shared'
 
 export type TeamItem =
@@ -10,6 +10,7 @@ export type TeamItem =
   | {
     kind: 'interaction'; id: string; seq: number; ts: string; botId: string; sessionId: string
     request: InteractionRequest; resolution: InteractionResolution | null; answers?: Record<string, string | string[]>; reason?: string
+    content?: Record<string, InteractionFormValue>
   }
 
 export interface TeamState {
@@ -43,7 +44,7 @@ export function applyTeamEvent(s: TeamState, e: TeamEvent) {
       break
     case 'interaction.resolved': {
       const item = s.items.findLast(i => i.kind === 'interaction' && i.id === e.payload.interactionId)
-      if (item?.kind === 'interaction') Object.assign(item, { resolution: e.payload.resolution, answers: e.payload.answers, reason: e.payload.reason })
+      if (item?.kind === 'interaction') Object.assign(item, { resolution: e.payload.resolution, answers: e.payload.answers, reason: e.payload.reason, content: e.payload.content })
       break
     }
     case 'bot.status': {

+ 1 - 1
packages/client/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/client",
-  "version": "0.5.0",
+  "version": "0.6.0",
   "private": true,
   "type": "module",
   "exports": {

+ 12 - 0
packages/client/src/index.ts

@@ -7,6 +7,9 @@ import type {
   FileDto,
   InteractionRequest,
   InteractionResponse,
+  McpPromptListDto,
+  McpPromptResultDto,
+  McpResourceListDto,
   Page,
   PermissionMode,
   ProfileDto,
@@ -49,6 +52,15 @@ export class AgentClient extends HttpClient {
     schedules: (id: string) => this.request<ScheduleDto[]>('GET', `/v1/sessions/${id}/schedules`),
     /** 会话的后台调用(外部工具的长调用) */
     backgroundCalls: (id: string) => this.request<BackgroundCallDto[]>('GET', `/v1/sessions/${id}/background-calls`),
+    /** Profile 勾选的 MCP 服务提供的资源目录(不含内容) */
+    mcpResources: (id: string, q: { server?: string; cursor?: string } = {}) => this.request<McpResourceListDto>('GET', `/v1/sessions/${id}/mcp/resources${query(q)}`),
+    /** 读取资源并保存为会话文件,返回的文件 id 放进 attachments */
+    attachMcpResource: (id: string, server: string, uri: string) => this.request<FileDto[]>('POST', `/v1/sessions/${id}/mcp/resources/attach`, { server, uri }),
+    /** MCP 服务提供的提示模板(快捷指令) */
+    mcpPrompts: (id: string) => this.request<McpPromptListDto>('GET', `/v1/sessions/${id}/mcp/prompts`),
+    /** 展开提示模板:text 放进输入框,attachments 一起发送 */
+    getMcpPrompt: (id: string, server: string, name: string, args: Record<string, string> = {}) =>
+      this.request<McpPromptResultDto>('POST', `/v1/sessions/${id}/mcp/prompts/get`, { server, name, arguments: args }),
     upload: (id: string, files: Blob[] | File[]) => {
       const form = new FormData()
       for (const f of files) form.append('file', f, (f as File).name ?? 'upload')

+ 1 - 1
packages/shared/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/shared",
-  "version": "0.5.0",
+  "version": "0.6.0",
   "private": true,
   "type": "module",
   "exports": {

+ 93 - 2
packages/shared/src/api.ts

@@ -27,6 +27,8 @@ export interface SessionDetailDto extends SessionDto {
   permissionMode: PermissionMode
   permissionModeOverridden: boolean
   systemPromptAppend: string
+  /** Profile 勾选的 MCP 服务是否提供资源、提示模板(输入框据此显示"资料"、"快捷指令") */
+  mcp: { resources: boolean; prompts: boolean }
 }
 
 export interface CreateSessionBody {
@@ -503,16 +505,38 @@ export interface ApiReferenceDto {
 // ---- MCP 服务(经 MCP Gateway 转接,两个引擎共用,ADR-0021)----
 
 /** 工具策略:auto 直接调用;ask 跟随权限模式(需审批时询问);off 不提供 */
-export type McpToolPolicy = 'auto' | 'ask' | 'off'
+/** auto 直接调用;ask 跟随权限模式;confirm 总是审批(自动模式下也审批);off 关闭 */
+export type McpToolPolicy = 'auto' | 'ask' | 'confirm' | 'off'
+
+/** 工具提示(docs/design/16-mcp-support.md §2.1):管理员可以覆盖服务的声明 */
+export interface McpToolHints {
+  readOnly?: boolean
+  destructive?: boolean
+  idempotent?: boolean
+}
 
 export interface McpToolDto {
   name: string
+  /** 服务提供的显示名 */
+  title: string | null
   description: string
-  /** 服务声明的只读工具(annotations.readOnlyHint);只读模式下只能调用这类工具 */
+  /** 生效的只读提示(覆盖后);只读模式下只能调用只读工具 */
   readOnly: boolean
+  /** 生效的提示:破坏性只认明确声明的 true;幂等;与外部世界交互 */
+  destructive: boolean
+  idempotent: boolean
+  openWorld: boolean
+  /** 服务声明的提示(未声明为 null) */
+  declared: { readOnly: boolean | null; destructive: boolean | null; idempotent: boolean | null; openWorld: boolean | null }
+  /** 管理员的覆盖 */
+  overrides: McpToolHints
   policy: McpToolPolicy
   /** 总是在后台执行(长时间运行的工具,docs/design/15-background-calls.md) */
   background: boolean
+  /** 声明了返回结构(outputSchema) */
+  hasOutputSchema: boolean
+  /** 长任务支持(execution.taskSupport) */
+  taskSupport: 'required' | 'optional' | null
 }
 
 /** 密钥类的值(请求头、环境变量):读取时只返回是否已设置 */
@@ -523,6 +547,16 @@ export interface McpSecretEntry {
   isSet?: boolean
 }
 
+/** OAuth 授权(按服务共享,docs/design/16-mcp-support.md §5):Client ID 留空时向授权服务器动态注册 */
+export interface McpOAuthInput {
+  enabled: boolean
+  clientId: string
+  /** 写入时为新值;读取时为 null。更新时传 null 表示保留原值 */
+  clientSecret: string | null
+  /** 申请的权限范围(空格分隔),可以留空 */
+  scope: string
+}
+
 export interface McpServerInput {
   /** 工具名前缀:小写字母、数字、下划线、连字符,1-32 位 */
   name: string
@@ -533,6 +567,8 @@ export interface McpServerInput {
   /** http:服务地址 */
   url: string
   headers: McpSecretEntry[]
+  /** http:OAuth 授权(不填时不使用) */
+  oauth?: McpOAuthInput
   /** stdio:命令、参数、环境变量 */
   command: string
   args: string[]
@@ -546,8 +582,12 @@ export interface McpServerInput {
 
 export interface McpServerDto extends McpServerInput {
   id: string
+  /** OAuth 授权状态(未启用 OAuth 时为 null) */
+  oauthStatus: { authorized: boolean; authorizedAt: string | null; clientSecretSet: boolean; registered: boolean; lastError: string | null } | null
   tools: McpToolDto[]
   toolsUpdatedAt: string | null
+  /** 服务提供资源(Resources)、提示模板(Prompts) */
+  features: { resources: boolean; prompts: boolean }
   status: 'connected' | 'idle' | 'error'
   lastError: string | null
   /** 本地进程最近的错误输出 */
@@ -558,6 +598,57 @@ export interface McpServerDto extends McpServerInput {
   updatedAt: string
 }
 
+/** 服务端资源与提示模板(docs/design/16-mcp-support.md §6) */
+export interface McpSourceRef {
+  serverId: string
+  serverName: string
+  serverTitle: string
+}
+
+export interface McpResourceDto extends McpSourceRef {
+  uri: string
+  name: string
+  title: string | null
+  description: string | null
+  mimeType: string | null
+  size: number | null
+}
+
+export interface McpResourceTemplateDto extends McpSourceRef {
+  uriTemplate: string
+  name: string
+  title: string | null
+  description: string | null
+  mimeType: string | null
+}
+
+export interface McpResourceListDto {
+  resources: McpResourceDto[]
+  templates: McpResourceTemplateDto[]
+  /** 只查一个服务时,下一页的游标 */
+  nextCursor: string | null
+  /** 连接失败的服务 */
+  errors: { serverTitle: string; error: string }[]
+}
+
+export interface McpPromptDto extends McpSourceRef {
+  name: string
+  title: string | null
+  description: string | null
+  arguments: { name: string; description: string | null; required: boolean }[]
+}
+
+export interface McpPromptListDto {
+  prompts: McpPromptDto[]
+  errors: { serverTitle: string; error: string }[]
+}
+
+/** 提示模板展开后的内容:文本放进输入框,嵌入的资源、图片已保存为会话附件 */
+export interface McpPromptResultDto {
+  text: string
+  attachments: FileDto[]
+}
+
 export interface McpBuiltinToolDto {
   name: string
   description: string

+ 4 - 0
packages/shared/src/background.ts

@@ -21,6 +21,10 @@ export interface BackgroundCallDto {
   error: string | null
   /** 结果是否已发给发起方 */
   delivered: boolean
+  /** 等待方式:wait 保持连接等待;task 服务支持长任务,按任务 id 查询状态(服务重启后继续) */
+  mode: 'wait' | 'task'
+  /** 长任务的最新状态说明(服务提供的 statusMessage) */
+  statusMessage: string | null
 }
 
 /** 后台总览:带发起方的名称与来源 */

+ 31 - 2
packages/shared/src/events.ts

@@ -35,7 +35,33 @@ export interface InteractionQuestionRequest {
   expiresAt: string
 }
 
-export type InteractionRequest = InteractionApprovalRequest | InteractionQuestionRequest
+/** 表单字段(由 MCP 服务询问的 requestedSchema 转换而来,docs/design/16-mcp-support.md §4) */
+export type InteractionFormField =
+  | { name: string; title: string; description?: string; required: boolean; type: 'string'; format?: 'email' | 'uri' | 'date' | 'date-time'; minLength?: number; maxLength?: number; default?: string }
+  | { name: string; title: string; description?: string; required: boolean; type: 'number' | 'integer'; minimum?: number; maximum?: number; default?: number }
+  | { name: string; title: string; description?: string; required: boolean; type: 'boolean'; default?: boolean }
+  | { name: string; title: string; description?: string; required: boolean; type: 'choice'; options: { value: string; label: string }[]; multiple: boolean; minItems?: number; maxItems?: number; default?: string | string[] }
+
+export type InteractionFormValue = string | number | boolean | string[]
+
+/**
+ * 外部服务向用户询问信息(MCP Elicitation):
+ * form 模式填写表单;url 模式请用户打开网址自行完成(授权、付款等),平台不自动打开
+ */
+export interface InteractionFormRequest {
+  interactionId: string
+  kind: 'form'
+  mode: 'form' | 'url'
+  /** 发起询问的服务(显示名) */
+  source: string
+  message: string
+  fields: InteractionFormField[]
+  /** url 模式:要打开的网址 */
+  url?: string
+  expiresAt: string
+}
+
+export type InteractionRequest = InteractionApprovalRequest | InteractionQuestionRequest | InteractionFormRequest
 
 export type InteractionDecision = 'allow_once' | 'allow_session' | 'deny'
 
@@ -43,6 +69,9 @@ export type InteractionResponse =
   | { decision: 'allow_once' | 'allow_session' }
   | { decision: 'deny'; reason?: string }
   | { answers: Record<string, string | string[]> }
+  /** 表单询问:提交(带填写的内容;url 模式表示已完成)、拒绝、关闭 */
+  | { action: 'accept'; content?: Record<string, InteractionFormValue> }
+  | { action: 'decline' | 'cancel' }
 
 export type InteractionResolution = 'allowed' | 'denied' | 'answered' | 'timeout' | 'cancelled'
 
@@ -68,7 +97,7 @@ export interface EventPayloads {
   'tool.completed': { toolCallId: string; status: 'ok' | 'error' | 'denied'; output: string; truncated: boolean }
   'file.changed': { fileId: string; path: string; kind: 'created' | 'modified'; size: number; mime: string }
   'interaction.required': InteractionRequest
-  'interaction.resolved': { interactionId: string; resolution: InteractionResolution; answers?: Record<string, string | string[]>; reason?: string }
+  'interaction.resolved': { interactionId: string; resolution: InteractionResolution; answers?: Record<string, string | string[]>; reason?: string; content?: Record<string, InteractionFormValue> }
   'usage': UsagePayload
   'run.completed': { durationMs: number }
   'run.failed': { code: string; message: string }

+ 2 - 2
packages/shared/src/teams.ts

@@ -1,6 +1,6 @@
 // Bot 工作组(docs/design/13-bot-teams.md):机器人、Bot Key、工作组与工作组事件
 import type { FileDto, PermissionMode } from './api'
-import type { InteractionRequest, InteractionResolution, ProviderId } from './events'
+import type { InteractionFormValue, InteractionRequest, InteractionResolution, ProviderId } from './events'
 
 /** 组长(lead)负责拆分和分派任务;组员(worker)执行具体工作 */
 export type BotKind = 'lead' | 'worker'
@@ -281,7 +281,7 @@ export interface TeamEventPayloads {
   'message.created': TeamMessage
   'bot.status': { botId: string; status: TeamBotStatus; sessionId: string | null; runId: string | null }
   'interaction.required': TeamInteractionDto
-  'interaction.resolved': { botId: string; sessionId: string; interactionId: string; resolution: InteractionResolution; answers?: Record<string, string | string[]>; reason?: string }
+  'interaction.resolved': { botId: string; sessionId: string; interactionId: string; resolution: InteractionResolution; answers?: Record<string, string | string[]>; reason?: string; content?: Record<string, InteractionFormValue> }
   'file.changed': { file: FileDto; kind: 'created' | 'modified' }
   'team.updated': TeamDto
   'job.updated': JobDto

+ 9 - 0
pnpm-lock.yaml

@@ -221,6 +221,15 @@ importers:
         specifier: ^5.0.5
         version: 5.0.5(@types/node@26.6.5)(vite@8.3.5)
 
+  examples/mcp-reference-server:
+    dependencies:
+      '@modelcontextprotocol/sdk':
+        specifier: 1.32.1
+        version: 1.32.1(zod@4.6.7)
+      zod:
+        specifier: ^4.6.7
+        version: 4.6.7
+
   packages/chat-ui:
     dependencies:
       '@agent-server/client':

+ 1 - 0
pnpm-workspace.yaml

@@ -1,3 +1,4 @@
 packages:
   - apps/*
   - packages/*
+  - examples/mcp-reference-server