feat(mcp): add OAuth2 authorization for MCP services

Support connecting to MCP servers that require OAuth2, with a Cursor-like
zero-config flow: the user provides only a URL and the system performs
metadata discovery, dynamic client registration (DCR), and the
authorization code flow with PKCE on first connect.

- Persist per-user access/refresh tokens and per-service DCR client
  credentials, encrypted at rest (AES-256-GCM)
- Store the PKCE verifier/state during the flow via Redis with an
  in-memory fallback for Lite mode
- Cache MCP clients per (service, user) so tokens stay user-isolated
- Add endpoints for authorize-url, callback, status, and token revoke;
  the callback is allowlisted for unauthenticated access
- Surface a structured "authorization required" hint when a tool call
  hits an unauthorized MCP service
- Frontend: add an auth-type selector with OAuth config, inline
  authorization status/actions, and render the connection test result
  inline in the editor drawer instead of a stacked centered dialog
This commit is contained in:
wizardchen
2026-06-22 20:19:31 +08:00
committed by lyingbug
parent c25d762ed1
commit 7f3f219f58
27 changed files with 1920 additions and 686 deletions
+40
View File
@@ -10,6 +10,10 @@ export interface MCPService {
url?: string // Optional: required for SSE/HTTP Streamable
headers?: Record<string, string>
auth_config?: {
// Authentication strategy. Empty/absent means none. "oauth" enables the
// per-user OAuth2 authorization-code flow (zero-config: discovery +
// dynamic client registration).
auth_type?: '' | 'api_key' | 'bearer' | 'oauth'
// Secret fields (api_key, token) are NEVER returned by the server in
// this shape — they live behind the /credentials subresource. The
// optional-property typing remains so create-mode payloads can still
@@ -17,6 +21,9 @@ export interface MCPService {
api_key?: string
token?: string
custom_headers?: Record<string, string>
// OAuth-only, non-secret configuration.
scopes?: string[]
auth_server_metadata_url?: string
}
advanced_config?: {
timeout?: number
@@ -166,6 +173,39 @@ export async function deleteMCPCredentialField(
await del(`/api/v1/mcp-services/${serviceId}/credentials/${field}`)
}
// ----------------------------------------------------------------------------
// Per-user OAuth2 authorization-code flow.
//
// The user authorizes a service once; the backend stores their access/refresh
// token (per tenant + user + service) and refreshes it transparently. The
// callback is a public backend route that the third-party authorization
// server redirects to.
// ----------------------------------------------------------------------------
// Path of the public backend OAuth callback (registered outside /mcp-services
// to avoid a route conflict, and allow-listed for no-auth in the backend).
export const MCP_OAUTH_CALLBACK_PATH = '/api/v1/mcp-oauth/callback'
// Begin authorization for the current user. Returns the URL to open in a popup.
export async function getMCPOAuthAuthorizeURL(
serviceId: string,
body: { redirect_uri: string; frontend_redirect?: string }
): Promise<string> {
const response: any = await post(`/api/v1/mcp-services/${serviceId}/oauth/authorize-url`, body)
return (response.data ?? response)?.authorization_url ?? ''
}
// Whether the current user has authorized this service.
export async function getMCPOAuthStatus(serviceId: string): Promise<boolean> {
const response: any = await get(`/api/v1/mcp-services/${serviceId}/oauth/status`)
return Boolean((response.data ?? response)?.authorized)
}
// Revoke the current user's token (forces re-authorization).
export async function revokeMCPOAuthToken(serviceId: string): Promise<void> {
await del(`/api/v1/mcp-services/${serviceId}/oauth/token`)
}
export async function resolveToolApproval(
pendingId: string,
body: { decision: 'approve' | 'reject'; modified_args?: Record<string, unknown>; reason?: string }
+18
View File
@@ -4343,6 +4343,7 @@ export default {
connectionSection: "连接配置",
enableServiceDesc: "关闭后该服务不会被调用",
testAfterSaveHint: "保存后可测试连接",
testResultTitle: "测试结果",
unitSecond: "秒",
unitTimes: "次",
name: "服务名称",
@@ -4367,6 +4368,19 @@ export default {
addEnvVar: "添加环境变量",
enableService: "启用服务",
authConfig: "认证配置",
authType: "认证方式",
authTypeNone: "无 / 自定义 Header",
authTypeApiKey: "API Key",
authTypeBearer: "Bearer Token",
authTypeOAuth: "OAuth 2.0(首次连接授权)",
oauthScopes: "Scopes(可选,空格分隔)",
oauthAuthorization: "授权状态",
oauthAuthorized: "已授权",
oauthUnauthorized: "未授权",
oauthAuthorize: "去授权",
oauthReauthorize: "重新授权",
oauthRevoke: "撤销授权",
oauthSaveFirstHint: "保存服务后,可在编辑页发起首次授权(每个用户独立授权)。",
apiKey: "API Key",
bearerToken: "Bearer Token",
optional: "可选",
@@ -4387,6 +4401,10 @@ export default {
updated: "MCP 服务已更新",
createFailed: "创建 MCP 服务失败",
updateFailed: "更新 MCP 服务失败",
authorized: "授权成功",
authorizeFailed: "发起授权失败",
revoked: "已撤销授权",
revokeFailed: "撤销失败",
},
},
promptTemplate: {
+1 -25
View File
@@ -112,15 +112,6 @@
:service="currentService"
:mode="dialogMode"
@success="handleDialogSuccess"
@test="handleDrawerTest"
/>
<!-- Test Result Dialog -->
<McpTestResult
v-model:visible="testDialogVisible"
:result="testResult"
:service-name="testingServiceName"
:service-id="testingServiceId"
/>
</div>
</template>
@@ -134,11 +125,9 @@ import {
listMCPServices,
updateMCPService,
deleteMCPService,
type MCPService,
type MCPTestResult
type MCPService
} from '@/api/mcp-service'
import McpServiceDialog from './components/McpServiceDialog.vue'
import McpTestResult from './components/McpTestResult.vue'
import { useConfirmDelete } from '@/components/settings/useConfirmDelete'
import { useAuthStore } from '@/stores/auth'
@@ -151,10 +140,6 @@ const loading = ref(false)
const dialogVisible = ref(false)
const dialogMode = ref<'add' | 'edit'>('add')
const currentService = ref<MCPService | null>(null)
const testDialogVisible = ref(false)
const testResult = ref<MCPTestResult | null>(null)
const testingServiceName = ref('')
const testingServiceId = ref('')
// Load MCP services
const loadServices = async () => {
@@ -266,15 +251,6 @@ const getBuiltinServiceOptions = () => {
]
}
// Drawer 内点击"测试连接"后,复用现有的 testResult dialog 展示结果。
// 抽屉只负责调 API + 拿结果,弹窗位置/状态由父组件统一管。
const handleDrawerTest = ({ service, result }: { service: MCPService; result: MCPTestResult }) => {
testingServiceName.value = service.name
testingServiceId.value = service.id
testResult.value = result
testDialogVisible.value = true
}
// Handle menu action. 'test' has been removed from the menu — testing now
// lives only in the editor drawer. We keep the switch's case list narrow
// so a stray 'test' from somewhere else falls through harmlessly.
@@ -123,43 +123,88 @@
</div>
</section>
<!-- Section 3 — 认证配置(API Key / Bearer Token) -->
<!-- Section 3 — 认证配置(无 / API Key / Bearer Token / OAuth) -->
<section class="setting-drawer__section">
<h4 class="setting-drawer__section-title">{{ t('mcpServiceDialog.authConfig') }}</h4>
<div class="form-item">
<label class="form-label">{{ t('mcpServiceDialog.authType', '认证方式') }}</label>
<t-select v-model="formData.auth_config.auth_type" :options="authTypeOptions" />
</div>
<!-- OAuth 2.0:零配置(自动发现 + 动态客户端注册),按用户授权 -->
<template v-if="isOAuth">
<div class="form-item">
<label class="form-label">{{ t('mcpServiceDialog.oauthScopes', 'Scopes(可选,空格分隔)') }}</label>
<t-input v-model="oauthScopesText" :placeholder="t('mcpServiceDialog.optional')" />
</div>
<div v-if="mode === 'edit' && props.service?.id" class="form-item">
<label class="form-label">{{ t('mcpServiceDialog.oauthAuthorization', '授权状态') }}</label>
<div class="oauth-status">
<t-tag v-if="oauthAuthorized" theme="success" variant="light">
{{ t('mcpServiceDialog.oauthAuthorized', '已授权') }}
</t-tag>
<t-tag v-else theme="warning" variant="light">
{{ t('mcpServiceDialog.oauthUnauthorized', '未授权') }}
</t-tag>
<t-button
size="small"
theme="primary"
:loading="oauthAuthorizing || oauthChecking"
@click="handleAuthorize"
>
{{ oauthAuthorized ? t('mcpServiceDialog.oauthReauthorize', '重新授权') : t('mcpServiceDialog.oauthAuthorize', '去授权') }}
</t-button>
<t-button
v-if="oauthAuthorized"
size="small"
theme="danger"
variant="outline"
@click="handleRevokeOAuth"
>
{{ t('mcpServiceDialog.oauthRevoke', '撤销授权') }}
</t-button>
</div>
</div>
<p v-else class="oauth-hint">
{{ t('mcpServiceDialog.oauthSaveFirstHint', '保存服务后,可在编辑页发起首次授权(每个用户独立授权)。') }}
</p>
</template>
<!--
Edit 模式下凭证由 CredentialResource 管理(独立的 /credentials
子资源调用),不与本表单 submit 耦合;Create 模式下用 plain
password input + lock prefix-icon。两个字段都是 optional —
MCP 服务可能完全不需要鉴权(依赖 IP 白名单等)。
非 OAuth:Edit 模式下凭证由 CredentialResource 管理(独立的
/credentials 子资源调用);Create 模式下用 plain password input。
两个字段都是 optional — MCP 服务可能完全不需要鉴权。
-->
<CredentialResource
v-if="mode === 'edit' && props.service?.id"
:api="credentialApi"
:fields="credentialFields"
:meta="credentialMeta"
/>
<template v-else>
<div class="form-item">
<label class="form-label">{{ t('mcpServiceDialog.apiKey') }}</label>
<t-input
v-model="formData.auth_config.api_key"
type="password"
:placeholder="t('mcpServiceDialog.optional')"
>
<template #prefix-icon><t-icon name="lock-on" /></template>
</t-input>
</div>
<div class="form-item">
<label class="form-label">{{ t('mcpServiceDialog.bearerToken') }}</label>
<t-input
v-model="formData.auth_config.token"
type="password"
:placeholder="t('mcpServiceDialog.optional')"
>
<template #prefix-icon><t-icon name="lock-on" /></template>
</t-input>
</div>
<CredentialResource
v-if="mode === 'edit' && props.service?.id"
:api="credentialApi"
:fields="credentialFields"
:meta="credentialMeta"
/>
<template v-else>
<div class="form-item">
<label class="form-label">{{ t('mcpServiceDialog.apiKey') }}</label>
<t-input
v-model="formData.auth_config.api_key"
type="password"
:placeholder="t('mcpServiceDialog.optional')"
>
<template #prefix-icon><t-icon name="lock-on" /></template>
</t-input>
</div>
<div class="form-item">
<label class="form-label">{{ t('mcpServiceDialog.bearerToken') }}</label>
<t-input
v-model="formData.auth_config.token"
type="password"
:placeholder="t('mcpServiceDialog.optional')"
>
<template #prefix-icon><t-icon name="lock-on" /></template>
</t-input>
</div>
</template>
</template>
</section>
@@ -217,12 +262,30 @@
</t-input>
</div>
</section>
<!-- Section 5 — 测试结果(内联,避免在抽屉上再叠一个居中弹窗) -->
<section v-if="testResult" ref="testResultSection" class="setting-drawer__section">
<div class="test-result-header">
<h4 class="setting-drawer__section-title">{{ t('mcpServiceDialog.testResultTitle', '测试结果') }}</h4>
<t-button
variant="text"
theme="default"
shape="square"
size="small"
class="test-result-close"
@click="testResult = null"
>
<template #icon><t-icon name="close" /></template>
</t-button>
</div>
<McpTestResultBody :result="testResult" :service-id="props.service?.id" />
</section>
</t-form>
</SettingDrawer>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { ref, watch, computed, nextTick } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import type { FormInstanceFunctions, FormRule } from 'tdesign-vue-next'
import { useI18n } from 'vue-i18n'
@@ -232,11 +295,16 @@ import {
putMCPCredentials,
deleteMCPCredentialField,
testMCPService,
getMCPOAuthAuthorizeURL,
getMCPOAuthStatus,
revokeMCPOAuthToken,
MCP_OAUTH_CALLBACK_PATH,
type MCPService,
type McpCredentialField,
type MCPTestResult,
} from '@/api/mcp-service'
import SettingDrawer from '@/components/settings/SettingDrawer.vue'
import McpTestResultBody from './McpTestResultBody.vue'
import CredentialResource, {
type CredentialFieldDef,
type CredentialResourceApi,
@@ -251,11 +319,6 @@ interface Props {
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'success'): void
// Fired after a /test call inside the drawer so the parent can reuse its
// existing McpTestResult dialog. We deliberately don't render that dialog
// here — the parent owns the test-result dialog state across multiple
// entry points (drawer test button, list 行操作菜单 used to call it too).
(e: 'test', payload: { service: MCPService; result: MCPTestResult }): void
}
const props = defineProps<Props>()
@@ -272,9 +335,14 @@ const formData = ref({
transport_type: 'sse' as 'sse' | 'http-streamable',
url: '',
auth_config: {
// Authentication strategy: '' (none) | 'api_key' | 'bearer' | 'oauth'.
auth_type: '' as '' | 'api_key' | 'bearer' | 'oauth',
// Only used in add-mode; in edit-mode the CredentialResource owns these.
api_key: '',
token: '',
// OAuth-only, non-secret config.
scopes: [] as string[],
auth_server_metadata_url: '',
},
advanced_config: {
timeout: 30,
@@ -283,6 +351,93 @@ const formData = ref({
},
})
// Comma/space separated text binding for OAuth scopes.
const oauthScopesText = computed({
get: () => (formData.value.auth_config.scopes || []).join(' '),
set: (val: string) => {
formData.value.auth_config.scopes = val
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
},
})
const isOAuth = computed(() => formData.value.auth_config.auth_type === 'oauth')
const authTypeOptions = computed(() => [
{ value: '', label: t('mcpServiceDialog.authTypeNone', '无 / 自定义 Header') },
{ value: 'api_key', label: t('mcpServiceDialog.authTypeApiKey', 'API Key') },
{ value: 'bearer', label: t('mcpServiceDialog.authTypeBearer', 'Bearer Token') },
{ value: 'oauth', label: t('mcpServiceDialog.authTypeOAuth', 'OAuth 2.0(首次连接授权)') },
])
// ---- OAuth authorization state (edit mode only) ----
const oauthAuthorized = ref(false)
const oauthChecking = ref(false)
const oauthAuthorizing = ref(false)
async function refreshOAuthStatus() {
if (props.mode !== 'edit' || !props.service?.id || !isOAuth.value) return
oauthChecking.value = true
try {
oauthAuthorized.value = await getMCPOAuthStatus(props.service.id)
} catch (e) {
console.error('Failed to query MCP OAuth status:', e)
} finally {
oauthChecking.value = false
}
}
async function handleAuthorize() {
if (!props.service?.id) return
oauthAuthorizing.value = true
try {
const redirectUri = window.location.origin + MCP_OAUTH_CALLBACK_PATH
// After the backend completes the exchange it bounces the popup here. The
// app root is harmless; the popup is closed by the opener below once the
// authorization status flips, so this page is only shown briefly.
const frontendRedirect = window.location.origin + '/'
const authUrl = await getMCPOAuthAuthorizeURL(props.service.id, {
redirect_uri: redirectUri,
frontend_redirect: frontendRedirect,
})
if (!authUrl) {
MessagePlugin.error(t('mcpServiceDialog.toasts.authorizeFailed', '发起授权失败') as string)
return
}
const popup = window.open(authUrl, 'mcp_oauth', 'width=600,height=720')
// Poll for completion: either the popup closes or the status flips.
const timer = window.setInterval(async () => {
const closed = !popup || popup.closed
await refreshOAuthStatus()
if (oauthAuthorized.value || closed) {
window.clearInterval(timer)
oauthAuthorizing.value = false
if (oauthAuthorized.value) {
try { popup?.close() } catch { /* cross-origin close may throw */ }
MessagePlugin.success(t('mcpServiceDialog.toasts.authorized', '授权成功') as string)
}
}
}, 1500)
} catch (e) {
console.error('Failed to start MCP OAuth authorization:', e)
MessagePlugin.error(t('mcpServiceDialog.toasts.authorizeFailed', '发起授权失败') as string)
oauthAuthorizing.value = false
}
}
async function handleRevokeOAuth() {
if (!props.service?.id) return
try {
await revokeMCPOAuthToken(props.service.id)
oauthAuthorized.value = false
MessagePlugin.success(t('mcpServiceDialog.toasts.revoked', '已撤销授权') as string)
} catch (e) {
console.error('Failed to revoke MCP OAuth token:', e)
MessagePlugin.error(t('mcpServiceDialog.toasts.revokeFailed', '撤销失败') as string)
}
}
// Header icon name + transport label, mirrored from McpSettings list cards
// so the list-card → drawer hand-off stays visually continuous.
const transportIcon = computed(() => {
@@ -356,11 +511,24 @@ const testing = ref(false)
// false=just failed. Cleared when transport/url change so a stale ✓/✗
// doesn't sit next to a config the user is now editing.
const lastTestOk = ref<boolean | null>(null)
// In-drawer test result, rendered inline (no centered dialog stacked on the
// drawer). Cleared when the target config changes so a stale result doesn't
// sit next to edited config.
const testResult = ref<MCPTestResult | null>(null)
const testResultSection = ref<HTMLElement | null>(null)
// 结果区在抽屉最底部,测试完成后主动滚动到可见,免得用户以为没反应。
function scrollToTestResult() {
void nextTick(() => {
testResultSection.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
watch(
() => [formData.value.transport_type, formData.value.url],
() => {
lastTestOk.value = null
testResult.value = null
},
)
@@ -380,7 +548,8 @@ async function handleTestConnection() {
message: t('mcpSettings.toasts.noResponse') as string,
}
lastTestOk.value = safe.success === true
emit('test', { service: props.service, result: safe })
testResult.value = safe
scrollToTestResult()
} catch (error: any) {
MessagePlugin.closeAll()
const errorMessage =
@@ -389,10 +558,8 @@ async function handleTestConnection() {
(t('mcpSettings.toasts.testFailed') as string)
console.error('Failed to test MCP service:', error)
lastTestOk.value = false
emit('test', {
service: props.service,
result: { success: false, message: errorMessage },
})
testResult.value = { success: false, message: errorMessage }
scrollToTestResult()
} finally {
testing.value = false
}
@@ -451,7 +618,7 @@ const resetForm = () => {
enabled: true,
transport_type: 'sse',
url: '',
auth_config: { api_key: '', token: '' },
auth_config: { auth_type: '', api_key: '', token: '', scopes: [], auth_server_metadata_url: '' },
advanced_config: { timeout: 30, retry_count: 3, retry_delay: 1 },
}
formRef.value?.clearValidate()
@@ -462,6 +629,7 @@ watch(
(service) => {
// 切到不同服务(或新增)时清空上次测试反馈,避免旧的 ✓/✗ 漂在新表单上
lastTestOk.value = null
testResult.value = null
if (service) {
const transportType = service.transport_type === 'stdio' ? 'sse' : (service.transport_type || 'sse')
formData.value = {
@@ -472,13 +640,21 @@ watch(
url: service.url || '',
// Credentials are owned by CredentialResource in edit mode, but reset
// the local state too so a switch to add-mode starts clean.
auth_config: { api_key: '', token: '' },
auth_config: {
auth_type: (service.auth_config?.auth_type as '' | 'api_key' | 'bearer' | 'oauth') || '',
api_key: '',
token: '',
scopes: service.auth_config?.scopes ? [...service.auth_config.scopes] : [],
auth_server_metadata_url: service.auth_config?.auth_server_metadata_url || '',
},
advanced_config: {
timeout: service.advanced_config?.timeout || 30,
retry_count: service.advanced_config?.retry_count || 3,
retry_delay: service.advanced_config?.retry_delay || 1,
},
}
oauthAuthorized.value = false
refreshOAuthStatus()
} else {
resetForm()
}
@@ -501,18 +677,34 @@ const handleSubmit = async () => {
url: formData.value.url || undefined,
}
// Non-secret auth config (strategy + OAuth params) flows through the main
// body on both create and update. Secret fields are handled separately:
// on create they ride along in the POST; on edit they go through the
// /credentials subresource.
const auth: NonNullable<MCPService['auth_config']> = {
auth_type: formData.value.auth_config.auth_type,
}
if (isOAuth.value) {
auth.scopes = formData.value.auth_config.scopes
if (formData.value.auth_config.auth_server_metadata_url) {
auth.auth_server_metadata_url = formData.value.auth_config.auth_server_metadata_url
}
}
if (props.mode === 'add') {
// Initial credentials go along with the first POST. Subsequent edits
// route through the /credentials subresource.
const initialAuth: NonNullable<MCPService['auth_config']> = {}
if (formData.value.auth_config.api_key) initialAuth.api_key = formData.value.auth_config.api_key
if (formData.value.auth_config.token) initialAuth.token = formData.value.auth_config.token
if (Object.keys(initialAuth).length > 0) data.auth_config = initialAuth
if (!isOAuth.value) {
if (formData.value.auth_config.api_key) auth.api_key = formData.value.auth_config.api_key
if (formData.value.auth_config.token) auth.token = formData.value.auth_config.token
}
data.auth_config = auth
await createMCPService(data)
MessagePlugin.success(t('mcpServiceDialog.toasts.created'))
} else {
// Edit-mode: never send credential fields here. CredentialResource
// already committed any changes through the dedicated endpoint.
data.auth_config = auth
await updateMCPService(props.service!.id, data)
MessagePlugin.success(t('mcpServiceDialog.toasts.updated'))
}
@@ -541,6 +733,35 @@ const handleClose = () => {
margin-bottom: 0;
}
.oauth-status {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.test-result-header {
display: flex;
align-items: center;
justify-content: space-between;
.setting-drawer__section-title {
margin-bottom: 0;
}
.test-result-close {
flex-shrink: 0;
color: var(--td-text-color-placeholder);
}
}
.oauth-hint {
margin: 0;
font-size: 12px;
color: var(--td-text-color-secondary);
line-height: 1.5;
}
.form-label {
display: block;
margin-bottom: 6px;
@@ -1,563 +0,0 @@
<template>
<t-dialog
v-model:visible="dialogVisible"
:header="$t('mcp.testResult.title', { name: serviceName })"
width="600px"
:footer="false"
>
<div v-if="result" class="test-result">
<!-- Success/Error Status -->
<div class="status-section">
<div v-if="result.success" class="status-success">
<t-icon name="check-circle-filled" size="20px" />
<span class="status-text">{{ $t('mcp.testResult.connectionSuccess') }}</span>
</div>
<div v-else class="status-error">
<t-icon name="close-circle-filled" size="20px" />
<span class="status-text">{{ $t('mcp.testResult.connectionFailed') }}</span>
</div>
<p v-if="result.message" class="status-message">{{ result.message }}</p>
</div>
<!-- Details Section -->
<div v-if="result.success" class="details-section">
<!-- Tools List -->
<div v-if="result.tools && result.tools.length > 0" class="section">
<div class="section-header">
<h3>{{ $t('mcp.testResult.toolsTitle') }}</h3>
<t-tag theme="primary" variant="light" size="small">{{ result.tools.length }}</t-tag>
</div>
<div class="tools-grid">
<div
v-for="(tool, index) in displayTools"
:key="index"
class="tool-card"
:class="{ 'tool-card-expanded': expandedToolIndex === index }"
>
<div class="tool-card-header" @click="toggleTool(index)">
<div class="tool-header-left">
<t-icon name="tools" class="tool-icon" />
<div class="tool-info">
<div class="tool-name">{{ tool.name }}</div>
<div v-if="tool.description" class="tool-desc-preview">
{{ tool.description }}
</div>
</div>
</div>
<div class="tool-header-right" @click.stop>
<t-tooltip v-if="serviceId" :content="$t('mcp.testResult.requireApprovalTip')" placement="top">
<div class="approval-switch">
<t-icon name="error-circle-filled" class="danger-icon" />
<span class="approval-label">{{ $t('mcp.testResult.requireApproval') }}</span>
<t-switch
:value="tool.require_approval"
:loading="approvalLoading[tool.name]"
size="small"
@change="(v: boolean) => onRequireApprovalChange(tool.name, v)"
/>
</div>
</t-tooltip>
<t-icon
:name="expandedToolIndex === index ? 'chevron-up' : 'chevron-down'"
class="expand-icon"
/>
</div>
</div>
<div v-if="expandedToolIndex === index" class="tool-card-content">
<div v-if="tool.description" class="tool-description">
<div class="label">{{ $t('mcp.testResult.descriptionLabel') }}</div>
<div class="value">{{ tool.description }}</div>
</div>
<div v-if="tool.inputSchema" class="tool-schema">
<div class="label">{{ $t('mcp.testResult.schemaLabel') }}</div>
<div class="schema-content">
<pre>{{ formatSchema(tool.inputSchema) }}</pre>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Resources List -->
<div v-if="result.resources && result.resources.length > 0" class="section">
<div class="section-header">
<h3>{{ $t('mcp.testResult.resourcesTitle') }}</h3>
<t-tag theme="primary" variant="light" size="small">{{ result.resources.length }}</t-tag>
</div>
<div class="resources-grid">
<div
v-for="(resource, index) in result.resources"
:key="index"
class="resource-card"
>
<div class="resource-header">
<t-icon name="file" class="resource-icon" />
<div class="resource-info">
<div class="resource-name">{{ resource.name || resource.uri }}</div>
<div v-if="resource.description" class="resource-desc">
{{ resource.description }}
</div>
</div>
</div>
<div class="resource-meta">
<div v-if="resource.uri" class="resource-uri">
<t-icon name="link" size="14px" />
<span>{{ resource.uri }}</span>
</div>
<t-tag v-if="resource.mimeType" theme="default" variant="light-outline" size="small">
{{ resource.mimeType }}
</t-tag>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div
v-if="
(!result.tools || result.tools.length === 0) &&
(!result.resources || result.resources.length === 0)
"
class="empty-state"
>
<t-empty :description="$t('mcp.testResult.emptyDescription')" />
</div>
</div>
</div>
<template #footer>
<t-button @click="handleClose">{{ $t('common.close') }}</t-button>
</template>
</t-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { MCPTestResult, MCPTool } from '@/api/mcp-service'
import { getMCPToolApprovals, setMCPToolApproval } from '@/api/mcp-service'
import { MessagePlugin } from 'tdesign-vue-next'
import { useI18n } from 'vue-i18n'
interface Props {
visible: boolean
result: MCPTestResult | null
serviceName: string
/** When set, loads/saves per-tool approval flags */
serviceId?: string
}
interface Emits {
(e: 'update:visible', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const expandedToolIndex = ref<number | null>(null)
const { t } = useI18n()
const displayTools = ref<MCPTool[]>([])
const approvalLoading = ref<Record<string, boolean>>({})
const mergeApprovals = async () => {
const tools = props.result?.tools
if (!tools?.length) {
displayTools.value = []
return
}
if (!props.serviceId) {
displayTools.value = tools.map((x) => ({ ...x }))
return
}
try {
const rows = await getMCPToolApprovals(props.serviceId)
const map = new Map(rows.map((r) => [r.tool_name, r.require_approval]))
displayTools.value = tools.map((tool) => ({
...tool,
require_approval: map.get(tool.name) || false,
}))
} catch {
displayTools.value = tools.map((x) => ({ ...x }))
}
}
watch(
() => [props.visible, props.serviceId, props.result?.tools],
() => {
if (props.visible) {
void mergeApprovals()
}
},
{ deep: true }
)
const onRequireApprovalChange = async (toolName: string, value: boolean) => {
if (!props.serviceId) return
approvalLoading.value = { ...approvalLoading.value, [toolName]: true }
try {
await setMCPToolApproval(props.serviceId, toolName, value)
displayTools.value = displayTools.value.map((x) =>
x.name === toolName ? { ...x, require_approval: value } : x
)
} catch (e) {
console.error(e)
MessagePlugin.error(t('mcp.testResult.approvalSaveFailed'))
} finally {
approvalLoading.value = { ...approvalLoading.value, [toolName]: false }
}
}
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
const toggleTool = (index: number) => {
if (expandedToolIndex.value === index) {
expandedToolIndex.value = null
} else {
expandedToolIndex.value = index
}
}
const formatSchema = (schema: any): string => {
if (!schema) return ''
return JSON.stringify(schema, null, 2)
}
const handleClose = () => {
dialogVisible.value = false
expandedToolIndex.value = null
}
</script>
<style scoped lang="less">
.test-result {
padding: 20px 0;
.status-section {
margin-bottom: 24px;
.status-success,
.status-error {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding: 12px 16px;
border-radius: 6px;
background: var(--td-bg-color-secondarycontainer);
:deep(.t-icon) {
font-size: 20px;
}
.status-text {
font-size: 15px;
font-weight: 500;
}
}
.status-success {
:deep(.t-icon) {
color: var(--td-success-color);
}
.status-text {
color: var(--td-success-color);
}
}
.status-error {
:deep(.t-icon) {
color: var(--td-error-color);
}
.status-text {
color: var(--td-error-color);
}
}
.status-message {
margin: 0;
padding: 12px 16px;
background: var(--td-bg-color-secondarycontainer);
border-radius: 6px;
font-size: 13px;
color: var(--td-text-color-secondary);
line-height: 1.6;
word-break: break-word;
}
}
.details-section {
.section {
margin-bottom: 30px;
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
h3 {
font-size: 16px;
font-weight: 600;
margin: 0;
color: var(--td-text-color-primary);
}
}
.tools-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.tool-card {
border: 1px solid var(--td-component-stroke);
border-radius: 8px;
background: var(--td-bg-color-container);
transition: all 0.2s ease;
overflow: hidden;
&:hover {
border-color: var(--td-brand-color);
box-shadow: 0 2px 8px var(--td-brand-color-light);
}
&.tool-card-expanded {
border-color: var(--td-brand-color);
box-shadow: 0 2px 12px var(--td-brand-color-light);
}
.tool-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
cursor: pointer;
user-select: none;
.tool-header-left {
display: flex;
align-items: flex-start;
gap: 12px;
flex: 1;
min-width: 0;
.tool-icon {
color: var(--td-brand-color);
font-size: 18px;
margin-top: 2px;
flex-shrink: 0;
}
.tool-info {
flex: 1;
min-width: 0;
.tool-name {
font-size: 15px;
font-weight: 600;
color: var(--td-text-color-primary);
margin-bottom: 4px;
word-break: break-word;
}
.tool-desc-preview {
font-size: 13px;
color: var(--td-text-color-placeholder);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.tool-header-right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.approval-switch {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--td-text-color-secondary);
.danger-icon {
color: var(--td-warning-color);
font-size: 16px;
}
.approval-label {
max-width: 88px;
line-height: 1.2;
}
}
.expand-icon {
color: var(--td-text-color-placeholder);
font-size: 16px;
flex-shrink: 0;
transition: transform 0.2s ease;
}
}
.tool-card-content {
padding: 0 16px 16px 16px;
border-top: 1px solid var(--td-bg-color-secondarycontainer);
margin-top: 12px;
padding-top: 16px;
animation: slideDown 0.2s ease;
.tool-description,
.tool-schema {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
.label {
font-size: 12px;
font-weight: 600;
color: var(--td-text-color-placeholder);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.value {
font-size: 14px;
color: var(--td-text-color-secondary);
line-height: 1.6;
}
.schema-content {
background: var(--td-bg-color-secondarycontainer);
border: 1px solid var(--td-component-stroke);
border-radius: 6px;
overflow: hidden;
pre {
margin: 0;
padding: 12px;
overflow-x: auto;
font-size: 12px;
font-family: var(--app-font-family-mono);
color: var(--td-text-color-primary);
line-height: 1.6;
background: transparent;
border: none;
}
}
}
}
}
.resources-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.resource-card {
border: 1px solid var(--td-component-stroke);
border-radius: 8px;
background: var(--td-bg-color-container);
padding: 14px 16px;
transition: all 0.2s ease;
&:hover {
border-color: var(--td-brand-color);
box-shadow: 0 2px 8px var(--td-brand-color-light);
}
.resource-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
.resource-icon {
color: var(--td-brand-color);
font-size: 18px;
margin-top: 2px;
flex-shrink: 0;
}
.resource-info {
flex: 1;
min-width: 0;
.resource-name {
font-size: 15px;
font-weight: 600;
color: var(--td-text-color-primary);
margin-bottom: 4px;
word-break: break-word;
}
.resource-desc {
font-size: 13px;
color: var(--td-text-color-placeholder);
line-height: 1.5;
}
}
}
.resource-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 12px;
border-top: 1px solid var(--td-bg-color-secondarycontainer);
.resource-uri {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--td-text-color-placeholder);
:deep(.t-icon) {
color: var(--td-text-color-placeholder);
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
}
.empty-state {
padding: 40px 0;
}
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
@@ -0,0 +1,373 @@
<template>
<div v-if="result" class="mtr">
<!-- 状态条 -->
<div class="mtr-status" :class="result.success ? 'is-success' : 'is-error'">
<t-icon :name="result.success ? 'check-circle-filled' : 'close-circle-filled'" />
<span>{{ result.success ? $t('mcp.testResult.connectionSuccess') : $t('mcp.testResult.connectionFailed') }}</span>
</div>
<p v-if="result.message" class="mtr-message">{{ result.message }}</p>
<template v-if="result.success">
<!-- 工具列表 -->
<div v-if="result.tools && result.tools.length > 0" class="mtr-group">
<div class="mtr-group-title">
<span>{{ $t('mcp.testResult.toolsTitle') }}</span>
<t-tag theme="primary" variant="light" size="small">{{ result.tools.length }}</t-tag>
</div>
<div class="mtr-list">
<div
v-for="(tool, index) in displayTools"
:key="index"
class="mtr-item"
:class="{ 'is-open': expandedToolIndex === index }"
>
<div class="mtr-item-head" @click="toggleTool(index)">
<t-icon name="tools" class="mtr-item-icon" />
<span class="mtr-item-name">{{ tool.name }}</span>
<div class="mtr-item-actions" @click.stop>
<t-tooltip v-if="serviceId" :content="$t('mcp.testResult.requireApprovalTip')" placement="top">
<span class="mtr-approval">
<t-icon name="error-circle-filled" class="mtr-approval-icon" />
<span class="mtr-approval-label">{{ $t('mcp.testResult.requireApproval') }}</span>
<t-switch
:value="tool.require_approval"
:loading="approvalLoading[tool.name]"
size="small"
@change="(v: boolean) => onRequireApprovalChange(tool.name, v)"
/>
</span>
</t-tooltip>
<t-icon
:name="expandedToolIndex === index ? 'chevron-up' : 'chevron-down'"
class="mtr-chevron"
/>
</div>
</div>
<div
v-if="tool.description"
class="mtr-item-desc"
:class="{ 'is-clamped': expandedToolIndex !== index }"
>
{{ tool.description }}
</div>
<div v-if="expandedToolIndex === index && tool.inputSchema" class="mtr-item-schema">
<div class="mtr-schema-label">{{ $t('mcp.testResult.schemaLabel') }}</div>
<pre>{{ formatSchema(tool.inputSchema) }}</pre>
</div>
</div>
</div>
</div>
<!-- 资源列表 -->
<div v-if="result.resources && result.resources.length > 0" class="mtr-group">
<div class="mtr-group-title">
<span>{{ $t('mcp.testResult.resourcesTitle') }}</span>
<t-tag theme="primary" variant="light" size="small">{{ result.resources.length }}</t-tag>
</div>
<div class="mtr-list">
<div v-for="(resource, index) in result.resources" :key="index" class="mtr-item">
<div class="mtr-item-head is-static">
<t-icon name="file" class="mtr-item-icon" />
<span class="mtr-item-name">{{ resource.name || resource.uri }}</span>
<t-tag v-if="resource.mimeType" theme="default" variant="light-outline" size="small">
{{ resource.mimeType }}
</t-tag>
</div>
<div v-if="resource.description" class="mtr-item-desc">{{ resource.description }}</div>
<div v-if="resource.uri" class="mtr-item-uri">
<t-icon name="link" />
<span>{{ resource.uri }}</span>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<t-empty
v-if="(!result.tools || result.tools.length === 0) && (!result.resources || result.resources.length === 0)"
:description="$t('mcp.testResult.emptyDescription')"
class="mtr-empty"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { MCPTestResult, MCPTool } from '@/api/mcp-service'
import { getMCPToolApprovals, setMCPToolApproval } from '@/api/mcp-service'
import { MessagePlugin } from 'tdesign-vue-next'
import { useI18n } from 'vue-i18n'
interface Props {
result: MCPTestResult | null
/** When set, loads/saves per-tool approval flags */
serviceId?: string
/** When true, (re)loads approval flags. Lets the dialog gate the fetch on
* visibility; defaults to true for always-rendered inline usage. */
active?: boolean
}
const props = withDefaults(defineProps<Props>(), { active: true })
const expandedToolIndex = ref<number | null>(null)
const { t } = useI18n()
const displayTools = ref<MCPTool[]>([])
const approvalLoading = ref<Record<string, boolean>>({})
const mergeApprovals = async () => {
const tools = props.result?.tools
if (!tools?.length) {
displayTools.value = []
return
}
if (!props.serviceId) {
displayTools.value = tools.map((x) => ({ ...x }))
return
}
try {
const rows = await getMCPToolApprovals(props.serviceId)
const map = new Map(rows.map((r) => [r.tool_name, r.require_approval]))
displayTools.value = tools.map((tool) => ({
...tool,
require_approval: map.get(tool.name) || false,
}))
} catch {
displayTools.value = tools.map((x) => ({ ...x }))
}
}
watch(
() => [props.active, props.serviceId, props.result?.tools],
() => {
if (props.active) {
void mergeApprovals()
}
},
{ deep: true, immediate: true }
)
const onRequireApprovalChange = async (toolName: string, value: boolean) => {
if (!props.serviceId) return
approvalLoading.value = { ...approvalLoading.value, [toolName]: true }
try {
await setMCPToolApproval(props.serviceId, toolName, value)
displayTools.value = displayTools.value.map((x) =>
x.name === toolName ? { ...x, require_approval: value } : x
)
} catch (e) {
console.error(e)
MessagePlugin.error(t('mcp.testResult.approvalSaveFailed'))
} finally {
approvalLoading.value = { ...approvalLoading.value, [toolName]: false }
}
}
const toggleTool = (index: number) => {
expandedToolIndex.value = expandedToolIndex.value === index ? null : index
}
const formatSchema = (schema: any): string => {
if (!schema) return ''
return JSON.stringify(schema, null, 2)
}
</script>
<style scoped lang="less">
.mtr {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 状态条 */
.mtr-status {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
&.is-success {
color: var(--td-success-color);
background: var(--td-success-color-1, var(--td-bg-color-secondarycontainer));
}
&.is-error {
color: var(--td-error-color);
background: var(--td-error-color-1, var(--td-bg-color-secondarycontainer));
}
:deep(.t-icon) {
font-size: 18px;
}
}
.mtr-message {
margin: 0;
font-size: 13px;
color: var(--td-text-color-secondary);
line-height: 1.6;
word-break: break-word;
}
/* 分组 */
.mtr-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.mtr-group-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: var(--td-text-color-secondary);
}
/* 列表:扁平卡片,靠分隔与圆角,无重阴影,贴合抽屉 */
.mtr-list {
display: flex;
flex-direction: column;
border: 1px solid var(--td-component-stroke);
border-radius: 8px;
overflow: hidden;
}
.mtr-item {
padding: 12px 14px;
background: var(--td-bg-color-container);
& + .mtr-item {
border-top: 1px solid var(--td-component-stroke);
}
&.is-open {
background: var(--td-bg-color-secondarycontainer);
}
}
.mtr-item-head {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
&.is-static {
cursor: default;
}
}
.mtr-item-icon {
flex-shrink: 0;
font-size: 16px;
color: var(--td-brand-color);
}
.mtr-item-name {
flex: 1;
min-width: 0;
font-size: 14px;
font-weight: 600;
color: var(--td-text-color-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mtr-item-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.mtr-approval {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--td-text-color-secondary);
.mtr-approval-icon {
font-size: 15px;
color: var(--td-warning-color);
}
.mtr-approval-label {
white-space: nowrap;
}
}
.mtr-chevron {
font-size: 16px;
color: var(--td-text-color-placeholder);
flex-shrink: 0;
}
/* 描述:独占整行,绝不与右侧开关挤在一起 */
.mtr-item-desc {
margin-top: 6px;
font-size: 13px;
color: var(--td-text-color-placeholder);
line-height: 1.6;
word-break: break-word;
&.is-clamped {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.mtr-item-schema {
margin-top: 10px;
.mtr-schema-label {
font-size: 12px;
font-weight: 600;
color: var(--td-text-color-placeholder);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
pre {
margin: 0;
padding: 10px 12px;
border-radius: 6px;
background: var(--td-bg-color-page);
border: 1px solid var(--td-component-stroke);
overflow-x: auto;
font-size: 12px;
font-family: var(--app-font-family-mono);
color: var(--td-text-color-primary);
line-height: 1.6;
}
}
.mtr-item-uri {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 12px;
color: var(--td-text-color-placeholder);
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.mtr-empty {
padding: 24px 0;
}
</style>
+41 -8
View File
@@ -11,6 +11,7 @@ import (
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/mcp"
"github.com/Tencent/WeKnora/internal/types"
mcpclient "github.com/mark3labs/mcp-go/client"
)
type MCPInput = map[string]any
@@ -173,12 +174,12 @@ func (t *MCPTool) Execute(ctx context.Context, args json.RawMessage) (*types.Too
}
// Get or create MCP client
client, err := t.mcpManager.GetOrCreateClient(t.service)
client, err := t.mcpManager.GetOrCreateClient(ctx, t.service)
if err != nil {
logger.GetLogger(ctx).Errorf("Failed to get MCP client: %v", err)
return &types.ToolResult{
Success: false,
Error: fmt.Sprintf("Failed to connect to MCP service: %v", err),
Error: oauthAwareConnectError(t.service, err),
}, nil
}
@@ -200,12 +201,12 @@ func (t *MCPTool) Execute(ctx context.Context, args json.RawMessage) (*types.Too
logger.GetLogger(ctx).Warnf("MCP tool call failed, retrying with fresh connection: %v", err)
_ = client.Disconnect()
client, err = t.mcpManager.GetOrCreateClient(t.service)
client, err = t.mcpManager.GetOrCreateClient(ctx, t.service)
if err != nil {
logger.GetLogger(ctx).Errorf("Failed to reconnect MCP client: %v", err)
return &types.ToolResult{
Success: false,
Error: fmt.Sprintf("Failed to reconnect to MCP service: %v", err),
Error: oauthAwareConnectError(t.service, err),
}, nil
}
@@ -215,7 +216,7 @@ func (t *MCPTool) Execute(ctx context.Context, args json.RawMessage) (*types.Too
logger.GetLogger(ctx).Errorf("MCP tool call failed: %v", err)
return &types.ToolResult{
Success: false,
Error: fmt.Sprintf("Tool execution failed: %v", err),
Error: oauthAwareConnectError(t.service, err),
}, nil
}
@@ -374,6 +375,38 @@ func extractContentText(content []mcp.ContentItem) string {
return strings.Join(textParts, "\n")
}
// oauthAwareConnectError turns a low-level MCP connect/call error into a
// message the agent (and ultimately the user) can act on. For OAuth services
// that have not been authorized — or whose token expired and could not be
// refreshed — the underlying library surfaces an authorization-required error;
// we translate that into an explicit instruction to authorize, instead of an
// opaque "connection failed".
func oauthAwareConnectError(service *types.MCPService, err error) string {
if service.AuthConfig.IsOAuth() && isAuthorizationRequired(err) {
return fmt.Sprintf(
"MCP service %q requires OAuth authorization. Please open the service settings "+
"and click \"Authorize\" to grant access, then retry.",
service.Name,
)
}
return fmt.Sprintf("Failed to connect to MCP service: %v", err)
}
// isAuthorizationRequired reports whether err indicates the OAuth flow has not
// been completed (no valid token / 401).
func isAuthorizationRequired(err error) bool {
if err == nil {
return false
}
if mcpclient.IsOAuthAuthorizationRequiredError(err) || mcpclient.IsAuthorizationRequiredError(err) {
return true
}
msg := err.Error()
return strings.Contains(msg, "authorization required") ||
strings.Contains(msg, "no valid token") ||
strings.Contains(msg, "401")
}
// sanitizeName sanitizes a name to create a valid identifier
func sanitizeName(name string) string {
// Replace invalid characters with underscores
@@ -422,7 +455,7 @@ func RegisterMCPTools(
}
// Get or create client (this may take time, but has its own timeout)
client, err := mcpManager.GetOrCreateClient(service)
client, err := mcpManager.GetOrCreateClient(ctx, service)
if err != nil {
logger.GetLogger(ctx).Errorf("Failed to create MCP client for service %s: %v", service.Name, err)
continue
@@ -448,7 +481,7 @@ func RegisterMCPTools(
logger.GetLogger(ctx).Warnf("Failed to list tools from MCP service %s (will retry with fresh connection): %v", service.Name, err)
_ = client.Disconnect()
client, err = mcpManager.GetOrCreateClient(service)
client, err = mcpManager.GetOrCreateClient(ctx, service)
if err != nil {
logger.GetLogger(ctx).Errorf("Failed to reconnect MCP client for service %s: %v", service.Name, err)
continue
@@ -504,7 +537,7 @@ func GetMCPToolsInfo(
continue
}
client, err := mcpManager.GetOrCreateClient(service)
client, err := mcpManager.GetOrCreateClient(ctx, service)
if err != nil {
continue
}
@@ -0,0 +1,90 @@
package repository
import (
"context"
"errors"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// mcpOAuthRepository implements interfaces.MCPOAuthRepository.
type mcpOAuthRepository struct {
db *gorm.DB
}
// NewMCPOAuthRepository creates a new MCP OAuth repository.
func NewMCPOAuthRepository(db *gorm.DB) interfaces.MCPOAuthRepository {
return &mcpOAuthRepository{db: db}
}
func (r *mcpOAuthRepository) GetClient(
ctx context.Context, tenantID uint64, serviceID string,
) (*types.MCPOAuthClient, error) {
var client types.MCPOAuthClient
err := r.db.WithContext(ctx).
Where("tenant_id = ? AND service_id = ?", tenantID, serviceID).
First(&client).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &client, nil
}
func (r *mcpOAuthRepository) SaveClient(ctx context.Context, client *types.MCPOAuthClient) error {
client.UpdatedAt = time.Now()
return r.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "tenant_id"}, {Name: "service_id"}},
DoUpdates: clause.AssignmentColumns([]string{"client_id", "client_secret", "redirect_uri", "updated_at"}),
}).
Create(client).Error
}
func (r *mcpOAuthRepository) DeleteClient(ctx context.Context, tenantID uint64, serviceID string) error {
return r.db.WithContext(ctx).
Where("tenant_id = ? AND service_id = ?", tenantID, serviceID).
Delete(&types.MCPOAuthClient{}).Error
}
func (r *mcpOAuthRepository) GetToken(
ctx context.Context, tenantID uint64, userID, serviceID string,
) (*types.MCPOAuthToken, error) {
var token types.MCPOAuthToken
err := r.db.WithContext(ctx).
Where("tenant_id = ? AND user_id = ? AND service_id = ?", tenantID, userID, serviceID).
First(&token).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &token, nil
}
func (r *mcpOAuthRepository) SaveToken(ctx context.Context, token *types.MCPOAuthToken) error {
token.UpdatedAt = time.Now()
return r.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "tenant_id"}, {Name: "user_id"}, {Name: "service_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"access_token", "refresh_token", "token_type", "expires_at", "updated_at",
}),
}).
Create(token).Error
}
func (r *mcpOAuthRepository) DeleteToken(
ctx context.Context, tenantID uint64, userID, serviceID string,
) error {
return r.db.WithContext(ctx).
Where("tenant_id = ? AND user_id = ? AND service_id = ?", tenantID, userID, serviceID).
Delete(&types.MCPOAuthToken{}).Error
}
+41 -5
View File
@@ -18,16 +18,19 @@ import (
type mcpServiceService struct {
mcpServiceRepo interfaces.MCPServiceRepository
mcpManager *mcp.MCPManager
oauthRepo interfaces.MCPOAuthRepository
}
// NewMCPServiceService creates a new MCP service service
func NewMCPServiceService(
mcpServiceRepo interfaces.MCPServiceRepository,
mcpManager *mcp.MCPManager,
oauthRepo interfaces.MCPOAuthRepository,
) interfaces.MCPServiceService {
return &mcpServiceService{
mcpServiceRepo: mcpServiceRepo,
mcpManager: mcpManager,
oauthRepo: oauthRepo,
}
}
@@ -168,14 +171,36 @@ func (s *mcpServiceService) UpdateMCPService(ctx context.Context, service *types
maps.Copy(preHeaders, existing.AuthConfig.CustomHeaders)
}
preAuthType := types.MCPAuthNone
if existing.AuthConfig != nil {
preAuthType = existing.AuthConfig.AuthType
}
// CustomHeaders flows through main PUT (it's structural, not a secret) —
// nil preserves, non-nil replaces. Other AuthConfig fields (APIKey/Token)
// are never accepted via main PUT; the handler strips them up front.
if service.AuthConfig != nil && service.AuthConfig.CustomHeaders != nil {
//
// auth_type / scopes / auth_server_metadata_url are non-secret OAuth
// configuration and also flow through here.
if service.AuthConfig != nil {
if existing.AuthConfig == nil {
existing.AuthConfig = &types.MCPAuthConfig{}
}
existing.AuthConfig.CustomHeaders = service.AuthConfig.CustomHeaders
if service.AuthConfig.CustomHeaders != nil {
existing.AuthConfig.CustomHeaders = service.AuthConfig.CustomHeaders
}
// Only overwrite OAuth config when explicitly provided, so a partial
// PUT that carries only custom_headers does not wipe an existing
// auth_type / scopes. (Empty/absent is treated as "no change".)
if service.AuthConfig.AuthType != types.MCPAuthNone {
existing.AuthConfig.AuthType = service.AuthConfig.AuthType
}
if service.AuthConfig.Scopes != nil {
existing.AuthConfig.Scopes = service.AuthConfig.Scopes
}
if service.AuthConfig.AuthServerMetadataURL != "" {
existing.AuthConfig.AuthServerMetadataURL = service.AuthConfig.AuthServerMetadataURL
}
}
// Merge updates: only update fields that are provided (non-zero or explicitly set)
@@ -257,6 +282,9 @@ func (s *mcpServiceService) UpdateMCPService(ctx context.Context, service *types
if !maps.Equal(currHeaders, preHeaders) {
configChanged = true
}
if existing.AuthConfig != nil && existing.AuthConfig.AuthType != preAuthType {
configChanged = true
}
name := secutils.SanitizeForLog(existing.Name)
// Close existing client connection if:
// 1. Service is now disabled (need to close connection)
@@ -321,10 +349,18 @@ func (s *mcpServiceService) TestMCPService(
return nil, fmt.Errorf("MCP service not found")
}
// Create temporary client for testing
// Create temporary client for testing. For OAuth services, wire the
// per-user token store so the test connects with the current user's
// authorization (and surfaces an authorization-required message when the
// user has not authorized yet).
config := &mcp.ClientConfig{
Service: service,
}
if service.AuthConfig.IsOAuth() {
config.OAuthRepo = s.oauthRepo
config.TenantID, _ = types.TenantIDFromContext(ctx)
config.UserID, _ = types.UserIDFromContext(ctx)
}
client, err := mcp.NewMCPClient(config)
if err != nil {
@@ -397,7 +433,7 @@ func (s *mcpServiceService) GetMCPServiceTools(
}
// Get or create client
client, err := s.mcpManager.GetOrCreateClient(service)
client, err := s.mcpManager.GetOrCreateClient(ctx, service)
if err != nil {
return nil, fmt.Errorf("failed to get MCP client: %w", err)
}
@@ -535,7 +571,7 @@ func (s *mcpServiceService) GetMCPServiceResources(
}
// Get or create client
client, err := s.mcpManager.GetOrCreateClient(service)
client, err := s.mcpManager.GetOrCreateClient(ctx, service)
if err != nil {
return nil, fmt.Errorf("failed to get MCP client: %w", err)
}
@@ -111,7 +111,8 @@ func newTestService() (*mcpServiceService, *fakeMCPRepo) {
repo := newFakeMCPRepo()
svc := &mcpServiceService{
mcpServiceRepo: repo,
mcpManager: mcp.NewMCPManager(),
mcpManager: mcp.NewMCPManager(nil),
oauthRepo: nil,
}
return svc, repo
}
+3
View File
@@ -153,6 +153,7 @@ func BuildContainer(container *dig.Container) *dig.Container {
must(container.Provide(memoryRepo.NewMemoryRepository))
must(container.Provide(repository.NewMCPServiceRepository))
must(container.Provide(repository.NewMCPToolApprovalRepository))
must(container.Provide(repository.NewMCPOAuthRepository))
must(container.Provide(repository.NewCustomAgentRepository))
must(container.Provide(repository.NewOrganizationRepository))
must(container.Provide(repository.NewKBShareRepository))
@@ -171,6 +172,7 @@ func BuildContainer(container *dig.Container) *dig.Container {
// MCP manager for managing MCP client connections
logger.Debugf(ctx, "[Container] Registering MCP manager...")
must(container.Provide(mcp.NewMCPManager))
must(container.Provide(mcp.NewOAuthManager))
// Business service layer
logger.Debugf(ctx, "[Container] Registering business services...")
@@ -325,6 +327,7 @@ func BuildContainer(container *dig.Container) *dig.Container {
must(container.Provide(handler.NewSystemHandler))
must(container.Provide(handler.NewMCPServiceHandler))
must(container.Provide(handler.NewMCPCredentialsHandler))
must(container.Provide(handler.NewMCPOAuthHandler))
must(container.Provide(handler.NewModelCredentialsHandler))
must(container.Provide(handler.NewWebSearchProviderCredentialsHandler))
must(container.Provide(handler.NewDataSourceCredentialsHandler))
+11 -3
View File
@@ -45,9 +45,14 @@ type MCPServiceResponse struct {
}
// MCPAuthConfigResponse intentionally has no APIKey or Token fields. Their
// presence is signalled via MCPServiceResponse.Credentials.
// presence is signalled via MCPServiceResponse.Credentials. AuthType, Scopes
// and AuthServerMetadataURL are non-secret OAuth configuration and are safe to
// echo back so the UI can render the current strategy.
type MCPAuthConfigResponse struct {
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
AuthType types.MCPAuthType `json:"auth_type,omitempty"`
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
Scopes []string `json:"scopes,omitempty"`
AuthServerMetadataURL string `json:"auth_server_metadata_url,omitempty"`
}
// CredentialFieldMetadata reports whether a credential field has a value
@@ -84,7 +89,10 @@ func NewMCPServiceResponse(svc *types.MCPService) *MCPServiceResponse {
}
if svc.AuthConfig != nil {
resp.AuthConfig = &MCPAuthConfigResponse{
CustomHeaders: svc.AuthConfig.CustomHeaders,
AuthType: svc.AuthConfig.AuthType,
CustomHeaders: svc.AuthConfig.CustomHeaders,
Scopes: svc.AuthConfig.Scopes,
AuthServerMetadataURL: svc.AuthConfig.AuthServerMetadataURL,
}
}
if svc.IsBuiltin {
+204
View File
@@ -0,0 +1,204 @@
package handler
import (
"net/http"
"strings"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/mcp"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
)
// MCPOAuthHandler exposes the per-user MCP OAuth2 authorization-code flow:
// kicking off authorization (discovery + dynamic client registration + PKCE),
// receiving the provider redirect, reporting authorization status, and
// revoking a user's token.
type MCPOAuthHandler struct {
oauth *mcp.OAuthManager
mcpManager *mcp.MCPManager
svc interfaces.MCPServiceService
}
// NewMCPOAuthHandler constructs the handler.
func NewMCPOAuthHandler(
oauth *mcp.OAuthManager,
mcpManager *mcp.MCPManager,
svc interfaces.MCPServiceService,
) *MCPOAuthHandler {
return &MCPOAuthHandler{oauth: oauth, mcpManager: mcpManager, svc: svc}
}
type mcpOAuthAuthorizeRequest struct {
// RedirectURI is the absolute backend callback URL registered with the
// authorization server (e.g. https://host/api/v1/mcp-services/oauth/callback).
RedirectURI string `json:"redirect_uri"`
// FrontendRedirect is where the callback bounces the browser when done
// (e.g. the MCP settings page). Optional; defaults to "/".
FrontendRedirect string `json:"frontend_redirect"`
}
// AuthorizeURL begins authorization and returns the URL the browser must open.
//
// AuthorizeURL godoc
// @Summary 发起 MCP OAuth 授权
// @Description 对使用 OAuth 的 MCP 服务执行发现与动态客户端注册,返回浏览器应跳转的授权地址(当前用户维度)
// @Tags MCP服务
// @Accept json
// @Produce json
// @Param id path string true "MCP 服务 ID"
// @Param request body map[string]interface{} true "{redirect_uri: string, frontend_redirect?: string}"
// @Success 200 {object} map[string]interface{} "{authorization_url: string}"
// @Failure 400 {object} errors.AppError
// @Security Bearer
// @Router /mcp-services/{id}/oauth/authorize-url [post]
func (h *MCPOAuthHandler) AuthorizeURL(c *gin.Context) {
ctx := c.Request.Context()
serviceID := c.Param("id")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
userID, _ := types.UserIDFromContext(ctx)
if tenantID == 0 || userID == "" {
c.Error(errors.NewUnauthorizedError("authentication required"))
return
}
var req mcpOAuthAuthorizeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
req.RedirectURI = strings.TrimSpace(req.RedirectURI)
if req.RedirectURI == "" {
c.Error(errors.NewValidationError("redirect_uri is required"))
return
}
if req.FrontendRedirect == "" {
req.FrontendRedirect = "/"
}
service, err := h.svc.GetMCPServiceByID(ctx, tenantID, serviceID)
if err != nil || service == nil {
c.Error(errors.NewNotFoundError("MCP service not found"))
return
}
if !service.AuthConfig.IsOAuth() {
c.Error(errors.NewValidationError("MCP service is not configured to use OAuth"))
return
}
authURL, err := h.oauth.StartAuthorization(ctx, service, tenantID, userID, req.RedirectURI, req.FrontendRedirect)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"service_id": secutils.SanitizeForLog(serviceID),
})
c.Error(errors.NewInternalServerError("failed to start authorization: " + err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"authorization_url": authURL}})
}
// Callback receives the authorization-server redirect. It is registered as a
// public (no-bearer) route; the opaque single-use `state` parameter
// authenticates the request. On completion it redirects the browser back to
// the frontend with the result encoded in the URL fragment.
//
// Callback godoc
// @Summary MCP OAuth 回调
// @Description 接收授权服务器回调并完成 code 交换,随后重定向回前端
// @Tags MCP服务
// @Param code query string false "授权码"
// @Param state query string false "状态参数"
// @Param error query string false "授权错误码"
// @Success 302
// @Router /mcp-services/oauth/callback [get]
func (h *MCPOAuthHandler) Callback(c *gin.Context) {
ctx := c.Request.Context()
state := strings.TrimSpace(c.Query("state"))
code := strings.TrimSpace(c.Query("code"))
providerErr := strings.TrimSpace(c.Query("error"))
const fallbackRedirect = "/"
if providerErr != "" {
c.Redirect(http.StatusFound, fallbackRedirect+"#mcp_oauth_error="+urlQueryEscape(providerErr))
return
}
if state == "" || code == "" {
c.Redirect(http.StatusFound, fallbackRedirect+"#mcp_oauth_error="+urlQueryEscape("missing_code_or_state"))
return
}
frontendRedirect, err := h.oauth.CompleteAuthorization(ctx, state, code)
if frontendRedirect == "" {
frontendRedirect = fallbackRedirect
}
if err != nil {
logger.Errorf(ctx, "MCP OAuth callback failed: %v", err)
c.Redirect(http.StatusFound, frontendRedirect+"#mcp_oauth_error="+urlQueryEscape("authorization_failed"))
return
}
c.Redirect(http.StatusFound, frontendRedirect+"#mcp_oauth_result=success")
}
// Status reports whether the current user has authorized this service.
//
// Status godoc
// @Summary 查询 MCP OAuth 授权状态
// @Description 返回当前用户对指定 MCP 服务是否已完成 OAuth 授权
// @Tags MCP服务
// @Produce json
// @Param id path string true "MCP 服务 ID"
// @Success 200 {object} map[string]interface{} "{authorized: bool}"
// @Security Bearer
// @Router /mcp-services/{id}/oauth/status [get]
func (h *MCPOAuthHandler) Status(c *gin.Context) {
ctx := c.Request.Context()
serviceID := c.Param("id")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
userID, _ := types.UserIDFromContext(ctx)
if tenantID == 0 || userID == "" {
c.Error(errors.NewUnauthorizedError("authentication required"))
return
}
authorized, err := h.oauth.IsAuthorized(ctx, tenantID, userID, serviceID)
if err != nil {
c.Error(errors.NewInternalServerError("failed to query authorization status: " + err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"authorized": authorized}})
}
// Revoke removes the current user's stored token and recycles connections.
//
// Revoke godoc
// @Summary 撤销 MCP OAuth 授权
// @Description 删除当前用户对指定 MCP 服务的 OAuth 令牌
// @Tags MCP服务
// @Produce json
// @Param id path string true "MCP 服务 ID"
// @Success 204
// @Security Bearer
// @Router /mcp-services/{id}/oauth/token [delete]
func (h *MCPOAuthHandler) Revoke(c *gin.Context) {
ctx := c.Request.Context()
serviceID := c.Param("id")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
userID, _ := types.UserIDFromContext(ctx)
if tenantID == 0 || userID == "" {
c.Error(errors.NewUnauthorizedError("authentication required"))
return
}
if err := h.oauth.Revoke(ctx, tenantID, userID, serviceID); err != nil {
c.Error(errors.NewInternalServerError("failed to revoke authorization: " + err.Error()))
return
}
// Recycle any cached connections so a subsequent call re-authorizes.
_ = h.mcpManager.CloseClient(serviceID)
c.Status(http.StatusNoContent)
}
+17
View File
@@ -298,6 +298,23 @@ func (h *MCPServiceHandler) UpdateMCPService(c *gin.Context) {
}
service.AuthConfig.CustomHeaders = headers
}
// auth_type and scopes are non-secret OAuth configuration; allow them
// through the main PUT so a service can be switched to/from OAuth.
if authType, ok := authConfig["auth_type"].(string); ok {
service.AuthConfig.AuthType = types.MCPAuthType(authType)
}
if scopes, ok := authConfig["scopes"].([]interface{}); ok {
list := make([]string, 0, len(scopes))
for _, s := range scopes {
if str, ok := s.(string); ok {
list = append(list, str)
}
}
service.AuthConfig.Scopes = list
}
if metaURL, ok := authConfig["auth_server_metadata_url"].(string); ok {
service.AuthConfig.AuthServerMetadataURL = metaURL
}
}
if advancedConfig, ok := updateData["advanced_config"].(map[string]interface{}); ok {
service.AdvancedConfig = &types.MCPAdvancedConfig{}
+71 -10
View File
@@ -11,6 +11,7 @@ import (
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
@@ -49,6 +50,13 @@ type MCPClient interface {
// ClientConfig represents configuration for creating an MCP client
type ClientConfig struct {
Service *types.MCPService
// OAuth wiring (only used when Service.AuthConfig.AuthType == oauth).
// The token store is scoped to (TenantID, UserID, Service.ID) so each
// user connects with their own access/refresh token.
TenantID uint64
UserID string
OAuthRepo interfaces.MCPOAuthRepository
}
// mcpGoClient wraps mark3labs/mcp-go client to implement our MCPClient interface
@@ -92,18 +100,33 @@ func NewMCPClient(config *ClientConfig) (MCPClient, error) {
}
}
// Build OAuth config when this service uses the OAuth strategy. The
// client_id comes from the dynamically-registered client persisted at
// authorization time; the token store loads the invoking user's token
// and transparently refreshes it.
oauthConfig, useOAuth, err := buildOAuthConfig(config, httpClient)
if err != nil {
return nil, err
}
// Create client based on transport type
var mcpClient *client.Client
var err error
switch config.Service.TransportType {
case types.MCPTransportSSE:
if config.Service.URL == nil || *config.Service.URL == "" {
return nil, fmt.Errorf("URL is required for SSE transport")
}
mcpClient, err = client.NewSSEMCPClient(*config.Service.URL,
client.WithHTTPClient(httpClient),
client.WithHeaders(headers),
)
if useOAuth {
mcpClient, err = client.NewOAuthSSEClient(*config.Service.URL, oauthConfig,
transport.WithHTTPClient(httpClient),
transport.WithHeaders(headers),
)
} else {
mcpClient, err = client.NewSSEMCPClient(*config.Service.URL,
client.WithHTTPClient(httpClient),
client.WithHeaders(headers),
)
}
if err != nil {
return nil, fmt.Errorf("failed to create SSE client: %w", err)
}
@@ -111,11 +134,18 @@ func NewMCPClient(config *ClientConfig) (MCPClient, error) {
if config.Service.URL == nil || *config.Service.URL == "" {
return nil, fmt.Errorf("URL is required for HTTP Streamable transport")
}
// For HTTP streamable, we need to use transport options
mcpClient, err = client.NewStreamableHttpClient(*config.Service.URL,
transport.WithHTTPBasicClient(httpClient),
transport.WithHTTPHeaders(headers),
)
if useOAuth {
mcpClient, err = client.NewOAuthStreamableHttpClient(*config.Service.URL, oauthConfig,
transport.WithHTTPBasicClient(httpClient),
transport.WithHTTPHeaders(headers),
)
} else {
// For HTTP streamable, we need to use transport options
mcpClient, err = client.NewStreamableHttpClient(*config.Service.URL,
transport.WithHTTPBasicClient(httpClient),
transport.WithHTTPHeaders(headers),
)
}
if err != nil {
return nil, fmt.Errorf("failed to create HTTP streamable client: %w", err)
}
@@ -134,6 +164,37 @@ func NewMCPClient(config *ClientConfig) (MCPClient, error) {
return instance, nil
}
// buildOAuthConfig returns the OAuth configuration for an OAuth-enabled MCP
// service, or (_, false, nil) when the service does not use OAuth. It loads
// the dynamically-registered client_id and wires a per-user token store so
// the transport injects the invoking user's bearer token and refreshes it.
func buildOAuthConfig(config *ClientConfig, httpClient *http.Client) (transport.OAuthConfig, bool, error) {
svc := config.Service
if !svc.AuthConfig.IsOAuth() {
return transport.OAuthConfig{}, false, nil
}
if config.OAuthRepo == nil {
return transport.OAuthConfig{}, false, fmt.Errorf("OAuth repository is required for OAuth MCP services")
}
if config.UserID == "" {
return transport.OAuthConfig{}, false, fmt.Errorf("user context is required to connect to an OAuth MCP service")
}
oauthCfg := transport.OAuthConfig{
Scopes: svc.AuthConfig.Scopes,
TokenStore: newDBTokenStore(config.OAuthRepo, config.TenantID, config.UserID, svc.ID),
PKCEEnabled: true,
AuthServerMetadataURL: svc.AuthConfig.AuthServerMetadataURL,
HTTPClient: httpClient,
}
if regClient, err := config.OAuthRepo.GetClient(context.Background(), config.TenantID, svc.ID); err == nil && regClient != nil {
oauthCfg.ClientID = regClient.ClientID
oauthCfg.ClientSecret = regClient.ClientSecret
oauthCfg.RedirectURI = regClient.RedirectURI
}
return oauthCfg, true, nil
}
// onConnectionLost callback when the connection is lost
func (c *mcpGoClient) onConnectionLost(err error) {
_ = c.Disconnect()
+57 -22
View File
@@ -3,29 +3,34 @@ package mcp
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// MCPManager manages MCP client connections
type MCPManager struct {
clients map[string]MCPClient // serviceID -> client
clients map[string]MCPClient // cacheKey -> client
clientsMu sync.RWMutex
oauthRepo interfaces.MCPOAuthRepository
ctx context.Context
cancel context.CancelFunc
}
// NewMCPManager creates a new MCP manager
func NewMCPManager() *MCPManager {
// NewMCPManager creates a new MCP manager. oauthRepo is used to wire per-user
// OAuth token stores for OAuth-enabled MCP services.
func NewMCPManager(oauthRepo interfaces.MCPOAuthRepository) *MCPManager {
ctx, cancel := context.WithCancel(context.Background())
manager := &MCPManager{
clients: make(map[string]MCPClient),
ctx: ctx,
cancel: cancel,
clients: make(map[string]MCPClient),
oauthRepo: oauthRepo,
ctx: ctx,
cancel: cancel,
}
// Start cleanup goroutine
@@ -34,10 +39,23 @@ func NewMCPManager() *MCPManager {
return manager
}
// cacheKey computes the connection-cache key for a service. OAuth services are
// keyed per user (each user connects with their own token); all other services
// share a single connection per service ID.
func cacheKey(service *types.MCPService, userID string) string {
if service.AuthConfig.IsOAuth() {
return service.ID + "\x00" + userID
}
return service.ID
}
// GetOrCreateClient gets an existing client or creates a new one
// Caches and reuses existing connections for SSE/HTTP Streamable
// Note: Stdio transport is disabled for security reasons
func (m *MCPManager) GetOrCreateClient(service *types.MCPService) (MCPClient, error) {
//
// For OAuth-enabled services the connection is keyed per user (derived from
// ctx) so each user connects with their own token.
func (m *MCPManager) GetOrCreateClient(ctx context.Context, service *types.MCPService) (MCPClient, error) {
// Check if service is enabled
if !service.Enabled {
return nil, fmt.Errorf("MCP service %s is not enabled", service.Name)
@@ -48,9 +66,20 @@ func (m *MCPManager) GetOrCreateClient(service *types.MCPService) (MCPClient, er
return nil, fmt.Errorf("stdio transport is disabled for security reasons; please use SSE or HTTP Streamable transport instead")
}
var tenantID uint64
var userID string
if service.AuthConfig.IsOAuth() {
tenantID, _ = types.TenantIDFromContext(ctx)
userID, _ = types.UserIDFromContext(ctx)
if userID == "" {
return nil, fmt.Errorf("user context is required to connect to OAuth MCP service %s", service.Name)
}
}
key := cacheKey(service, userID)
// For SSE/HTTP Streamable, check if client already exists and reuse
m.clientsMu.RLock()
client, exists := m.clients[service.ID]
client, exists := m.clients[key]
m.clientsMu.RUnlock()
if exists && client.IsConnected() {
@@ -62,14 +91,17 @@ func (m *MCPManager) GetOrCreateClient(service *types.MCPService) (MCPClient, er
defer m.clientsMu.Unlock()
// Double check after acquiring write lock
client, exists = m.clients[service.ID]
client, exists = m.clients[key]
if exists && client.IsConnected() {
return client, nil
}
// Create new client
config := &ClientConfig{
Service: service,
Service: service,
TenantID: tenantID,
UserID: userID,
OAuthRepo: m.oauthRepo,
}
client, err := NewMCPClient(config)
@@ -89,7 +121,7 @@ func (m *MCPManager) GetOrCreateClient(service *types.MCPService) (MCPClient, er
}
// Store client (only for non-stdio transports)
m.clients[service.ID] = client
m.clients[key] = client
logger.GetLogger(m.ctx).Infof("MCP client created and initialized for service: %s", service.Name)
return client, nil
@@ -128,22 +160,25 @@ func (m *MCPManager) GetClient(serviceID string) (MCPClient, bool) {
return client, exists
}
// CloseClient closes and removes a specific client
// CloseClient closes and removes all cached connections for a service. For
// OAuth services this spans every per-user connection (keys are prefixed with
// the service ID).
func (m *MCPManager) CloseClient(serviceID string) error {
m.clientsMu.Lock()
defer m.clientsMu.Unlock()
client, exists := m.clients[serviceID]
if !exists {
return nil
for key, client := range m.clients {
// Match the plain service-ID key as well as per-user OAuth keys
// ("<serviceID>\x00<userID>").
if key != serviceID && !strings.HasPrefix(key, serviceID+"\x00") {
continue
}
if err := client.Disconnect(); err != nil {
logger.GetLogger(m.ctx).Errorf("Failed to disconnect MCP client %s: %v", key, err)
}
delete(m.clients, key)
logger.GetLogger(m.ctx).Infof("MCP client closed: %s", key)
}
if err := client.Disconnect(); err != nil {
logger.GetLogger(m.ctx).Errorf("Failed to disconnect MCP client %s: %v", serviceID, err)
}
delete(m.clients, serviceID)
logger.GetLogger(m.ctx).Infof("MCP client closed: %s", serviceID)
return nil
}
+189
View File
@@ -0,0 +1,189 @@
package mcp
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/redis/go-redis/v9"
)
// clientRegistrationName is sent as client_name during dynamic client
// registration (RFC 7591).
const clientRegistrationName = "WeKnora"
// OAuthManager orchestrates the MCP OAuth2 authorization-code flow:
// discovery, dynamic client registration, the authorize redirect, and the
// callback code exchange. Tokens are persisted per (tenant, user, service);
// the registered client is persisted per (tenant, service) and reused.
type OAuthManager struct {
repo interfaces.MCPOAuthRepository
serviceRepo interfaces.MCPServiceRepository
states *oauthStateStore
}
// NewOAuthManager constructs the OAuth manager. rdb may be nil (Lite mode),
// in which case in-flight authorization states are kept in memory.
func NewOAuthManager(
repo interfaces.MCPOAuthRepository,
serviceRepo interfaces.MCPServiceRepository,
rdb *redis.Client,
) *OAuthManager {
return &OAuthManager{
repo: repo,
serviceRepo: serviceRepo,
states: newOAuthStateStore(rdb),
}
}
// newHandler builds an OAuth handler bound to a service + per-user token store.
func (m *OAuthManager) newHandler(
ctx context.Context, service *types.MCPService, tenantID uint64, userID, redirectURI string,
) (*transport.OAuthHandler, error) {
if service.URL == nil || *service.URL == "" {
return nil, fmt.Errorf("MCP service URL is required for OAuth")
}
cfg := transport.OAuthConfig{
RedirectURI: redirectURI,
Scopes: service.AuthConfig.Scopes,
TokenStore: newDBTokenStore(m.repo, tenantID, userID, service.ID),
PKCEEnabled: true,
AuthServerMetadataURL: service.AuthConfig.AuthServerMetadataURL,
}
if existing, err := m.repo.GetClient(ctx, tenantID, service.ID); err == nil && existing != nil {
cfg.ClientID = existing.ClientID
cfg.ClientSecret = existing.ClientSecret
}
h := transport.NewOAuthHandler(cfg)
h.SetBaseURL(*service.URL)
return h, nil
}
// StartAuthorization performs discovery + (one-time) dynamic client
// registration, then returns the authorization URL the browser should visit.
// redirectURI is the backend callback URL registered with the auth server;
// frontendRedirect is where the callback bounces the browser when finished.
func (m *OAuthManager) StartAuthorization(
ctx context.Context,
service *types.MCPService,
tenantID uint64,
userID, redirectURI, frontendRedirect string,
) (string, error) {
if !service.AuthConfig.IsOAuth() {
return "", fmt.Errorf("MCP service %s does not use OAuth", service.ID)
}
h, err := m.newHandler(ctx, service, tenantID, userID, redirectURI)
if err != nil {
return "", err
}
// Register a client dynamically if we don't have one yet for this service.
existing, _ := m.repo.GetClient(ctx, tenantID, service.ID)
if existing == nil {
if err := h.RegisterClient(ctx, clientRegistrationName); err != nil {
return "", fmt.Errorf("dynamic client registration failed: %w", err)
}
clientID := h.GetClientID()
if clientID == "" {
return "", fmt.Errorf("dynamic client registration returned an empty client_id")
}
if err := m.repo.SaveClient(ctx, &types.MCPOAuthClient{
TenantID: tenantID,
ServiceID: service.ID,
ClientID: clientID,
RedirectURI: redirectURI,
}); err != nil {
logger.GetLogger(ctx).Warnf("failed to persist MCP oauth client: %v", err)
}
}
verifier, err := transport.GenerateCodeVerifier()
if err != nil {
return "", fmt.Errorf("failed to generate PKCE verifier: %w", err)
}
challenge := transport.GenerateCodeChallenge(verifier)
state, err := transport.GenerateState()
if err != nil {
return "", fmt.Errorf("failed to generate state: %w", err)
}
authURL, err := h.GetAuthorizationURL(ctx, state, challenge)
if err != nil {
return "", fmt.Errorf("failed to build authorization URL: %w", err)
}
if err := m.states.Put(ctx, state, OAuthState{
TenantID: tenantID,
UserID: userID,
ServiceID: service.ID,
CodeVerifier: verifier,
ClientID: h.GetClientID(),
RedirectURI: redirectURI,
FrontendRedirect: frontendRedirect,
}); err != nil {
return "", fmt.Errorf("failed to persist authorization state: %w", err)
}
return authURL, nil
}
// CompleteAuthorization handles the provider callback: it validates state,
// exchanges the code for tokens (PKCE), and persists the per-user token.
// Returns the frontend redirect URL recorded at StartAuthorization time.
func (m *OAuthManager) CompleteAuthorization(
ctx context.Context, state, code string,
) (frontendRedirect string, err error) {
st, err := m.states.Take(ctx, state)
if err != nil {
return "", err
}
frontendRedirect = st.FrontendRedirect
service, err := m.serviceRepo.GetByID(ctx, st.TenantID, st.ServiceID)
if err != nil {
return frontendRedirect, fmt.Errorf("failed to load MCP service: %w", err)
}
if service == nil {
return frontendRedirect, fmt.Errorf("MCP service not found")
}
h, err := m.newHandler(ctx, service, st.TenantID, st.UserID, st.RedirectURI)
if err != nil {
return frontendRedirect, err
}
// Re-prime the expected state so the library's CSRF check passes after
// reconstructing the handler in this separate request.
h.SetExpectedState(state)
if err := h.ProcessAuthorizationResponse(ctx, code, state, st.CodeVerifier); err != nil {
return frontendRedirect, fmt.Errorf("token exchange failed: %w", err)
}
// ProcessAuthorizationResponse persists the token via the TokenStore.
logger.GetLogger(ctx).Infof(
"MCP OAuth authorized: service=%s user=%s", st.ServiceID, st.UserID,
)
return frontendRedirect, nil
}
// IsAuthorized reports whether the given user has a stored (non-empty) token
// for the service.
func (m *OAuthManager) IsAuthorized(
ctx context.Context, tenantID uint64, userID, serviceID string,
) (bool, error) {
tok, err := m.repo.GetToken(ctx, tenantID, userID, serviceID)
if err != nil {
return false, err
}
return tok != nil && tok.AccessToken != "", nil
}
// Revoke removes the user's stored token for the service.
func (m *OAuthManager) Revoke(
ctx context.Context, tenantID uint64, userID, serviceID string,
) error {
return m.repo.DeleteToken(ctx, tenantID, userID, serviceID)
}
+125
View File
@@ -0,0 +1,125 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
// oauthStateTTL bounds how long an in-flight authorization may take from
// "authorize-url issued" to "callback received".
const oauthStateTTL = 10 * time.Minute
// OAuthState is the transient data needed to complete an authorization-code
// exchange. It is keyed by the opaque OAuth `state` parameter and MUST hold
// the PKCE code_verifier, which is a secret that must never reach the
// authorization server — hence server-side storage rather than encoding it
// into the state parameter.
type OAuthState struct {
TenantID uint64 `json:"tenant_id"`
UserID string `json:"user_id"`
ServiceID string `json:"service_id"`
CodeVerifier string `json:"code_verifier"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
// FrontendRedirect is where the backend callback redirects the browser
// after completing (or failing) the exchange.
FrontendRedirect string `json:"frontend_redirect"`
}
// oauthStateStore persists in-flight OAuth states. Backed by Redis when
// available (so the callback can land on any backend replica); falls back to
// a TTL in-memory map for single-instance / Lite deployments.
type oauthStateStore struct {
rdb *redis.Client
mu sync.Mutex
mem map[string]memStateEntry
}
type memStateEntry struct {
value OAuthState
expiresAt time.Time
}
func newOAuthStateStore(rdb *redis.Client) *oauthStateStore {
s := &oauthStateStore{rdb: rdb, mem: make(map[string]memStateEntry)}
if rdb == nil {
go s.gcLoop()
}
return s
}
func (s *oauthStateStore) key(state string) string {
ns := strings.TrimSpace(os.Getenv("WEKNORA_REDIS_NAMESPACE"))
if ns != "" {
return "weknora:mcp_oauth_state:" + ns + ":" + state
}
return "weknora:mcp_oauth_state:" + state
}
// Put stores a state with a fixed TTL.
func (s *oauthStateStore) Put(ctx context.Context, state string, value OAuthState) error {
if s.rdb != nil {
data, err := json.Marshal(value)
if err != nil {
return err
}
return s.rdb.Set(ctx, s.key(state), data, oauthStateTTL).Err()
}
s.mu.Lock()
defer s.mu.Unlock()
s.mem[state] = memStateEntry{value: value, expiresAt: time.Now().Add(oauthStateTTL)}
return nil
}
// Take retrieves and deletes a state (single-use). Returns an error if the
// state is unknown or expired.
func (s *oauthStateStore) Take(ctx context.Context, state string) (OAuthState, error) {
if s.rdb != nil {
data, err := s.rdb.GetDel(ctx, s.key(state)).Bytes()
if err != nil {
if err == redis.Nil {
return OAuthState{}, fmt.Errorf("oauth state not found or expired")
}
return OAuthState{}, err
}
var v OAuthState
if err := json.Unmarshal(data, &v); err != nil {
return OAuthState{}, err
}
return v, nil
}
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.mem[state]
if !ok {
return OAuthState{}, fmt.Errorf("oauth state not found or expired")
}
delete(s.mem, state)
if time.Now().After(entry.expiresAt) {
return OAuthState{}, fmt.Errorf("oauth state not found or expired")
}
return entry.value, nil
}
func (s *oauthStateStore) gcLoop() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
s.mu.Lock()
for k, v := range s.mem {
if now.After(v.expiresAt) {
delete(s.mem, k)
}
}
s.mu.Unlock()
}
}
+70
View File
@@ -0,0 +1,70 @@
package mcp
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/mark3labs/mcp-go/client/transport"
)
// dbTokenStore is a transport.TokenStore backed by the MCPOAuthRepository,
// scoped to a single (tenant, user, service) tuple. The mcp-go OAuth handler
// calls GetToken before each request (refreshing via refresh_token when
// expired) and SaveToken after a successful authorization or refresh, so this
// store transparently persists refreshed tokens back to the database.
type dbTokenStore struct {
repo interfaces.MCPOAuthRepository
tenantID uint64
userID string
serviceID string
}
// newDBTokenStore creates a per-user, per-service token store.
func newDBTokenStore(
repo interfaces.MCPOAuthRepository, tenantID uint64, userID, serviceID string,
) *dbTokenStore {
return &dbTokenStore{repo: repo, tenantID: tenantID, userID: userID, serviceID: serviceID}
}
// GetToken returns the persisted token, or transport.ErrNoToken when the user
// has not authorized this service yet.
func (s *dbTokenStore) GetToken(ctx context.Context) (*transport.Token, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
row, err := s.repo.GetToken(ctx, s.tenantID, s.userID, s.serviceID)
if err != nil {
return nil, err
}
if row == nil || row.AccessToken == "" {
return nil, transport.ErrNoToken
}
return &transport.Token{
AccessToken: row.AccessToken,
RefreshToken: row.RefreshToken,
TokenType: row.TokenType,
ExpiresAt: row.ExpiresAt,
}, nil
}
// SaveToken persists a freshly issued or refreshed token.
func (s *dbTokenStore) SaveToken(ctx context.Context, token *transport.Token) error {
if err := ctx.Err(); err != nil {
return err
}
expiresAt := token.ExpiresAt
if expiresAt.IsZero() && token.ExpiresIn > 0 {
expiresAt = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
}
return s.repo.SaveToken(ctx, &types.MCPOAuthToken{
TenantID: s.tenantID,
UserID: s.userID,
ServiceID: s.serviceID,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
TokenType: token.TokenType,
ExpiresAt: expiresAt,
})
}
+4
View File
@@ -36,6 +36,10 @@ var noAuthAPI = map[string][]string{
"/api/v1/auth/oidc/config": {"GET"},
"/api/v1/auth/oidc/url": {"GET"},
"/api/v1/auth/oidc/callback": {"GET"},
// MCP OAuth provider redirect: the third-party authorization server
// redirects the browser here without a WeKnora bearer token. The request
// is authenticated by the opaque, single-use `state` parameter instead.
"/api/v1/mcp-oauth/callback": {"GET"},
"/api/v1/auth/refresh": {"POST"},
// IM platforms (Feishu, Slack, etc.) commonly issue a HEAD request
// before GET to validate Content-Type / Content-Length when rendering
+15 -1
View File
@@ -67,6 +67,7 @@ type RouterParams struct {
SystemHandler *handler.SystemHandler
MCPServiceHandler *handler.MCPServiceHandler
MCPCredentialsHandler *handler.MCPCredentialsHandler
MCPOAuthHandler *handler.MCPOAuthHandler
WebSearchHandler *handler.WebSearchHandler
WebSearchProviderHandler *handler.WebSearchProviderHandler
WebSearchCredentialsHandler *handler.WebSearchProviderCredentialsHandler
@@ -216,7 +217,7 @@ func NewRouter(params RouterParams) *gin.Engine {
RegisterInitializationRoutes(v1, params.InitializationHandler, rbacGuards)
RegisterSystemRoutes(v1, params.SystemHandler, rbacGuards)
RegisterSystemAdminRoutes(v1, params.SystemHandler, params.AuditLogHandler, rbacGuards)
RegisterMCPServiceRoutes(v1, params.MCPServiceHandler, params.MCPCredentialsHandler, rbacGuards)
RegisterMCPServiceRoutes(v1, params.MCPServiceHandler, params.MCPCredentialsHandler, params.MCPOAuthHandler, rbacGuards)
RegisterWebSearchRoutes(v1, params.WebSearchHandler, rbacGuards)
RegisterWebSearchProviderRoutes(v1, params.WebSearchProviderHandler, params.WebSearchCredentialsHandler, rbacGuards)
RegisterVectorStoreRoutes(v1, params.VectorStoreHandler, rbacGuards)
@@ -833,8 +834,15 @@ func RegisterMCPServiceRoutes(
r *gin.RouterGroup,
handler *handler.MCPServiceHandler,
credHandler *handler.MCPCredentialsHandler,
oauthHandler *handler.MCPOAuthHandler,
g *rbacGuards,
) {
// MCP OAuth provider redirect. Registered OUTSIDE the /mcp-services group
// to avoid a static-vs-":id" route conflict, and left unauthenticated
// (allow-listed in middleware/auth.go) because the third-party browser
// redirect carries no WeKnora bearer — the single-use state authenticates.
r.GET("/mcp-oauth/callback", oauthHandler.Callback)
mcpServices := r.Group("/mcp-services")
{
// Create MCP service — Admin+
@@ -860,6 +868,12 @@ func RegisterMCPServiceRoutes(
// MCP tool human approval (issue #1173) — Viewer+ to read, Admin+ to set policy
mcpServices.GET("/:id/tool-approvals", g.Viewer(), handler.ListMCPToolApprovals)
mcpServices.PUT("/:id/tool-approvals/:tool_name", g.Admin(), handler.SetMCPToolApproval)
// Per-user OAuth authorization flow. Viewer+ may authorize/inspect/
// revoke their own token; the callback is the separate public route
// registered above.
mcpServices.POST("/:id/oauth/authorize-url", g.Viewer(), oauthHandler.AuthorizeURL)
mcpServices.GET("/:id/oauth/status", g.Viewer(), oauthHandler.Status)
mcpServices.DELETE("/:id/oauth/token", g.Viewer(), oauthHandler.Revoke)
}
agentTool := r.Group("/agent")
+32
View File
@@ -0,0 +1,32 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// MCPOAuthRepository persists OAuth clients (per service) and tokens
// (per user + service) for the MCP OAuth2 authorization-code flow.
type MCPOAuthRepository interface {
// GetClient returns the registered OAuth client for a service, or
// (nil, nil) when none has been registered yet.
GetClient(ctx context.Context, tenantID uint64, serviceID string) (*types.MCPOAuthClient, error)
// SaveClient creates or updates the registered OAuth client for a service.
SaveClient(ctx context.Context, client *types.MCPOAuthClient) error
// DeleteClient removes the registered OAuth client for a service.
DeleteClient(ctx context.Context, tenantID uint64, serviceID string) error
// GetToken returns the stored token for (tenant, user, service), or
// (nil, nil) when the user has not authorized yet.
GetToken(ctx context.Context, tenantID uint64, userID, serviceID string) (*types.MCPOAuthToken, error)
// SaveToken creates or updates the per-user token for a service.
SaveToken(ctx context.Context, token *types.MCPOAuthToken) error
// DeleteToken removes the per-user token for a service (revoke /
// re-authorize).
DeleteToken(ctx context.Context, tenantID uint64, userID, serviceID string) error
}
+35
View File
@@ -43,6 +43,22 @@ type MCPService struct {
// MCPHeaders represents HTTP headers as a map
type MCPHeaders map[string]string
// MCPAuthType enumerates the authentication strategies for an MCP service.
type MCPAuthType string
const (
// MCPAuthNone means no authentication (or only static custom headers).
MCPAuthNone MCPAuthType = ""
// MCPAuthAPIKey injects a static API key header (X-API-Key).
MCPAuthAPIKey MCPAuthType = "api_key"
// MCPAuthBearer injects a static Authorization: Bearer <token> header.
MCPAuthBearer MCPAuthType = "bearer"
// MCPAuthOAuth performs the MCP OAuth2 authorization-code flow
// (discovery + dynamic client registration + PKCE) per user. Tokens are
// stored per (tenant, user, service) in mcp_oauth_tokens.
MCPAuthOAuth MCPAuthType = "oauth"
)
// MCPAuthConfig represents authentication configuration for MCP service.
//
// Secret fields (APIKey, Token) are persisted in this struct but are NEVER
@@ -50,10 +66,29 @@ type MCPHeaders map[string]string
// dto.MCPServiceResponse which omits them by construction. Credential
// mutations happen through the dedicated /credentials subresource handled
// by MCPCredentialsHandler.
//
// OAuth note: the OAuth strategy stores no secret in this struct. The
// per-user access/refresh tokens live in mcp_oauth_tokens and the
// dynamically registered client lives in mcp_oauth_clients. The fields here
// (Scopes, AuthServerMetadataURL) are non-secret OAuth configuration.
type MCPAuthConfig struct {
// AuthType selects the authentication strategy. Empty ("") is treated as
// none for backward compatibility with rows that pre-date this field.
AuthType MCPAuthType `json:"auth_type,omitempty"`
APIKey string `json:"api_key,omitempty"`
Token string `json:"token,omitempty"`
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
// Scopes are the OAuth scopes requested during authorization. Optional.
Scopes []string `json:"scopes,omitempty"`
// AuthServerMetadataURL optionally pins the OAuth authorization server
// metadata URL. When empty, the server is discovered automatically from
// the MCP URL (RFC 9728 / RFC 8414).
AuthServerMetadataURL string `json:"auth_server_metadata_url,omitempty"`
}
// IsOAuth reports whether this service uses the OAuth strategy.
func (c *MCPAuthConfig) IsOAuth() bool {
return c != nil && c.AuthType == MCPAuthOAuth
}
// MCPAdvancedConfig represents advanced configuration for MCP service
+139
View File
@@ -0,0 +1,139 @@
package types
import (
"time"
"github.com/Tencent/WeKnora/internal/utils"
"github.com/google/uuid"
"gorm.io/gorm"
)
// MCPOAuthClient stores the OAuth client credentials obtained for an MCP
// service. For servers that support RFC 7591 Dynamic Client Registration the
// client_id (and optional client_secret) is registered once per service and
// reused across all users of that service, avoiding a registration round-trip
// on every authorization.
//
// One row per (tenant_id, service_id). The client_secret is encrypted at rest
// (AES-256-GCM) when SYSTEM_AES_KEY is configured.
type MCPOAuthClient struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
TenantID uint64 `json:"tenant_id" gorm:"not null;uniqueIndex:idx_mcp_oauth_clients_tenant_svc"`
ServiceID string `json:"service_id" gorm:"type:varchar(36);not null;uniqueIndex:idx_mcp_oauth_clients_tenant_svc;index"`
ClientID string `json:"client_id" gorm:"type:varchar(512);not null"`
ClientSecret string `json:"-" gorm:"type:text"`
RedirectURI string `json:"redirect_uri" gorm:"type:varchar(1024)"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName pins the table name. GORM's default naming would otherwise turn
// "MCPOAuthClient" into "mcpo_auth_clients" (treating "MCPO" as one token),
// which does not match the migration table "mcp_oauth_clients".
func (MCPOAuthClient) TableName() string { return "mcp_oauth_clients" }
// BeforeCreate sets the primary key and encrypts the client secret.
func (m *MCPOAuthClient) BeforeCreate(tx *gorm.DB) error {
if m.ID == "" {
m.ID = uuid.New().String()
}
m.encryptSecret()
return nil
}
// BeforeSave re-encrypts the secret on update paths.
func (m *MCPOAuthClient) BeforeSave(tx *gorm.DB) error {
m.encryptSecret()
return nil
}
// AfterFind decrypts the client secret after loading.
func (m *MCPOAuthClient) AfterFind(tx *gorm.DB) error {
if plain, ok := utils.DecryptStoredSecretLenient(m.ClientSecret); ok {
m.ClientSecret = plain
} else {
m.ClientSecret = ""
}
return nil
}
func (m *MCPOAuthClient) encryptSecret() {
if m.ClientSecret == "" {
return
}
if key := utils.GetAESKey(); key != nil {
if enc, err := utils.EncryptAESGCM(m.ClientSecret, key); err == nil {
m.ClientSecret = enc
}
}
}
// MCPOAuthToken stores a per-user OAuth token for an MCP service. The agent
// connects to the MCP server on behalf of the invoking user, so tokens are
// isolated by (tenant_id, user_id, service_id).
//
// AccessToken and RefreshToken are encrypted at rest (AES-256-GCM) when
// SYSTEM_AES_KEY is configured.
type MCPOAuthToken struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
TenantID uint64 `json:"tenant_id" gorm:"not null;uniqueIndex:idx_mcp_oauth_tokens_tenant_user_svc"`
UserID string `json:"user_id" gorm:"type:varchar(64);not null;uniqueIndex:idx_mcp_oauth_tokens_tenant_user_svc;index"`
ServiceID string `json:"service_id" gorm:"type:varchar(36);not null;uniqueIndex:idx_mcp_oauth_tokens_tenant_user_svc;index"`
AccessToken string `json:"-" gorm:"type:text"`
RefreshToken string `json:"-" gorm:"type:text"`
TokenType string `json:"token_type" gorm:"type:varchar(32)"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName pins the table name (see MCPOAuthClient.TableName); the default
// would be "mcpo_auth_tokens" instead of the migration's "mcp_oauth_tokens".
func (MCPOAuthToken) TableName() string { return "mcp_oauth_tokens" }
// BeforeCreate sets the primary key and encrypts secrets.
func (m *MCPOAuthToken) BeforeCreate(tx *gorm.DB) error {
if m.ID == "" {
m.ID = uuid.New().String()
}
m.encryptSecrets()
return nil
}
// BeforeSave re-encrypts secrets on update paths.
func (m *MCPOAuthToken) BeforeSave(tx *gorm.DB) error {
m.encryptSecrets()
return nil
}
// AfterFind decrypts secrets after loading.
func (m *MCPOAuthToken) AfterFind(tx *gorm.DB) error {
if plain, ok := utils.DecryptStoredSecretLenient(m.AccessToken); ok {
m.AccessToken = plain
} else {
m.AccessToken = ""
}
if plain, ok := utils.DecryptStoredSecretLenient(m.RefreshToken); ok {
m.RefreshToken = plain
} else {
m.RefreshToken = ""
}
return nil
}
func (m *MCPOAuthToken) encryptSecrets() {
key := utils.GetAESKey()
if key == nil {
return
}
if m.AccessToken != "" {
if enc, err := utils.EncryptAESGCM(m.AccessToken, key); err == nil {
m.AccessToken = enc
}
}
if m.RefreshToken != "" {
if enc, err := utils.EncryptAESGCM(m.RefreshToken, key); err == nil {
m.RefreshToken = enc
}
}
}
+33
View File
@@ -417,6 +417,39 @@ CREATE TABLE IF NOT EXISTS mcp_tool_approvals (
CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_tool_approvals_tenant_svc_tool ON mcp_tool_approvals(tenant_id, service_id, tool_name);
CREATE INDEX IF NOT EXISTS idx_mcp_tool_approvals_service_id ON mcp_tool_approvals(service_id);
CREATE TABLE IF NOT EXISTS mcp_oauth_clients (
id VARCHAR(36) PRIMARY KEY,
tenant_id INTEGER NOT NULL,
service_id VARCHAR(36) NOT NULL,
client_id VARCHAR(512) NOT NULL,
client_secret TEXT,
redirect_uri VARCHAR(1024),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (service_id) REFERENCES mcp_services(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_oauth_clients_tenant_svc ON mcp_oauth_clients(tenant_id, service_id);
CREATE INDEX IF NOT EXISTS idx_mcp_oauth_clients_service_id ON mcp_oauth_clients(service_id);
CREATE TABLE IF NOT EXISTS mcp_oauth_tokens (
id VARCHAR(36) PRIMARY KEY,
tenant_id INTEGER NOT NULL,
user_id VARCHAR(64) NOT NULL,
service_id VARCHAR(36) NOT NULL,
access_token TEXT,
refresh_token TEXT,
token_type VARCHAR(32),
expires_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (service_id) REFERENCES mcp_services(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_oauth_tokens_tenant_user_svc ON mcp_oauth_tokens(tenant_id, user_id, service_id);
CREATE INDEX IF NOT EXISTS idx_mcp_oauth_tokens_service_id ON mcp_oauth_tokens(service_id);
CREATE INDEX IF NOT EXISTS idx_mcp_oauth_tokens_user_id ON mcp_oauth_tokens(user_id);
CREATE TABLE IF NOT EXISTS custom_agents (
id VARCHAR(36) NOT NULL,
name VARCHAR(255) NOT NULL,
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS mcp_oauth_tokens;
DROP TABLE IF EXISTS mcp_oauth_clients;
@@ -0,0 +1,38 @@
-- MCP OAuth2 support: per-service dynamically-registered clients and
-- per-user access/refresh tokens (issue: MCP OAuth2 authorization-code flow).
DO $$ BEGIN RAISE NOTICE '[Migration 000062] Creating mcp_oauth_clients / mcp_oauth_tokens...'; END $$;
CREATE TABLE IF NOT EXISTS mcp_oauth_clients (
id VARCHAR(36) PRIMARY KEY,
tenant_id INTEGER NOT NULL,
service_id VARCHAR(36) NOT NULL REFERENCES mcp_services(id) ON DELETE CASCADE,
client_id VARCHAR(512) NOT NULL,
client_secret TEXT,
redirect_uri VARCHAR(1024),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_oauth_clients_tenant_svc
ON mcp_oauth_clients(tenant_id, service_id);
CREATE INDEX IF NOT EXISTS idx_mcp_oauth_clients_service_id ON mcp_oauth_clients(service_id);
CREATE TABLE IF NOT EXISTS mcp_oauth_tokens (
id VARCHAR(36) PRIMARY KEY,
tenant_id INTEGER NOT NULL,
user_id VARCHAR(64) NOT NULL,
service_id VARCHAR(36) NOT NULL REFERENCES mcp_services(id) ON DELETE CASCADE,
access_token TEXT,
refresh_token TEXT,
token_type VARCHAR(32),
expires_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_oauth_tokens_tenant_user_svc
ON mcp_oauth_tokens(tenant_id, user_id, service_id);
CREATE INDEX IF NOT EXISTS idx_mcp_oauth_tokens_service_id ON mcp_oauth_tokens(service_id);
CREATE INDEX IF NOT EXISTS idx_mcp_oauth_tokens_user_id ON mcp_oauth_tokens(user_id);
DO $$ BEGIN RAISE NOTICE '[Migration 000062] mcp_oauth tables ready'; END $$;