feat(AI接入): 内置 MCP 服务与个人 Token 管理
Docker Build and Push / build-image (push) Successful in 4m34s

- 新增 /api/mcp (Streamable HTTP) 与 41 个工具: 项目/看板列/任务/清单/打卡; - 新增 McpToken 模型与迁移, DB 仅存 SHA-256 哈希, token 绑定用户; - 新增 /api/mcp-tokens 增删改查, 支持独立命名 (如 R1); - 「我的」页新增 AI 接入 (MCP) 区块: 地址复制/新建/一次性展示/删除; - 补充 docs/MCP.md 与样式
This commit is contained in:
jif2.zhang
2026-11-09 10:41:08 +08:00
parent fa172d8974
commit ec6c9c7677
26 changed files with 3763 additions and 12 deletions
+104
View File
@@ -0,0 +1,104 @@
// AI 接入 (MCP): 个人授权 token 管理 (登录用户本人)
import { Router } from 'express'
import { prisma } from '../db.js'
import { authRequired } from '../middleware.js'
import { generateMcpToken, hashMcpToken } from '../mcp/auth.js'
const router = Router()
router.use(authRequired)
router.use((_req, res, next) => {
res.set('Cache-Control', 'no-store')
next()
})
const NAME_MAX = 32
function publicToken(row) {
return {
id: row.id,
name: row.name,
prefix: row.prefix,
lastUsedAt: row.lastUsedAt,
createdAt: row.createdAt,
}
}
function cleanName(v) {
const name = String(v || '').trim()
if (!name) return { error: 'Token 名称不能为空' }
if (name.length > NAME_MAX) return { error: `Token 名称不能超过 ${NAME_MAX} 字` }
return { name }
}
// GET /api/mcp-tokens 当前用户的全部 token (不含明文)
router.get('/', async (req, res, next) => {
try {
const rows = await prisma.mcpToken.findMany({
where: { userId: req.user.sub },
orderBy: { createdAt: 'asc' },
})
res.json({ items: rows.map(publicToken) })
} catch (e) { next(e) }
})
// POST /api/mcp-tokens { name } 新建 token, 明文仅在此响应中返回一次
router.post('/', async (req, res, next) => {
try {
const userId = req.user.sub
const { name, error } = cleanName(req.body?.name)
if (error) return res.status(400).json({ error })
const dup = await prisma.mcpToken.findFirst({ where: { userId, name } })
if (dup) return res.status(409).json({ error: `Token 名称「${name}」已存在` })
const raw = generateMcpToken()
const row = await prisma.mcpToken.create({
data: {
userId,
name,
prefix: raw.slice(0, 12),
tokenHash: hashMcpToken(raw),
},
})
res.json({ item: publicToken(row), token: raw })
} catch (e) {
if (e?.code === 'P2002') return res.status(409).json({ error: 'Token 名称已存在' })
next(e)
}
})
// PATCH /api/mcp-tokens/:id { name } 重命名
router.patch('/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const { name, error } = cleanName(req.body?.name)
if (error) return res.status(400).json({ error })
const exist = await prisma.mcpToken.findFirst({ where: { id, userId } })
if (!exist) return res.status(404).json({ error: 'Token 不存在' })
const dup = await prisma.mcpToken.findFirst({ where: { userId, name, NOT: { id } } })
if (dup) return res.status(409).json({ error: `Token 名称「${name}」已存在` })
const row = await prisma.mcpToken.update({ where: { id }, data: { name } })
res.json({ item: publicToken(row) })
} catch (e) {
if (e?.code === 'P2002') return res.status(409).json({ error: 'Token 名称已存在' })
next(e)
}
})
// DELETE /api/mcp-tokens/:id 删除后立即失效
router.delete('/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const exist = await prisma.mcpToken.findFirst({ where: { id, userId } })
if (!exist) return res.status(404).json({ error: 'Token 不存在' })
await prisma.mcpToken.delete({ where: { id } })
res.json({ ok: true })
} catch (e) { next(e) }
})
export default router