feat(mcp): 16 PM tools + chat tool + 6 resource URIs + admin token UI

Phase F: tools_pm.go — list/get/create/update/close/reopen/assign for
projects, workspaces, requirements, issues (16 tools total).

Phase G: tools_chat.go — list_recent_messages tool; resources.go — 6 PM
resource URI templates (projects, project-detail, requirements, issues,
workspaces, workspace-detail).

Phase H: frontend admin UI — mcpTokens API client, Pinia store,
MCPTokenAdminView with create/revoke/list, router entry, NavBar link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 11:27:47 +08:00
parent d0efe2bdd4
commit 7d9164d478
9 changed files with 1360 additions and 4 deletions
+43
View File
@@ -0,0 +1,43 @@
import { buildQuery, request } from './client'
export interface MCPTokenScope {
tools?: string[] | null
project_ids?: string[] | null
}
export interface MCPToken {
id: string
name: string
issuer: 'admin' | 'system'
scope: MCPTokenScope
expires_at?: string | null
last_used_at?: string | null
revoked_at?: string | null
created_at: string
}
export interface CreateTokenReq {
user_id?: string
name: string
scope?: MCPTokenScope
expires_at: string
}
export interface CreateTokenResp {
id: string
token: string
expires_at: string
}
export const mcpTokensApi = {
list: (params?: { active_only?: boolean }) =>
request<MCPToken[]>(
`/api/v1/mcp/tokens${buildQuery(params ?? {})}`,
),
create: (req: CreateTokenReq) =>
request<CreateTokenResp>('/api/v1/mcp/tokens', { method: 'POST', body: req }),
revoke: (id: string) =>
request<void>(`/api/v1/mcp/tokens/${encodeURIComponent(id)}`, { method: 'DELETE' }),
}
+6
View File
@@ -57,6 +57,12 @@
>
全局用量
</RouterLink>
<RouterLink
:to="{ name: 'admin-mcp-tokens' }"
class="text-sm hover:underline"
>
MCP Tokens
</RouterLink>
</template>
</nav>
<span
+6
View File
@@ -136,6 +136,12 @@ const routes: RouteRecordRaw[] = [
props: { adminMode: true },
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/admin/mcp-tokens',
name: 'admin-mcp-tokens',
component: () => import('@/views/admin/MCPTokenAdminView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/admin/acp/agent-kinds',
component: () => import('@/views/admin/AgentKindAdminListView.vue'),
+35
View File
@@ -0,0 +1,35 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import {
mcpTokensApi,
type MCPToken,
type CreateTokenReq,
type CreateTokenResp,
} from '@/api/mcpTokens'
export const useMcpTokensStore = defineStore('mcpTokens', () => {
const tokens = ref<MCPToken[]>([])
const loading = ref(false)
async function fetch(params?: { active_only?: boolean }) {
loading.value = true
try {
tokens.value = await mcpTokensApi.list(params)
} finally {
loading.value = false
}
}
async function create(req: CreateTokenReq): Promise<CreateTokenResp> {
const resp = await mcpTokensApi.create(req)
await fetch()
return resp
}
async function revoke(id: string) {
await mcpTokensApi.revoke(id)
await fetch()
}
return { tokens, loading, fetch, create, revoke }
})
+255
View File
@@ -0,0 +1,255 @@
<template>
<AppShell>
<div class="space-y-4">
<div class="flex justify-between items-center">
<h2 class="text-lg font-semibold">
MCP Tokens
</h2>
<div class="flex items-center gap-3">
<NCheckbox
v-model:checked="activeOnly"
@update:checked="reload"
>
仅显示活跃
</NCheckbox>
<NButton
type="primary"
@click="openCreate"
>
+ 新增
</NButton>
</div>
</div>
<NDataTable
:data="store.tokens"
:columns="columns"
:row-key="rowKey"
:loading="store.loading"
/>
<!-- Create modal -->
<NModal
v-model:show="showCreate"
preset="card"
title="新增 MCP Token"
style="width: 520px"
>
<NForm>
<NFormItem label="名称">
<NInput v-model:value="createForm.name" />
</NFormItem>
<NFormItem label="过期时间">
<NDatePicker
v-model:value="createForm.expiresAt"
type="datetime"
:is-date-disabled="isDateDisabled"
style="width: 100%"
/>
</NFormItem>
<NFormItem label="允许的工具 (scope)">
<NInput
v-model:value="toolsInput"
placeholder="逗号分隔,留空表示全部"
/>
</NFormItem>
</NForm>
<template #footer>
<div class="flex justify-end gap-2">
<NButton @click="showCreate = false">
取消
</NButton>
<NButton
type="primary"
:loading="creating"
:disabled="!canCreate"
@click="doCreate"
>
创建
</NButton>
</div>
</template>
</NModal>
<!-- Token display dialog -->
<NModal
v-model:show="showToken"
preset="card"
title="Token 已创建"
style="width: 520px"
>
<NAlert
type="warning"
:bordered="false"
>
请立即复制 Token关闭后无法再次查看
</NAlert>
<NInput
:value="createdToken"
readonly
type="textarea"
rows="3"
class="mt-3"
/>
<template #footer>
<div class="flex justify-end">
<NButton @click="copyToken">
复制
</NButton>
</div>
</template>
</NModal>
</div>
</AppShell>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'
import {
NAlert,
NButton,
NCheckbox,
NDataTable,
NDatePicker,
NForm,
NFormItem,
NInput,
NModal,
useDialog,
useMessage,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import { useMcpTokensStore } from '@/stores/mcpTokens'
import type { MCPToken } from '@/api/mcpTokens'
const store = useMcpTokensStore()
const msg = useMessage()
const dlg = useDialog()
const activeOnly = ref(false)
const showCreate = ref(false)
const creating = ref(false)
const showToken = ref(false)
const createdToken = ref('')
const createForm = ref<{ name: string; expiresAt: number | null }>({
name: '',
expiresAt: null,
})
const toolsInput = ref('')
const canCreate = computed(() => {
const f = createForm.value
return !!f.name.trim() && f.expiresAt !== null
})
function isDateDisabled(ts: number): boolean {
return ts < Date.now()
}
const columns: DataTableColumns<MCPToken> = [
{ title: '名称', key: 'name' },
{ title: '签发者', key: 'issuer', width: 90 },
{
title: '过期时间',
key: 'expires_at',
render: (row) => row.expires_at ? new Date(row.expires_at).toLocaleString() : '永不过期',
},
{
title: '最后使用',
key: 'last_used_at',
render: (row) => row.last_used_at ? new Date(row.last_used_at).toLocaleString() : '-',
},
{
title: '状态',
key: 'status',
render: (row) => {
if (row.revoked_at) return '已吊销'
if (row.expires_at && new Date(row.expires_at) < new Date()) return '已过期'
return '活跃'
},
},
{
title: '创建时间',
key: 'created_at',
render: (row) => new Date(row.created_at).toLocaleString(),
},
{
title: '操作',
key: 'actions',
render: (row) =>
h(
NButton,
{
size: 'small',
type: 'error',
disabled: !!row.revoked_at,
onClick: () => revoke(row),
},
{ default: () => '吊销' },
),
},
]
const rowKey = (row: MCPToken) => row.id
onMounted(reload)
async function reload() {
await store.fetch(activeOnly.value ? { active_only: true } : undefined)
}
function openCreate() {
createForm.value = { name: '', expiresAt: null }
toolsInput.value = ''
showCreate.value = true
}
async function doCreate() {
const f = createForm.value
if (!f.name.trim() || f.expiresAt === null) return
creating.value = true
try {
const tools = toolsInput.value.trim()
? toolsInput.value.split(',').map((s) => s.trim()).filter(Boolean)
: undefined
const resp = await store.create({
name: f.name.trim(),
expires_at: new Date(f.expiresAt).toISOString(),
scope: tools?.length ? { tools } : undefined,
})
showCreate.value = false
createdToken.value = resp.token
showToken.value = true
} catch (e: unknown) {
msg.error((e as Error).message || '创建失败')
} finally {
creating.value = false
}
}
function revoke(row: MCPToken) {
dlg.warning({
title: '确认吊销',
content: `确定要吊销 Token "${row.name}" 吗?此操作不可撤销。`,
positiveText: '吊销',
negativeText: '取消',
onPositiveClick: async () => {
try {
await store.revoke(row.id)
msg.success('已吊销')
} catch (e: unknown) {
msg.error((e as Error).message || '吊销失败')
}
},
})
}
function copyToken() {
navigator.clipboard.writeText(createdToken.value).then(
() => msg.success('已复制'),
() => msg.error('复制失败'),
)
}
</script>