feat(mcp): drive auth by AuthType and auto-detect OAuth on test
Select the auth header for the configured strategy only, instead of emitting both X-API-Key and Authorization whenever the fields happened to be set (which double-authed after switching strategies). AuthType="" keeps the legacy infer-from-fields behavior for pre-existing rows. Add a configurable api_key_header (non-secret, defaults to X-API-Key) so services expecting the key in a different header work without plaintext custom headers. On connection test, upgrade a bare 401 that advertises RFC 9728 protected-resource metadata into an explicit OAuthRequired signal so the UI can guide the user to switch to OAuth 2.0. Surface the server's description from the initialize handshake in the test result.
This commit is contained in:
@@ -78,10 +78,11 @@ type MCPResource struct {
|
||||
|
||||
// MCPTestResult represents the result of testing an MCP service connection
|
||||
type MCPTestResult struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Tools []*MCPTool `json:"tools,omitempty"`
|
||||
Resources []*MCPResource `json:"resources,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Tools []*MCPTool `json:"tools,omitempty"`
|
||||
Resources []*MCPResource `json:"resources,omitempty"`
|
||||
}
|
||||
|
||||
// CreateMCPService creates a new MCP service
|
||||
|
||||
@@ -306,6 +306,7 @@ curl --location --request POST 'http://localhost:8080/api/v1/mcp-services/mcp-00
|
||||
"data": {
|
||||
"success": true,
|
||||
"message": "连接成功",
|
||||
"description": "提供全球天气信息查询",
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_weather",
|
||||
|
||||
@@ -19,6 +19,10 @@ export interface MCPService {
|
||||
// optional-property typing remains so create-mode payloads can still
|
||||
// carry them in the initial POST body.
|
||||
api_key?: string
|
||||
// Header name carrying api_key when auth_type is "api_key". Non-secret;
|
||||
// empty defaults to "X-API-Key". Lets services expecting the key in a
|
||||
// different header (e.g. raw token in "Authorization") work.
|
||||
api_key_header?: string
|
||||
token?: string
|
||||
custom_headers?: Record<string, string>
|
||||
// OAuth-only, non-secret configuration.
|
||||
@@ -69,6 +73,10 @@ export interface MCPResource {
|
||||
export interface MCPTestResult {
|
||||
success: boolean
|
||||
message?: string
|
||||
description?: string
|
||||
// Set when the server requires OAuth (RFC 9728) but the service was not
|
||||
// configured for it — the UI guides the user to switch to OAuth 2.0.
|
||||
oauth_required?: boolean
|
||||
tools?: MCPTool[]
|
||||
resources?: MCPResource[]
|
||||
}
|
||||
@@ -212,4 +220,3 @@ export async function resolveToolApproval(
|
||||
): Promise<void> {
|
||||
await post(`/api/v1/agent/tool-approvals/${encodeURIComponent(pendingId)}`, body)
|
||||
}
|
||||
|
||||
|
||||
@@ -4465,6 +4465,14 @@ export default {
|
||||
addEnvVar: 'Add Environment Variable',
|
||||
enableService: 'Enable Service',
|
||||
authConfig: 'Authentication',
|
||||
authType: 'Auth Method',
|
||||
authTypeNone: 'None / Custom Header',
|
||||
authTypeApiKey: 'API Key / Token',
|
||||
authTypeOAuth: 'OAuth 2.0 (authorize on first connect)',
|
||||
oauthAuthorizeHint: 'Clicking "Authorize" saves the current config first, then starts authorization (each user authorizes individually).',
|
||||
apiKeyHeader: 'Header Name',
|
||||
apiKeyHeaderDesc: 'Defaults to X-API-Key. For Bearer, set Authorization and put "Bearer <token>" in the value below; for raw-token services use Authorization with the raw token.',
|
||||
credentialValue: 'Secret / Token',
|
||||
apiKey: 'API Key',
|
||||
bearerToken: 'Bearer Token',
|
||||
optional: 'Optional',
|
||||
@@ -4484,7 +4492,8 @@ export default {
|
||||
created: 'MCP service created',
|
||||
updated: 'MCP service updated',
|
||||
createFailed: 'Failed to create MCP service',
|
||||
updateFailed: 'Failed to update MCP service'
|
||||
updateFailed: 'Failed to update MCP service',
|
||||
oauthRequired: 'This server requires OAuth. Switched to OAuth 2.0 — save, then click "Authorize".'
|
||||
},
|
||||
customHeaders: {
|
||||
label: 'Custom Headers (optional)',
|
||||
|
||||
@@ -4461,6 +4461,14 @@ export default {
|
||||
addEnvVar: "환경 변수 추가",
|
||||
enableService: "서비스 활성화",
|
||||
authConfig: "인증 설정",
|
||||
authType: "인증 방식",
|
||||
authTypeNone: "없음 / 사용자 정의 헤더",
|
||||
authTypeApiKey: "API Key / Token",
|
||||
authTypeOAuth: "OAuth 2.0(최초 연결 시 인증)",
|
||||
oauthAuthorizeHint: "「인증하기」를 클릭하면 현재 설정을 먼저 자동 저장한 후 인증을 시작합니다(사용자별 개별 인증).",
|
||||
apiKeyHeader: "요청 헤더 이름",
|
||||
apiKeyHeaderDesc: "비워 두면 기본값은 X-API-Key입니다. Bearer 방식은 Authorization을 입력하고 아래 비밀 값에 \"Bearer <token>\"을 작성하세요. 원시 토큰이 필요하면 Authorization에 토큰을 그대로 입력합니다.",
|
||||
credentialValue: "비밀 값 / 토큰",
|
||||
apiKey: "API Key",
|
||||
bearerToken: "Bearer Token",
|
||||
optional: "선택",
|
||||
@@ -4481,6 +4489,7 @@ export default {
|
||||
updated: "MCP 서비스가 업데이트되었습니다",
|
||||
createFailed: "MCP 서비스 생성 실패",
|
||||
updateFailed: "MCP 서비스 업데이트 실패",
|
||||
oauthRequired: "이 서비스는 OAuth 인증이 필요하여 OAuth 2.0으로 자동 전환했습니다. 저장 후 「인증하기」를 클릭하세요.",
|
||||
},
|
||||
customHeaders: {
|
||||
label: "사용자 지정 헤더(선택 사항)",
|
||||
|
||||
@@ -4113,6 +4113,14 @@ export default {
|
||||
addEnvVar: 'Добавить переменную',
|
||||
enableService: 'Включить сервис',
|
||||
authConfig: 'Аутентификация',
|
||||
authType: 'Способ аутентификации',
|
||||
authTypeNone: 'Нет / Свой заголовок',
|
||||
authTypeApiKey: 'API Key / Token',
|
||||
authTypeOAuth: 'OAuth 2.0 (авторизация при первом подключении)',
|
||||
oauthAuthorizeHint: 'Нажатие «Авторизоваться» сначала сохранит текущую конфигурацию, затем запустит авторизацию (каждый пользователь авторизуется отдельно).',
|
||||
apiKeyHeader: 'Имя заголовка',
|
||||
apiKeyHeaderDesc: 'По умолчанию X-API-Key. Для Bearer укажите Authorization и впишите "Bearer <token>" в значение ниже; для «сырого» токена используйте Authorization и сам токен.',
|
||||
credentialValue: 'Секрет / Токен',
|
||||
apiKey: 'API Key',
|
||||
bearerToken: 'Bearer Token',
|
||||
optional: 'Необязательно',
|
||||
@@ -4132,7 +4140,8 @@ export default {
|
||||
created: 'Сервис MCP создан',
|
||||
updated: 'Сервис MCP обновлён',
|
||||
createFailed: 'Не удалось создать сервис MCP',
|
||||
updateFailed: 'Не удалось обновить сервис MCP'
|
||||
updateFailed: 'Не удалось обновить сервис MCP',
|
||||
oauthRequired: 'Сервис требует OAuth. Переключено на OAuth 2.0 — сохраните и нажмите «Авторизоваться».'
|
||||
},
|
||||
customHeaders: {
|
||||
label: 'Пользовательские заголовки (необязательно)',
|
||||
|
||||
@@ -4468,9 +4468,11 @@ export default {
|
||||
authConfig: "认证配置",
|
||||
authType: "认证方式",
|
||||
authTypeNone: "无 / 自定义 Header",
|
||||
authTypeApiKey: "API Key",
|
||||
authTypeBearer: "Bearer Token",
|
||||
authTypeApiKey: "API Key / Token",
|
||||
authTypeOAuth: "OAuth 2.0(首次连接授权)",
|
||||
apiKeyHeader: "请求头名称",
|
||||
apiKeyHeaderDesc: "留空默认 X-API-Key。Bearer 方式请填 Authorization,并在下方密钥值中写 “Bearer <token>”;需要裸 token 时填 Authorization 并直接填入 token。",
|
||||
credentialValue: "密钥值 / Token",
|
||||
oauthScopes: "Scopes(可选,空格分隔)",
|
||||
oauthAuthorization: "授权状态",
|
||||
oauthAuthorized: "已授权",
|
||||
@@ -4479,6 +4481,7 @@ export default {
|
||||
oauthReauthorize: "重新授权",
|
||||
oauthRevoke: "撤销授权",
|
||||
oauthSaveFirstHint: "保存服务后,可在编辑页发起首次授权(每个用户独立授权)。",
|
||||
oauthAuthorizeHint: "点击「去授权」会先自动保存当前配置,再发起授权(每个用户独立授权)。",
|
||||
apiKey: "API Key",
|
||||
bearerToken: "Bearer Token",
|
||||
optional: "可选",
|
||||
@@ -4499,6 +4502,7 @@ export default {
|
||||
updated: "MCP 服务已更新",
|
||||
createFailed: "创建 MCP 服务失败",
|
||||
updateFailed: "更新 MCP 服务失败",
|
||||
oauthRequired: "该服务需要 OAuth 授权,已自动切换为 OAuth 2.0,请保存后点击「去授权」。",
|
||||
authorized: "授权成功",
|
||||
authorizeFailed: "发起授权失败",
|
||||
revoked: "已撤销授权",
|
||||
|
||||
@@ -196,7 +196,19 @@
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('mcpServiceDialog.authType', '认证方式') }}</label>
|
||||
<t-select v-model="formData.auth_config.auth_type" :options="authTypeOptions" />
|
||||
<!-- 展开式 pill segmented,与上方传输类型同款,避免再点开下拉 -->
|
||||
<div class="source-options" role="radiogroup">
|
||||
<button
|
||||
v-for="opt in authTypeOptions"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="source-option"
|
||||
:class="{ 'is-active': formData.auth_config.auth_type === opt.value }"
|
||||
@click="formData.auth_config.auth_type = opt.value as '' | 'api_key' | 'bearer' | 'oauth'"
|
||||
>
|
||||
<span class="source-option__label">{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OAuth 2.0:零配置(自动发现 + 动态客户端注册),按用户授权 -->
|
||||
@@ -205,7 +217,7 @@
|
||||
<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">
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('mcpServiceDialog.oauthAuthorization', '授权状态') }}</label>
|
||||
<div class="oauth-status">
|
||||
<t-tag v-if="oauthAuthorized" theme="success" variant="light">
|
||||
@@ -217,13 +229,13 @@
|
||||
<t-button
|
||||
size="small"
|
||||
theme="primary"
|
||||
:loading="oauthAuthorizing || oauthChecking"
|
||||
:loading="oauthAuthorizing || oauthChecking || submitting"
|
||||
@click="handleAuthorize"
|
||||
>
|
||||
{{ oauthAuthorized ? t('mcpServiceDialog.oauthReauthorize', '重新授权') : t('mcpServiceDialog.oauthAuthorize', '去授权') }}
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="oauthAuthorized"
|
||||
v-if="oauthAuthorized && props.service?.id"
|
||||
size="small"
|
||||
theme="danger"
|
||||
variant="outline"
|
||||
@@ -232,10 +244,10 @@
|
||||
{{ t('mcpServiceDialog.oauthRevoke', '撤销授权') }}
|
||||
</t-button>
|
||||
</div>
|
||||
<p class="form-desc">
|
||||
{{ t('mcpServiceDialog.oauthAuthorizeHint', '点击「去授权」会先自动保存当前配置,再发起授权(每个用户独立授权)。') }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="oauth-hint">
|
||||
{{ t('mcpServiceDialog.oauthSaveFirstHint', '保存服务后,可在编辑页发起首次授权(每个用户独立授权)。') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
@@ -243,35 +255,34 @@
|
||||
/credentials 子资源调用);Create 模式下用 plain password input。
|
||||
两个字段都是 optional — MCP 服务可能完全不需要鉴权。
|
||||
-->
|
||||
<template v-else>
|
||||
<!-- 凭证 Header 策略:请求头名称 + 密钥值(其余 auth_type 不展示密钥字段) -->
|
||||
<template v-else-if="formData.auth_config.auth_type === 'api_key'">
|
||||
<!-- 请求头名称(非密钥):默认 X-API-Key,Bearer/裸 token 场景填 Authorization。 -->
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('mcpServiceDialog.apiKeyHeader', '请求头名称') }}</label>
|
||||
<t-input
|
||||
v-model="formData.auth_config.api_key_header"
|
||||
placeholder="X-API-Key"
|
||||
/>
|
||||
<p class="form-desc">{{ t('mcpServiceDialog.apiKeyHeaderDesc', '留空默认 X-API-Key。Bearer 方式请填 Authorization,并在下方密钥值中写 “Bearer <token>”;需要裸 token 时填 Authorization 并直接填入 token。') }}</p>
|
||||
</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>
|
||||
<div v-else class="form-item">
|
||||
<label class="form-label">{{ t('mcpServiceDialog.credentialValue', '密钥值 / Token') }}</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>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -409,10 +420,13 @@ const formData = ref({
|
||||
// `headers`). Independent of the auth strategy.
|
||||
headers: [] as { key: string; value: string }[],
|
||||
auth_config: {
|
||||
// Authentication strategy: '' (none) | 'api_key' | 'bearer' | 'oauth'.
|
||||
// Authentication strategy. UI exposes '' (none) | 'api_key' (credential
|
||||
// header) | 'oauth'. Legacy 'bearer' is normalised to 'api_key' on load.
|
||||
auth_type: '' as '' | 'api_key' | 'bearer' | 'oauth',
|
||||
// Only used in add-mode; in edit-mode the CredentialResource owns these.
|
||||
api_key: '',
|
||||
// Non-secret: header name for the api_key value (default X-API-Key).
|
||||
api_key_header: '',
|
||||
token: '',
|
||||
// OAuth-only, non-secret config.
|
||||
scopes: [] as string[],
|
||||
@@ -463,9 +477,13 @@ function applyServerConfig(name: string, cfg: Record<string, unknown>) {
|
||||
|
||||
// Auth: recognise Bearer / API-key headers; every other header lands in the
|
||||
// custom-headers editor so nothing is silently dropped.
|
||||
let authType: '' | 'api_key' | 'bearer' = ''
|
||||
let token = ''
|
||||
// Map a recognised auth header onto the unified credential strategy: the raw
|
||||
// header value is the secret (encrypted), carried in the given header name.
|
||||
// Bearer keeps its "Bearer " prefix inside the value; Authorization with a
|
||||
// raw token and X-API-Key all collapse to the same shape.
|
||||
let authType: '' | 'api_key' = ''
|
||||
let apiKey = ''
|
||||
let apiKeyHeader = ''
|
||||
const customHeaders: { key: string; value: string }[] = []
|
||||
const headers = (cfg.headers && typeof cfg.headers === 'object')
|
||||
? (cfg.headers as Record<string, unknown>)
|
||||
@@ -473,12 +491,10 @@ function applyServerConfig(name: string, cfg: Record<string, unknown>) {
|
||||
for (const [key, val] of Object.entries(headers)) {
|
||||
const lowerKey = key.toLowerCase()
|
||||
const strVal = typeof val === 'string' ? val : String(val ?? '')
|
||||
if (lowerKey === 'authorization' && /^bearer\s+/i.test(strVal)) {
|
||||
authType = 'bearer'
|
||||
token = strVal.replace(/^bearer\s+/i, '').trim()
|
||||
} else if (['x-api-key', 'api-key', 'apikey'].includes(lowerKey)) {
|
||||
if (lowerKey === 'authorization' || ['x-api-key', 'api-key', 'apikey'].includes(lowerKey)) {
|
||||
authType = 'api_key'
|
||||
apiKey = strVal.trim()
|
||||
apiKeyHeader = lowerKey === 'x-api-key' ? '' : key
|
||||
} else {
|
||||
customHeaders.push({ key, value: strVal })
|
||||
}
|
||||
@@ -489,9 +505,11 @@ function applyServerConfig(name: string, cfg: Record<string, unknown>) {
|
||||
formData.value.transport_type = transport
|
||||
if (typeof cfg.description === 'string') formData.value.description = cfg.description
|
||||
formData.value.headers = customHeaders
|
||||
// No recognised auth header → none (custom headers carry the rest).
|
||||
formData.value.auth_config.auth_type = authType
|
||||
formData.value.auth_config.token = token
|
||||
formData.value.auth_config.token = ''
|
||||
formData.value.auth_config.api_key = apiKey
|
||||
formData.value.auth_config.api_key_header = apiKeyHeader
|
||||
formData.value.auth_config.scopes = []
|
||||
|
||||
return true
|
||||
@@ -570,10 +588,13 @@ const oauthScopesText = computed({
|
||||
|
||||
const isOAuth = computed(() => formData.value.auth_config.auth_type === 'oauth')
|
||||
|
||||
// API Key / Bearer are unified into one "credential header" strategy now that
|
||||
// the header name is configurable (Bearer is just Authorization + a "Bearer "
|
||||
// value prefix). "None" stays as the default for services that need no auth or
|
||||
// only the custom headers configured above.
|
||||
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: 'api_key', label: t('mcpServiceDialog.authTypeApiKey', 'API Key / Token') },
|
||||
{ value: 'oauth', label: t('mcpServiceDialog.authTypeOAuth', 'OAuth 2.0(首次连接授权)') },
|
||||
])
|
||||
|
||||
@@ -594,8 +615,11 @@ async function refreshOAuthStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAuthorize() {
|
||||
if (!props.service?.id) return
|
||||
// Open the OAuth popup for an already-persisted service and poll for
|
||||
// completion against that service id (independent of props, which may be
|
||||
// re-bound by the parent after a save).
|
||||
async function startAuthorize(serviceId: string) {
|
||||
if (!serviceId) return
|
||||
oauthAuthorizing.value = true
|
||||
try {
|
||||
const redirectUri = window.location.origin + MCP_OAUTH_CALLBACK_PATH
|
||||
@@ -603,19 +627,22 @@ async function handleAuthorize() {
|
||||
// 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, {
|
||||
const authUrl = await getMCPOAuthAuthorizeURL(serviceId, {
|
||||
redirect_uri: redirectUri,
|
||||
frontend_redirect: frontendRedirect,
|
||||
})
|
||||
if (!authUrl) {
|
||||
MessagePlugin.error(t('mcpServiceDialog.toasts.authorizeFailed', '发起授权失败') as string)
|
||||
oauthAuthorizing.value = false
|
||||
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()
|
||||
try {
|
||||
oauthAuthorized.value = await getMCPOAuthStatus(serviceId)
|
||||
} catch { /* keep polling */ }
|
||||
if (oauthAuthorized.value || closed) {
|
||||
window.clearInterval(timer)
|
||||
oauthAuthorizing.value = false
|
||||
@@ -632,6 +659,39 @@ async function handleAuthorize() {
|
||||
}
|
||||
}
|
||||
|
||||
// Authorize ALWAYS persists the current form first (create or update), so the
|
||||
// backend sees auth_type=oauth before issuing the authorization request.
|
||||
// Without this, a brand-new service or one just switched to OAuth fails with
|
||||
// "MCP service is not configured to use OAuth". The drawer stays open the whole
|
||||
// time — the parent re-binds the (now saved) service via the `created` event.
|
||||
async function handleAuthorize() {
|
||||
const valid = await formRef.value?.validate()
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
let serviceId = ''
|
||||
try {
|
||||
const hasId = !!props.service?.id
|
||||
const data = buildPayload(!hasId)
|
||||
if (hasId) {
|
||||
await updateMCPService(props.service!.id, data)
|
||||
serviceId = props.service!.id
|
||||
emit('created', { ...(props.service as MCPService), ...data } as MCPService)
|
||||
} else {
|
||||
const created = await createMCPService(data)
|
||||
serviceId = created.id
|
||||
emit('created', created)
|
||||
}
|
||||
} catch (e) {
|
||||
MessagePlugin.error(t('mcpServiceDialog.toasts.updateFailed') as string)
|
||||
console.error('Failed to save before authorize:', e)
|
||||
submitting.value = false
|
||||
return
|
||||
}
|
||||
submitting.value = false
|
||||
await startAuthorize(serviceId)
|
||||
}
|
||||
|
||||
async function handleRevokeOAuth() {
|
||||
if (!props.service?.id) return
|
||||
try {
|
||||
@@ -657,10 +717,15 @@ const transportLabel = computed(() => {
|
||||
// Field metadata for the credential subresource. Keep label keys local to
|
||||
// MCP so other resources don't accidentally inherit "API Key" / "Bearer
|
||||
// Token" labels via the shared component.
|
||||
const credentialFields = computed<CredentialFieldDef<McpCredentialField>[]>(() => [
|
||||
{ key: 'api_key', label: t('mcpServiceDialog.apiKey') },
|
||||
{ key: 'token', label: t('mcpServiceDialog.bearerToken') },
|
||||
])
|
||||
// The unified credential strategy stores its secret in the api_key field
|
||||
// (placed verbatim into the configured header). None / OAuth carry no static
|
||||
// secret, so the credential card is only shown for the api_key strategy.
|
||||
const credentialFields = computed<CredentialFieldDef<McpCredentialField>[]>(() => {
|
||||
if (formData.value.auth_config.auth_type === 'api_key') {
|
||||
return [{ key: 'api_key', label: t('mcpServiceDialog.credentialValue', '密钥值 / Token') }]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// Adapter that binds the generic CredentialResource component to the MCP
|
||||
// credential endpoints. Recomputed if the user opens a different service.
|
||||
@@ -755,6 +820,22 @@ async function handleTestConnection() {
|
||||
}
|
||||
lastTestOk.value = safe.success === true
|
||||
testResult.value = safe
|
||||
const discoveredDescription = typeof safe.description === 'string' ? safe.description.trim() : ''
|
||||
if (safe.success === true && discoveredDescription) {
|
||||
formData.value.description = discoveredDescription
|
||||
}
|
||||
// Server told us it needs OAuth (RFC 9728): guide the user by switching the
|
||||
// auth strategy to OAuth. We intentionally do NOT prefill
|
||||
// auth_server_metadata_url — the discovered URL is the RFC 9728
|
||||
// protected-resource metadata (not the RFC 8414 authorization-server
|
||||
// metadata), and the client library discovers the right endpoints from the
|
||||
// base URL on its own.
|
||||
if (safe.oauth_required === true && formData.value.auth_config.auth_type !== 'oauth') {
|
||||
formData.value.auth_config.auth_type = 'oauth'
|
||||
MessagePlugin.warning(
|
||||
t('mcpServiceDialog.toasts.oauthRequired', '该服务需要 OAuth 授权,已自动切换为 OAuth 2.0,请保存后点击「去授权」。') as string,
|
||||
)
|
||||
}
|
||||
scrollToTestResult()
|
||||
} catch (error: any) {
|
||||
MessagePlugin.closeAll()
|
||||
@@ -825,7 +906,7 @@ const resetForm = () => {
|
||||
transport_type: 'sse',
|
||||
url: '',
|
||||
headers: [],
|
||||
auth_config: { auth_type: '', api_key: '', token: '', scopes: [], auth_server_metadata_url: '' },
|
||||
auth_config: { auth_type: '', api_key: '', api_key_header: '', token: '', scopes: [], auth_server_metadata_url: '' },
|
||||
advanced_config: { timeout: 30, retry_count: 3, retry_delay: 1 },
|
||||
}
|
||||
formRef.value?.clearValidate()
|
||||
@@ -855,8 +936,13 @@ watch(
|
||||
// Credentials are owned by CredentialResource in edit mode, but reset
|
||||
// the local state too so a switch to add-mode starts clean.
|
||||
auth_config: {
|
||||
auth_type: (service.auth_config?.auth_type as '' | 'api_key' | 'bearer' | 'oauth') || '',
|
||||
// Legacy 'bearer' collapses into the unified 'api_key' strategy; ''
|
||||
// (none) and the rest are preserved.
|
||||
auth_type: service.auth_config?.auth_type === 'bearer'
|
||||
? 'api_key'
|
||||
: ((service.auth_config?.auth_type as '' | 'api_key' | 'oauth') || ''),
|
||||
api_key: '',
|
||||
api_key_header: service.auth_config?.api_key_header || '',
|
||||
token: '',
|
||||
scopes: service.auth_config?.scopes ? [...service.auth_config.scopes] : [],
|
||||
auth_server_metadata_url: service.auth_config?.auth_server_metadata_url || '',
|
||||
@@ -876,53 +962,58 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Build the request body from the form. `asCreate` controls whether initial
|
||||
// secret credentials ride along (POST only — edits route secrets through the
|
||||
// /credentials subresource).
|
||||
function buildPayload(asCreate: boolean): Partial<MCPService> {
|
||||
// Custom headers: trim, drop blank rows, collapse to a Record. Always send
|
||||
// the field (even when empty) so removing the last header persists on edit.
|
||||
const headersMap: Record<string, string> = {}
|
||||
for (const item of formData.value.headers) {
|
||||
const key = (item.key ?? '').trim()
|
||||
const value = (item.value ?? '').trim()
|
||||
if (key && value) headersMap[key] = value
|
||||
}
|
||||
|
||||
const data: Partial<MCPService> = {
|
||||
name: formData.value.name,
|
||||
description: formData.value.description,
|
||||
enabled: formData.value.enabled,
|
||||
transport_type: formData.value.transport_type,
|
||||
advanced_config: formData.value.advanced_config,
|
||||
url: formData.value.url || undefined,
|
||||
headers: headersMap,
|
||||
}
|
||||
|
||||
// Non-secret auth config (strategy + OAuth params) flows through the main body.
|
||||
const auth: NonNullable<MCPService['auth_config']> = {
|
||||
auth_type: formData.value.auth_config.auth_type,
|
||||
}
|
||||
if (formData.value.auth_config.auth_type === 'api_key' && formData.value.auth_config.api_key_header) {
|
||||
auth.api_key_header = formData.value.auth_config.api_key_header.trim()
|
||||
}
|
||||
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 (asCreate && !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
|
||||
return data
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const valid = await formRef.value?.validate()
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
// Custom headers: trim, drop blank rows, collapse to a Record. Always send
|
||||
// the field (even when empty) so removing the last header persists on edit.
|
||||
const headersMap: Record<string, string> = {}
|
||||
for (const item of formData.value.headers) {
|
||||
const key = (item.key ?? '').trim()
|
||||
const value = (item.value ?? '').trim()
|
||||
if (key && value) headersMap[key] = value
|
||||
}
|
||||
|
||||
const data: Partial<MCPService> = {
|
||||
name: formData.value.name,
|
||||
description: formData.value.description,
|
||||
enabled: formData.value.enabled,
|
||||
transport_type: formData.value.transport_type,
|
||||
advanced_config: formData.value.advanced_config,
|
||||
url: formData.value.url || undefined,
|
||||
headers: headersMap,
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
const data = buildPayload(props.mode === 'add')
|
||||
if (props.mode === 'add') {
|
||||
// Initial credentials go along with the first POST. Subsequent edits
|
||||
// route through the /credentials subresource.
|
||||
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
|
||||
const created = await createMCPService(data)
|
||||
MessagePlugin.success(t('mcpServiceDialog.toasts.created'))
|
||||
// Keep the drawer open and hand back the new service so the parent can
|
||||
@@ -931,9 +1022,6 @@ const handleSubmit = async () => {
|
||||
// the user do them immediately instead of save → reopen.
|
||||
emit('created', 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'))
|
||||
emit('success')
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
<span>{{ result.success ? $t('mcp.testResult.connectionSuccess') : $t('mcp.testResult.connectionFailed') }}</span>
|
||||
</div>
|
||||
<p v-if="result.message" class="mtr-message">{{ result.message }}</p>
|
||||
<div v-if="result.success && result.description" class="mtr-service-desc">
|
||||
<span>{{ $t('mcp.testResult.descriptionLabel') }}</span>
|
||||
<p>{{ result.description }}</p>
|
||||
</div>
|
||||
|
||||
<template v-if="result.success">
|
||||
<!-- 工具列表 -->
|
||||
@@ -213,6 +217,29 @@ const formatSchema = (schema: any): string => {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mtr-service-desc {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--td-component-stroke);
|
||||
border-radius: 6px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
/* 分组 */
|
||||
.mtr-group {
|
||||
display: flex;
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
@@ -172,8 +173,10 @@ func (s *mcpServiceService) UpdateMCPService(ctx context.Context, service *types
|
||||
}
|
||||
|
||||
preAuthType := types.MCPAuthNone
|
||||
preAPIKeyHeader := ""
|
||||
if existing.AuthConfig != nil {
|
||||
preAuthType = existing.AuthConfig.AuthType
|
||||
preAPIKeyHeader = existing.AuthConfig.APIKeyHeader
|
||||
}
|
||||
|
||||
// CustomHeaders flows through main PUT (it's structural, not a secret) —
|
||||
@@ -195,6 +198,11 @@ func (s *mcpServiceService) UpdateMCPService(ctx context.Context, service *types
|
||||
if service.AuthConfig.AuthType != types.MCPAuthNone {
|
||||
existing.AuthConfig.AuthType = service.AuthConfig.AuthType
|
||||
}
|
||||
// APIKeyHeader is non-secret; empty means "use default X-API-Key", so a
|
||||
// partial PUT that omits it is treated as no-change (mirrors scopes).
|
||||
if service.AuthConfig.APIKeyHeader != "" {
|
||||
existing.AuthConfig.APIKeyHeader = service.AuthConfig.APIKeyHeader
|
||||
}
|
||||
if service.AuthConfig.Scopes != nil {
|
||||
existing.AuthConfig.Scopes = service.AuthConfig.Scopes
|
||||
}
|
||||
@@ -282,7 +290,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 {
|
||||
if existing.AuthConfig != nil &&
|
||||
(existing.AuthConfig.AuthType != preAuthType ||
|
||||
existing.AuthConfig.APIKeyHeader != preAPIKeyHeader) {
|
||||
configChanged = true
|
||||
}
|
||||
name := secutils.SanitizeForLog(existing.Name)
|
||||
@@ -335,6 +345,26 @@ func (s *mcpServiceService) DeleteMCPService(ctx context.Context, tenantID uint6
|
||||
}
|
||||
|
||||
// TestMCPService tests the connection to an MCP service and returns available tools/resources
|
||||
// mcpTestFailure builds a failed MCPTestResult, upgrading a generic connection
|
||||
// error into an explicit "OAuth required" signal when the server answered with
|
||||
// an RFC 9728 OAuth challenge. This lets the UI guide the user to switch the
|
||||
// auth strategy to OAuth instead of showing a bare 401.
|
||||
func mcpTestFailure(err error, prefix string) *types.MCPTestResult {
|
||||
var oerr *mcp.OAuthRequiredError
|
||||
if errors.As(err, &oerr) {
|
||||
return &types.MCPTestResult{
|
||||
Success: false,
|
||||
OAuthRequired: true,
|
||||
Message: "This MCP server requires OAuth authorization. " +
|
||||
"Switch the auth method to OAuth 2.0 and authorize.",
|
||||
}
|
||||
}
|
||||
return &types.MCPTestResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("%s: %v", prefix, err),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mcpServiceService) TestMCPService(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
@@ -375,20 +405,14 @@ func (s *mcpServiceService) TestMCPService(
|
||||
defer cancel()
|
||||
|
||||
if err := client.Connect(testCtx); err != nil {
|
||||
return &types.MCPTestResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Connection failed: %v", err),
|
||||
}, nil
|
||||
return mcpTestFailure(err, "Connection failed"), nil
|
||||
}
|
||||
defer client.Disconnect()
|
||||
|
||||
// Initialize
|
||||
initResult, err := client.Initialize(testCtx)
|
||||
if err != nil {
|
||||
return &types.MCPTestResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Initialization failed: %v", err),
|
||||
}, nil
|
||||
return mcpTestFailure(err, "Initialization failed"), nil
|
||||
}
|
||||
|
||||
// List tools
|
||||
@@ -412,8 +436,9 @@ func (s *mcpServiceService) TestMCPService(
|
||||
initResult.ServerInfo.Name,
|
||||
initResult.ServerInfo.Version,
|
||||
),
|
||||
Tools: tools,
|
||||
Resources: resources,
|
||||
Description: initResult.ServerInfo.Description,
|
||||
Tools: tools,
|
||||
Resources: resources,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -584,4 +609,3 @@ func (s *mcpServiceService) GetMCPServiceResources(
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
|
||||
+17
-15
@@ -21,21 +21,21 @@ import (
|
||||
// only a boolean per credential field so the frontend can render a
|
||||
// "configured / not configured" badge without an additional round-trip.
|
||||
type MCPServiceResponse struct {
|
||||
ID string `json:"id"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TransportType types.MCPTransportType `json:"transport_type"`
|
||||
URL *string `json:"url,omitempty"`
|
||||
Headers types.MCPHeaders `json:"headers,omitempty"`
|
||||
AuthConfig *MCPAuthConfigResponse `json:"auth_config,omitempty"`
|
||||
AdvancedConfig *types.MCPAdvancedConfig `json:"advanced_config,omitempty"`
|
||||
StdioConfig *types.MCPStdioConfig `json:"stdio_config,omitempty"`
|
||||
EnvVars types.MCPEnvVars `json:"env_vars,omitempty"`
|
||||
IsBuiltin bool `json:"is_builtin"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TransportType types.MCPTransportType `json:"transport_type"`
|
||||
URL *string `json:"url,omitempty"`
|
||||
Headers types.MCPHeaders `json:"headers,omitempty"`
|
||||
AuthConfig *MCPAuthConfigResponse `json:"auth_config,omitempty"`
|
||||
AdvancedConfig *types.MCPAdvancedConfig `json:"advanced_config,omitempty"`
|
||||
StdioConfig *types.MCPStdioConfig `json:"stdio_config,omitempty"`
|
||||
EnvVars types.MCPEnvVars `json:"env_vars,omitempty"`
|
||||
IsBuiltin bool `json:"is_builtin"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Credentials is the per-field "configured?" map. Embedded on the main
|
||||
// response so the credential UI doesn't need a follow-up GET. The
|
||||
// frontend never sees the actual secret value — only whether one is
|
||||
@@ -50,6 +50,7 @@ type MCPServiceResponse struct {
|
||||
// echo back so the UI can render the current strategy.
|
||||
type MCPAuthConfigResponse struct {
|
||||
AuthType types.MCPAuthType `json:"auth_type,omitempty"`
|
||||
APIKeyHeader string `json:"api_key_header,omitempty"`
|
||||
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
AuthServerMetadataURL string `json:"auth_server_metadata_url,omitempty"`
|
||||
@@ -90,6 +91,7 @@ func NewMCPServiceResponse(svc *types.MCPService) *MCPServiceResponse {
|
||||
if svc.AuthConfig != nil {
|
||||
resp.AuthConfig = &MCPAuthConfigResponse{
|
||||
AuthType: svc.AuthConfig.AuthType,
|
||||
APIKeyHeader: svc.AuthConfig.APIKeyHeader,
|
||||
CustomHeaders: svc.AuthConfig.CustomHeaders,
|
||||
Scopes: svc.AuthConfig.Scopes,
|
||||
AuthServerMetadataURL: svc.AuthConfig.AuthServerMetadataURL,
|
||||
|
||||
@@ -303,6 +303,11 @@ func (h *MCPServiceHandler) UpdateMCPService(c *gin.Context) {
|
||||
if authType, ok := authConfig["auth_type"].(string); ok {
|
||||
service.AuthConfig.AuthType = types.MCPAuthType(authType)
|
||||
}
|
||||
// api_key_header is non-secret structural config (header name for the
|
||||
// api_key strategy); flows through the main PUT like custom_headers.
|
||||
if apiKeyHeader, ok := authConfig["api_key_header"].(string); ok {
|
||||
service.AuthConfig.APIKeyHeader = apiKeyHeader
|
||||
}
|
||||
if scopes, ok := authConfig["scopes"].([]interface{}); ok {
|
||||
list := make([]string, 0, len(scopes))
|
||||
for _, s := range scopes {
|
||||
|
||||
+85
-17
@@ -67,6 +67,80 @@ type mcpGoClient struct {
|
||||
initialized bool
|
||||
}
|
||||
|
||||
// applyAuthHeaders injects the auth header for the SELECTED strategy only —
|
||||
// driven by AuthType so static API key / bearer are mutually exclusive (the old
|
||||
// code emitted both whenever the fields happened to be set, which double-authed
|
||||
// after a strategy switch). OAuth is handled separately by the caller.
|
||||
// CustomHeaders are always layered on top regardless of strategy and may
|
||||
// override the strategy header.
|
||||
func applyAuthHeaders(headers map[string]string, ac *types.MCPAuthConfig) {
|
||||
if ac == nil {
|
||||
return
|
||||
}
|
||||
switch ac.AuthType {
|
||||
case types.MCPAuthAPIKey:
|
||||
if ac.APIKey != "" {
|
||||
name := ac.APIKeyHeader
|
||||
if name == "" {
|
||||
name = "X-API-Key"
|
||||
}
|
||||
headers[name] = ac.APIKey
|
||||
}
|
||||
case types.MCPAuthBearer:
|
||||
if ac.Token != "" {
|
||||
headers["Authorization"] = "Bearer " + ac.Token
|
||||
}
|
||||
case types.MCPAuthNone:
|
||||
// Backward compatibility for rows that predate AuthType: infer from
|
||||
// whichever static credential is set, preserving the historical
|
||||
// behavior so existing services keep authenticating after upgrade.
|
||||
if ac.APIKey != "" {
|
||||
headers["X-API-Key"] = ac.APIKey
|
||||
}
|
||||
if ac.Token != "" {
|
||||
headers["Authorization"] = "Bearer " + ac.Token
|
||||
}
|
||||
}
|
||||
for key, value := range ac.CustomHeaders {
|
||||
headers[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// OAuthRequiredError signals that the target MCP server requires OAuth
|
||||
// authorization — it answered the connect/initialize handshake with a 401 that
|
||||
// advertised RFC 9728 protected-resource metadata — even though the service was
|
||||
// NOT configured to use OAuth. Callers use this to guide the user to switch the
|
||||
// auth strategy to OAuth instead of surfacing a generic "401" failure.
|
||||
type OAuthRequiredError struct {
|
||||
// MetadataURL is the RFC 9728 protected-resource metadata URL advertised by
|
||||
// the server via the WWW-Authenticate header. Non-empty by construction
|
||||
// (asOAuthRequired only wraps when the server advertised it).
|
||||
MetadataURL string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *OAuthRequiredError) Error() string {
|
||||
return fmt.Sprintf("the MCP server requires OAuth authorization: %v", e.Err)
|
||||
}
|
||||
|
||||
func (e *OAuthRequiredError) Unwrap() error { return e.Err }
|
||||
|
||||
// asOAuthRequired inspects err for a transport-level authorization-required
|
||||
// signal that carries RFC 9728 protected-resource metadata. It returns a
|
||||
// non-nil *OAuthRequiredError ONLY when the server advertised a metadata URL —
|
||||
// a bare 401 without metadata is treated as an ordinary auth failure (e.g. a
|
||||
// wrong/missing API key) so we don't misdirect the user toward OAuth.
|
||||
func asOAuthRequired(err error) *OAuthRequiredError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var authErr *transport.AuthorizationRequiredError
|
||||
if errors.As(err, &authErr) && authErr.ResourceMetadataURL != "" {
|
||||
return &OAuthRequiredError{MetadataURL: authErr.ResourceMetadataURL, Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewMCPClient creates a new MCP client based on the transport type
|
||||
func NewMCPClient(config *ClientConfig) (MCPClient, error) {
|
||||
// Create HTTP client with timeout
|
||||
@@ -84,21 +158,7 @@ func NewMCPClient(config *ClientConfig) (MCPClient, error) {
|
||||
for key, value := range config.Service.Headers {
|
||||
headers[key] = value
|
||||
}
|
||||
|
||||
// Add auth headers
|
||||
if config.Service.AuthConfig != nil {
|
||||
if config.Service.AuthConfig.APIKey != "" {
|
||||
headers["X-API-Key"] = config.Service.AuthConfig.APIKey
|
||||
}
|
||||
if config.Service.AuthConfig.Token != "" {
|
||||
headers["Authorization"] = "Bearer " + config.Service.AuthConfig.Token
|
||||
}
|
||||
if config.Service.AuthConfig.CustomHeaders != nil {
|
||||
for key, value := range config.Service.AuthConfig.CustomHeaders {
|
||||
headers[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
applyAuthHeaders(headers, config.Service.AuthConfig)
|
||||
|
||||
// Build OAuth config when this service uses the OAuth strategy. The
|
||||
// client_id comes from the dynamically-registered client persisted at
|
||||
@@ -229,6 +289,9 @@ func (c *mcpGoClient) Connect(ctx context.Context) error {
|
||||
|
||||
// Start the client
|
||||
if err := c.client.Start(ctx); err != nil {
|
||||
if oerr := asOAuthRequired(err); oerr != nil {
|
||||
return oerr
|
||||
}
|
||||
return fmt.Errorf("failed to start client: %w", err)
|
||||
}
|
||||
c.connected = true
|
||||
@@ -277,6 +340,9 @@ func (c *mcpGoClient) Initialize(ctx context.Context) (*InitializeResult, error)
|
||||
result, err := c.client.Initialize(ctx, req)
|
||||
if err != nil {
|
||||
c.checkErrorAndDisconnectIfNeeded(err)
|
||||
if oerr := asOAuthRequired(err); oerr != nil {
|
||||
return nil, oerr
|
||||
}
|
||||
return nil, fmt.Errorf("failed to initialize: %w", err)
|
||||
}
|
||||
|
||||
@@ -285,8 +351,10 @@ func (c *mcpGoClient) Initialize(ctx context.Context) (*InitializeResult, error)
|
||||
return &InitializeResult{
|
||||
ProtocolVersion: result.ProtocolVersion,
|
||||
ServerInfo: ServerInfo{
|
||||
Name: result.ServerInfo.Name,
|
||||
Version: result.ServerInfo.Version,
|
||||
Name: result.ServerInfo.Name,
|
||||
Version: result.ServerInfo.Version,
|
||||
Title: result.ServerInfo.Title,
|
||||
Description: result.ServerInfo.Description,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/mark3labs/mcp-go/client/transport"
|
||||
)
|
||||
|
||||
func TestAsOAuthRequired(t *testing.T) {
|
||||
t.Run("nil error", func(t *testing.T) {
|
||||
if got := asOAuthRequired(nil); got != nil {
|
||||
t.Fatalf("got %v, want nil", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("401 with RFC 9728 metadata is treated as OAuth required", func(t *testing.T) {
|
||||
meta := "https://example.com/.well-known/oauth-protected-resource"
|
||||
err := fmt.Errorf("wrap: %w", &transport.AuthorizationRequiredError{ResourceMetadataURL: meta})
|
||||
got := asOAuthRequired(err)
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil OAuthRequiredError")
|
||||
}
|
||||
if got.MetadataURL != meta {
|
||||
t.Errorf("MetadataURL = %q, want %q", got.MetadataURL, meta)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare 401 without metadata is NOT OAuth required", func(t *testing.T) {
|
||||
err := &transport.AuthorizationRequiredError{ResourceMetadataURL: ""}
|
||||
if got := asOAuthRequired(err); got != nil {
|
||||
t.Fatalf("got %v, want nil (bare 401 should not suggest OAuth)", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unrelated error is ignored", func(t *testing.T) {
|
||||
if got := asOAuthRequired(errors.New("connection refused")); got != nil {
|
||||
t.Fatalf("got %v, want nil", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyAuthHeaders(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ac *types.MCPAuthConfig
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "nil config injects nothing",
|
||||
ac: nil,
|
||||
want: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "api_key uses default X-API-Key header",
|
||||
ac: &types.MCPAuthConfig{AuthType: types.MCPAuthAPIKey, APIKey: "k1"},
|
||||
want: map[string]string{"X-API-Key": "k1"},
|
||||
},
|
||||
{
|
||||
name: "api_key honors custom header name (e.g. raw token in Authorization)",
|
||||
ac: &types.MCPAuthConfig{
|
||||
AuthType: types.MCPAuthAPIKey,
|
||||
APIKey: "f7bfde",
|
||||
APIKeyHeader: "Authorization",
|
||||
},
|
||||
want: map[string]string{"Authorization": "f7bfde"},
|
||||
},
|
||||
{
|
||||
name: "bearer adds Bearer prefix",
|
||||
ac: &types.MCPAuthConfig{AuthType: types.MCPAuthBearer, Token: "t1"},
|
||||
want: map[string]string{"Authorization": "Bearer t1"},
|
||||
},
|
||||
{
|
||||
name: "selected strategy is exclusive — stale token is not emitted",
|
||||
ac: &types.MCPAuthConfig{
|
||||
AuthType: types.MCPAuthAPIKey,
|
||||
APIKey: "k1",
|
||||
Token: "stale",
|
||||
},
|
||||
want: map[string]string{"X-API-Key": "k1"},
|
||||
},
|
||||
{
|
||||
name: "empty AuthType keeps legacy behavior (infer from fields)",
|
||||
ac: &types.MCPAuthConfig{
|
||||
AuthType: types.MCPAuthNone,
|
||||
APIKey: "k1",
|
||||
Token: "t1",
|
||||
},
|
||||
want: map[string]string{"X-API-Key": "k1", "Authorization": "Bearer t1"},
|
||||
},
|
||||
{
|
||||
name: "custom headers are always layered on top",
|
||||
ac: &types.MCPAuthConfig{
|
||||
AuthType: types.MCPAuthBearer,
|
||||
Token: "t1",
|
||||
CustomHeaders: map[string]string{"X-Trace": "abc"},
|
||||
},
|
||||
want: map[string]string{"Authorization": "Bearer t1", "X-Trace": "abc"},
|
||||
},
|
||||
{
|
||||
name: "oauth strategy emits no static header (handled elsewhere)",
|
||||
ac: &types.MCPAuthConfig{AuthType: types.MCPAuthOAuth, Token: "ignored"},
|
||||
want: map[string]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
headers := map[string]string{}
|
||||
applyAuthHeaders(headers, tt.ac)
|
||||
if len(headers) != len(tt.want) {
|
||||
t.Fatalf("header count = %d, want %d (%v)", len(headers), len(tt.want), headers)
|
||||
}
|
||||
for k, v := range tt.want {
|
||||
if headers[k] != v {
|
||||
t.Errorf("header[%q] = %q, want %q", k, headers[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,10 @@ type PromptsCapability struct {
|
||||
|
||||
// ServerInfo represents information about the server
|
||||
type ServerInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// CallToolResult represents the result of tools/call request
|
||||
|
||||
+19
-7
@@ -74,8 +74,15 @@ const (
|
||||
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"`
|
||||
AuthType MCPAuthType `json:"auth_type,omitempty"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
// APIKeyHeader is the header name that carries APIKey when AuthType is
|
||||
// api_key. Empty defaults to "X-API-Key". This is non-secret structural
|
||||
// config (the secret is APIKey), so it is not encrypted and is safe to echo
|
||||
// back in responses. It lets services that expect the key in a different
|
||||
// header (e.g. the raw token directly in "Authorization") work without
|
||||
// resorting to plaintext custom headers.
|
||||
APIKeyHeader string `json:"api_key_header,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
|
||||
// Scopes are the OAuth scopes requested during authorization. Optional.
|
||||
@@ -146,10 +153,16 @@ type MCPResource struct {
|
||||
|
||||
// MCPTestResult represents the result of testing an MCP service connection
|
||||
type MCPTestResult struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Tools []*MCPTool `json:"tools,omitempty"`
|
||||
Resources []*MCPResource `json:"resources,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
// OAuthRequired is true when the connection failed because the server
|
||||
// requires OAuth authorization (RFC 9728), even though the service was not
|
||||
// configured for OAuth. The UI uses it to guide the user to switch the auth
|
||||
// strategy to OAuth.
|
||||
OAuthRequired bool `json:"oauth_required,omitempty"`
|
||||
Tools []*MCPTool `json:"tools,omitempty"`
|
||||
Resources []*MCPResource `json:"resources,omitempty"`
|
||||
}
|
||||
|
||||
// BeforeCreate is a GORM hook that runs before creating a new MCP service
|
||||
@@ -317,4 +330,3 @@ func GetDefaultAdvancedConfig() *MCPAdvancedConfig {
|
||||
// compile-time invariant of the DTO instead of a runtime call that handlers
|
||||
// must remember to make. Builtin-service field stripping is also implemented
|
||||
// there.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user