mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-24 16:29:01 +08:00
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:
@@ -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 }
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 $$;
|
||||
Reference in New Issue
Block a user