feat(mcp): prompt and resume OAuth authorization mid-conversation

When an agent invokes an OAuth-enabled MCP service that the current user has
not authorized yet, the tool call now pauses and surfaces an "Authorize" card
in the chat instead of failing with an opaque error. After the user completes
the per-user OAuth popup, the backend verifies a token exists and resumes the
paused tool call automatically.

Backend:
- Add mcp_oauth_required / mcp_oauth_resolved events and response types.
- Add OAuthPendingRequest + Gate.RequestOAuthAndWait, reusing the existing
  approval waiter, Resolve, Redis cross-instance fan-out and timeout.
- MCPTool.Execute pauses on an authorization-required error for OAuth services
  and retries once (with a fresh ctx and recycled connection) after the user
  authorizes.
- Forward the new events to the SSE stream.
- Add ResolveMCPOAuth handler + route; it verifies authorization before
  unblocking so a premature resolve cannot resume into another failure.

Frontend:
- Add McpOAuthCard with an Authorize button reusing the popup + status-poll
  flow, then resolve the paused run.
- Handle the new stream events (dedup multiple pending cards per service).
- Add resolveMCPOAuth API client and i18n keys (zh/en/ko/ru).
This commit is contained in:
wizardchen
2026-06-29 22:37:02 +08:00
committed by lyingbug
parent f2686e9e39
commit 9fd7a7f522
16 changed files with 862 additions and 34 deletions
+10
View File
@@ -220,3 +220,13 @@ export async function resolveToolApproval(
): Promise<void> {
await post(`/api/v1/agent/tool-approvals/${encodeURIComponent(pendingId)}`, body)
}
// Resume an agent run that paused on an in-conversation MCP OAuth prompt.
// Call after the per-user authorization popup completes; the backend verifies
// the token exists before unblocking the paused tool call.
export async function resolveMCPOAuth(
pendingId: string,
body: { service_id: string }
): Promise<void> {
await post(`/api/v1/agent/mcp-oauth-resolutions/${encodeURIComponent(pendingId)}`, body)
}
@@ -595,6 +595,41 @@ export function useChatStreamHandler(options: UseChatStreamHandlerOptions) {
}
break
}
case 'mcp_oauth_required': {
if (!message.agentEventStream) message.agentEventStream = []
const d = dataPayload || {}
;(message.agentEventStream as ChatMessage[]).push({
type: 'mcp_oauth_required',
pending_id: d.pending_id,
service_id: d.service_id,
service_name: d.service_name,
mcp_tool_name: d.mcp_tool_name,
timeout_seconds: d.timeout_seconds,
requested_at: d.requested_at,
tool_call_id: d.tool_call_id,
resolved: false,
})
break
}
case 'mcp_oauth_resolved': {
const d = dataPayload || {}
const pid = d.pending_id
const sid = d.service_id
const list = message.agentEventStream as ChatMessage[] | undefined
// Resolve the matching card; also clear any other still-pending cards
// for the same service (parallel tool calls dedup to a single auth).
list?.forEach((e) => {
if (e.type !== 'mcp_oauth_required' || e.resolved) return
if (e.pending_id === pid || (sid && e.service_id === sid && d.authorized)) {
e.resolved = true
e.authorized = d.authorized
e.resolve_reason = d.reason
e.timed_out = d.timed_out
e.canceled = d.canceled
}
})
break
}
case 'tool_call': {
if (dataPayload?.tool_name === 'final_answer') break
if (message.agentEventStream) {
+12
View File
@@ -5049,6 +5049,18 @@ export default {
submitFailed: 'Submit failed',
userRejected: 'User rejected',
},
mcpOAuth: {
banner: 'This MCP service requires OAuth authorization before it can be used',
desc: 'Authorizing opens a new window to sign in. The tool call resumes automatically once authorization succeeds.',
authorize: 'Authorize',
countdown: 'About {seconds}s remaining',
authorizedTag: 'Authorized',
timedOutTag: 'Authorization timed out',
canceledTag: 'Canceled',
authorizedToast: 'Authorized, resuming',
startFailed: 'Failed to start authorization',
resumeFailed: 'Failed to resume execution, please retry',
},
tools: {
searchKnowledge: 'Knowledge Search',
grepChunks: 'Text Pattern Search',
+12
View File
@@ -5066,6 +5066,18 @@ export default {
submitFailed: '제출 실패',
userRejected: '사용자 거부',
},
mcpOAuth: {
banner: '이 MCP 서비스는 OAuth 인증 후에 사용할 수 있습니다',
desc: '인증을 누르면 새 창에서 로그인합니다. 인증이 완료되면 이 도구 호출이 자동으로 계속됩니다.',
authorize: '인증하기',
countdown: '약 {seconds}초 남음',
authorizedTag: '인증됨',
timedOutTag: '인증 시간 초과',
canceledTag: '취소됨',
authorizedToast: '인증 성공, 계속 진행 중',
startFailed: '인증 시작 실패',
resumeFailed: '실행 재개 실패, 다시 시도하세요',
},
tools: {
searchKnowledge: '지식베이스 검색',
grepChunks: '텍스트 패턴 검색',
+12
View File
@@ -4566,6 +4566,18 @@ export default {
submitFailed: 'Ошибка отправки',
userRejected: 'Отклонено пользователем',
},
mcpOAuth: {
banner: 'Этот MCP-сервис требует авторизации OAuth перед использованием',
desc: 'Авторизация откроет новое окно для входа. После успешной авторизации вызов инструмента продолжится автоматически.',
authorize: 'Авторизовать',
countdown: 'Осталось примерно {seconds}с',
authorizedTag: 'Авторизовано',
timedOutTag: 'Время авторизации истекло',
canceledTag: 'Отменено',
authorizedToast: 'Авторизация успешна, продолжаем',
startFailed: 'Не удалось начать авторизацию',
resumeFailed: 'Не удалось возобновить выполнение, повторите попытку',
},
tools: {
searchKnowledge: 'Поиск по базе знаний',
grepChunks: 'Поиск по текстовому шаблону',
+12
View File
@@ -5062,6 +5062,18 @@ export default {
submitFailed: "提交失败",
userRejected: "用户拒绝",
},
mcpOAuth: {
banner: "该 MCP 服务需要 OAuth 授权后才能调用",
desc: "点击授权将打开新窗口完成登录,授权成功后会自动继续本次工具调用。",
authorize: "去授权",
countdown: "剩余约 {seconds} 秒",
authorizedTag: "已授权",
timedOutTag: "授权超时",
canceledTag: "已取消",
authorizedToast: "授权成功,正在继续",
startFailed: "发起授权失败",
resumeFailed: "恢复执行失败,请重试",
},
tools: {
searchKnowledge: "知识库检索",
grepChunks: "搜索关键词",
@@ -88,6 +88,15 @@
:resolve-reason="event.resolve_reason" />
</div>
<!-- MCP OAuth in-conversation authorization prompt -->
<div v-else-if="event.type === 'mcp_oauth_required'" class="tool-event">
<McpOAuthCard :pending-id="event.pending_id" :service-id="event.service_id || ''"
:service-name="event.service_name || ''" :mcp-tool-name="event.mcp_tool_name || ''"
:timeout-seconds="event.timeout_seconds" :requested-at="event.requested_at"
:resolved="event.resolved" :authorized="event.authorized"
:resolve-reason="event.resolve_reason" :timed-out="event.timed_out" :canceled="event.canceled" />
</div>
<!-- Tool Call Event (non-thinking) -->
<div v-else-if="event.type === 'tool_call'" class="tool-event">
<div class="action-card" :class="{
@@ -234,6 +243,15 @@
:approved="event.approved" :resolve-reason="event.resolve_reason" />
</div>
<!-- MCP OAuth in-conversation authorization prompt -->
<div v-else-if="event.type === 'mcp_oauth_required'" class="tool-event">
<McpOAuthCard :pending-id="event.pending_id" :service-id="event.service_id || ''"
:service-name="event.service_name || ''" :mcp-tool-name="event.mcp_tool_name || ''"
:timeout-seconds="event.timeout_seconds" :requested-at="event.requested_at" :resolved="event.resolved"
:authorized="event.authorized" :resolve-reason="event.resolve_reason" :timed-out="event.timed_out"
:canceled="event.canceled" />
</div>
<!-- Thinking Tool Call -->
<div v-else-if="event.type === 'tool_call' && event.tool_name === 'thinking'" class="tool-event">
<div class="action-card"
@@ -419,6 +437,7 @@ import { marked } from 'marked';
import 'katex/dist/katex.min.css';
import ToolResultRenderer from './ToolResultRenderer.vue';
import ToolApprovalCard from './ToolApprovalCard.vue';
import McpOAuthCard from './McpOAuthCard.vue';
import ChatRequestInfoButton from '@/components/ChatRequestInfoButton.vue';
import ChatCitationFloat from '@/components/ChatCitationFloat.vue';
import picturePreview from '@/components/picture-preview.vue';
@@ -1405,6 +1424,9 @@ const getEventKey = (event: any, index: number): string => {
if (event.type === 'tool_approval_required' && event.pending_id) {
return `approval-${event.pending_id}`;
}
if (event.type === 'mcp_oauth_required' && event.pending_id) {
return `mcp-oauth-${event.pending_id}`;
}
return `event-${index}-${event.type || 'unknown'}`;
};
@@ -0,0 +1,332 @@
<template>
<div class="oauth-card" :class="cardClass">
<!-- Status strip -->
<div class="oauth-strip">
<span class="oauth-strip-icon">
<t-icon v-if="!resolved" name="lock-on" />
<t-icon v-else-if="authorized" name="check-circle-filled" />
<t-icon v-else name="close-circle-filled" />
</span>
<span class="oauth-strip-text">
<template v-if="!resolved">{{ $t('agentStream.mcpOAuth.banner') }}</template>
<template v-else-if="authorized">{{ $t('agentStream.mcpOAuth.authorizedTag') }}</template>
<template v-else-if="timedOut">{{ $t('agentStream.mcpOAuth.timedOutTag') }}</template>
<template v-else>{{ $t('agentStream.mcpOAuth.canceledTag') }}</template>
</span>
<span v-if="!resolved && secondsLeft >= 0" class="oauth-strip-timer" :class="timerClass">
<t-icon name="time" />
{{ formatCountdown(secondsLeft) }}
</span>
</div>
<!-- Identity row -->
<div class="oauth-identity">
<span class="ident-service">{{ serviceName }}</span>
<template v-if="mcpToolName">
<t-icon name="chevron-right" class="ident-sep" />
<span class="ident-tool">{{ mcpToolName }}</span>
</template>
</div>
<div v-if="!resolved" class="oauth-desc">{{ $t('agentStream.mcpOAuth.desc') }}</div>
<!-- Footer (pending) -->
<div v-if="!resolved" class="oauth-footer">
<span class="oauth-spacer" />
<t-button
theme="primary"
size="small"
:loading="authorizing"
:disabled="authorizing"
@click="authorize"
>
{{ $t('agentStream.mcpOAuth.authorize') }}
</t-button>
</div>
<!-- Footer (resolved) -->
<div v-else class="oauth-resolved-footer">
<span v-if="resolveReason" class="oauth-resolved-reason">{{ resolveReason }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import { useI18n } from 'vue-i18n'
import {
getMCPOAuthAuthorizeURL,
getMCPOAuthStatus,
resolveMCPOAuth,
MCP_OAUTH_CALLBACK_PATH,
} from '@/api/mcp-service'
const props = defineProps<{
pendingId: string
serviceId: string
serviceName: string
mcpToolName?: string
timeoutSeconds?: number
requestedAt?: number
resolved?: boolean
authorized?: boolean
resolveReason?: string
timedOut?: boolean
canceled?: boolean
}>()
const { t } = useI18n()
const authorizing = ref(false)
const now = ref(Date.now())
let clock: ReturnType<typeof setInterval> | null = null
let poll: ReturnType<typeof setInterval> | null = null
const deadline = computed(() => {
const base = (props.requestedAt || 0) * 1000
const add = (props.timeoutSeconds || 600) * 1000
return base + add
})
const secondsLeft = computed(() => {
if (props.resolved) return -1
return Math.max(0, Math.floor((deadline.value - now.value) / 1000))
})
const timerClass = computed(() => {
if (secondsLeft.value <= 30) return 'timer-critical'
if (secondsLeft.value <= 120) return 'timer-warning'
return ''
})
const cardClass = computed(() => ({
'is-resolved': !!props.resolved,
'is-authorized': !!props.resolved && !!props.authorized,
'is-failed': !!props.resolved && !props.authorized,
'is-pending': !props.resolved,
}))
function formatCountdown(s: number): string {
if (s < 60) return t('agentStream.mcpOAuth.countdown', { seconds: s })
const m = Math.floor(s / 60)
const r = s % 60
return `${m}:${r.toString().padStart(2, '0')}`
}
function stopPoll() {
if (poll) {
clearInterval(poll)
poll = null
}
}
const authorize = async () => {
if (props.resolved || authorizing.value) return
authorizing.value = true
try {
const redirectUri = window.location.origin + MCP_OAUTH_CALLBACK_PATH
const frontendRedirect = window.location.origin + '/'
const authUrl = await getMCPOAuthAuthorizeURL(props.serviceId, {
redirect_uri: redirectUri,
frontend_redirect: frontendRedirect,
})
if (!authUrl) {
MessagePlugin.error(t('agentStream.mcpOAuth.startFailed'))
authorizing.value = false
return
}
const popup = window.open(authUrl, 'mcp_oauth', 'width=600,height=720')
// Poll until the user authorizes (status flips) or closes the popup.
poll = window.setInterval(async () => {
const closed = !popup || popup.closed
let ok = false
try {
ok = await getMCPOAuthStatus(props.serviceId)
} catch {
/* transient; keep polling */
}
if (ok) {
stopPoll()
try { popup?.close() } catch { /* cross-origin close may throw */ }
try {
await resolveMCPOAuth(props.pendingId, { service_id: props.serviceId })
MessagePlugin.success(t('agentStream.mcpOAuth.authorizedToast'))
} catch (e: any) {
const msg = e?.response?.data?.error?.message || e?.message || t('agentStream.mcpOAuth.resumeFailed')
MessagePlugin.error(msg)
}
authorizing.value = false
} else if (closed) {
stopPoll()
authorizing.value = false
}
}, 1500)
} catch (e: any) {
const msg = e?.response?.data?.error?.message || e?.message || t('agentStream.mcpOAuth.startFailed')
MessagePlugin.error(msg)
authorizing.value = false
}
}
onMounted(() => {
clock = setInterval(() => {
now.value = Date.now()
}, 1000)
})
onBeforeUnmount(() => {
if (clock) clearInterval(clock)
stopPoll()
})
</script>
<style scoped lang="less">
@warning-rgb: 237, 122, 11;
@success-rgb: 7, 192, 95;
@danger-rgb: 232, 80, 91;
.oauth-card {
--strip-color: var(--td-brand-color);
--strip-rgb: 0, 82, 217;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-stroke);
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
transition: border-color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
display: flex;
flex-direction: column;
position: relative;
&::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: var(--strip-color);
transition: background-color 0.2s ease;
}
&.is-authorized {
--strip-color: var(--td-success-color);
--strip-rgb: @success-rgb;
opacity: 0.94;
}
&.is-failed {
--strip-color: var(--td-error-color);
--strip-rgb: @danger-rgb;
opacity: 0.94;
}
}
.oauth-strip {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 12px 7px 14px;
font-size: 12px;
font-weight: 500;
color: var(--strip-color);
background: rgba(var(--strip-rgb), 0.06);
border-bottom: 1px solid var(--td-component-stroke);
.oauth-strip-icon {
display: inline-flex;
align-items: center;
.t-icon {
font-size: 14px;
}
}
.oauth-strip-text {
flex: 1;
color: var(--strip-color);
}
.oauth-strip-timer {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.04);
color: var(--td-text-color-secondary);
font-variant-numeric: tabular-nums;
font-weight: 500;
.t-icon {
font-size: 12px;
}
&.timer-warning {
color: var(--td-warning-color);
background: rgba(@warning-rgb, 0.1);
}
&.timer-critical {
color: var(--td-error-color);
background: rgba(@danger-rgb, 0.12);
animation: timerPulse 1.2s ease-in-out infinite;
}
}
}
.oauth-identity {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px 6px 14px;
font-size: 13px;
flex-wrap: wrap;
.ident-service {
color: var(--td-text-color-secondary);
font-weight: 500;
}
.ident-sep {
color: var(--td-text-color-placeholder);
font-size: 12px;
}
.ident-tool {
color: var(--td-brand-color);
font-weight: 600;
font-family: var(--td-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 13px;
}
}
.oauth-desc {
padding: 0 12px 4px 14px;
font-size: 12px;
line-height: 1.6;
color: var(--td-text-color-secondary);
}
.oauth-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px 12px 14px;
}
.oauth-spacer {
flex: 1;
}
.oauth-resolved-footer {
padding: 6px 12px 10px 14px;
font-size: 12px;
color: var(--td-text-color-secondary);
.oauth-resolved-reason {
color: var(--td-text-color-secondary);
}
&:empty {
display: none;
}
}
@keyframes timerPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
</style>
+121
View File
@@ -104,6 +104,21 @@ type MCPApproval interface {
RequestAndWait(ctx context.Context, req PendingRequest) (Decision, error)
}
// OAuthPendingRequest carries everything needed to prompt the user to authorize
// an OAuth-enabled MCP service mid-conversation and block until they do.
type OAuthPendingRequest struct {
TenantID uint64
UserID string // session owner; required for Resolve authorization
SessionID string
AssistantMessageID string
RequestID string
EventBus *event.EventBus
ServiceID string
ServiceName string
MCPToolName string
ToolCallID string
}
var _ MCPApproval = (*Gate)(nil)
// Gate coordinates wait/resolve for MCP tool approvals.
@@ -399,6 +414,112 @@ func (g *Gate) RequestAndWait(ctx context.Context, req PendingRequest) (Decision
}
}
// RequestOAuthAndWait emits an "MCP OAuth required" UI event, then blocks
// until the user authorizes (delivered via Resolve), the wait times out, or
// the request ctx is canceled. A returned Decision.Approved==true means the
// user completed authorization and the tool call should be retried.
//
// Unlike RequestAndWait this does NOT consult the approval checker — it is
// driven reactively by an authorization-required error from the MCP transport.
func (g *Gate) RequestOAuthAndWait(ctx context.Context, req OAuthPendingRequest) (Decision, error) {
if g == nil {
return Decision{}, fmt.Errorf("oauth gate: nil gate")
}
if req.EventBus == nil {
return Decision{}, fmt.Errorf("oauth gate: EventBus is nil")
}
pendingID := uuid.New().String()
w := &waiter{
ch: make(chan Decision, 1),
tenantID: req.TenantID,
userID: req.UserID,
}
g.mu.Lock()
g.pending[pendingID] = w
g.mu.Unlock()
defer func() {
g.mu.Lock()
delete(g.pending, pendingID)
g.mu.Unlock()
}()
timeoutSec := int(g.timeout / time.Second)
if timeoutSec < 1 {
timeoutSec = 1
}
if err := req.EventBus.Emit(ctx, event.Event{
ID: pendingID + "-mcp-oauth-required",
Type: event.EventMCPOAuthRequired,
SessionID: req.SessionID,
Data: event.MCPOAuthRequiredData{
PendingID: pendingID,
TenantID: req.TenantID,
SessionID: req.SessionID,
AssistantMessageID: req.AssistantMessageID,
ServiceID: req.ServiceID,
ServiceName: req.ServiceName,
MCPToolName: req.MCPToolName,
TimeoutSeconds: timeoutSec,
RequestedAtUnix: time.Now().Unix(),
ToolCallID: req.ToolCallID,
RequestID: req.RequestID,
},
Metadata: map[string]interface{}{
"assistant_message_id": req.AssistantMessageID,
"pending_id": pendingID,
},
RequestID: req.RequestID,
}); err != nil {
return Decision{}, fmt.Errorf("emit mcp oauth required: %w", err)
}
emitResolved := func(d Decision) {
_ = req.EventBus.Emit(context.WithoutCancel(ctx), event.Event{
ID: pendingID + "-mcp-oauth-resolved",
Type: event.EventMCPOAuthResolved,
SessionID: req.SessionID,
Data: event.MCPOAuthResolvedData{
PendingID: pendingID,
ServiceID: req.ServiceID,
Authorized: d.Approved,
Reason: d.Reason,
TimedOut: d.TimedOut,
Canceled: d.ContextCanceled,
},
Metadata: map[string]interface{}{
"assistant_message_id": req.AssistantMessageID,
},
RequestID: req.RequestID,
})
}
timer := time.NewTimer(g.timeout)
defer timer.Stop()
var d Decision
select {
case d = <-w.ch:
emitResolved(d)
return d, nil
case <-timer.C:
d = Decision{Approved: false, Reason: "authorization timeout", TimedOut: true}
_ = w.deliver(d)
d = <-w.ch
emitResolved(d)
return d, nil
case <-ctx.Done():
d = Decision{Approved: false, Reason: "request canceled", ContextCanceled: true}
_ = w.deliver(d)
d = <-w.ch
emitResolved(d)
return d, nil
}
}
// Resolve completes a pending approval. tenantID must match the tenant that
// started the wait; userID, when non-zero, must match the session owner.
//
+116 -33
View File
@@ -174,44 +174,51 @@ func (t *MCPTool) Execute(ctx context.Context, args json.RawMessage) (*types.Too
}
}
// Get or create MCP client
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: oauthAwareConnectError(t.service, err),
}, nil
}
// For stdio transport, ensure connection is released after use
isStdio := t.service.TransportType == types.MCPTransportStdio
if isStdio {
defer func() {
if err := client.Disconnect(); err != nil {
logger.GetLogger(ctx).Warnf("Failed to disconnect stdio MCP client: %v", err)
} else {
logger.GetLogger(ctx).Infof("Stdio MCP client disconnected after tool execution")
}
}()
}
// Call the tool via MCP (with one reconnection retry on failure)
result, err := client.CallTool(ctx, t.mcpTool.Name, input)
if err != nil && !isStdio {
logger.GetLogger(ctx).Warnf("MCP tool call failed, retrying with fresh connection: %v", err)
_ = client.Disconnect()
client, err = t.mcpManager.GetOrCreateClient(ctx, t.service)
// connectAndCall performs get-client + call-tool, including the existing
// stale-session reconnection retry. Factored out so an in-conversation
// OAuth authorization prompt can replay the whole sequence once after the
// user authorizes.
connectAndCall := func(ctx context.Context) (*mcp.CallToolResult, error) {
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: oauthAwareConnectError(t.service, err),
}, nil
return nil, err
}
// For stdio transport, ensure connection is released after use.
if isStdio {
defer func() {
if derr := client.Disconnect(); derr != nil {
logger.GetLogger(ctx).Warnf("Failed to disconnect stdio MCP client: %v", derr)
} else {
logger.GetLogger(ctx).Infof("Stdio MCP client disconnected after tool execution")
}
}()
}
result, err = client.CallTool(ctx, t.mcpTool.Name, input)
// Call the tool via MCP (with one reconnection retry on failure)
result, err := client.CallTool(ctx, t.mcpTool.Name, input)
if err != nil && !isStdio {
logger.GetLogger(ctx).Warnf("MCP tool call failed, retrying with fresh connection: %v", err)
_ = client.Disconnect()
client, err = t.mcpManager.GetOrCreateClient(ctx, t.service)
if err != nil {
return nil, err
}
result, err = client.CallTool(ctx, t.mcpTool.Name, input)
}
return result, err
}
result, err := connectAndCall(ctx)
if err != nil {
// When an OAuth-enabled service has not been authorized by this user,
// pause and prompt them to authorize in the chat, then retry once.
if retryCtx, cancel, ok := t.waitForOAuthAndRetry(ctx, err); ok {
defer cancel()
result, err = connectAndCall(retryCtx)
}
}
if err != nil {
logger.GetLogger(ctx).Errorf("MCP tool call failed: %v", err)
@@ -376,6 +383,82 @@ func extractContentText(content []mcp.ContentItem) string {
return strings.Join(textParts, "\n")
}
// oauthWaiter is the subset of the approval gate used to pause the agent while
// the user completes an MCP OAuth authorization. Declared locally (and accessed
// via a type assertion) so the broader MCPApproval interface — and its test
// fakes — need not change.
type oauthWaiter interface {
RequestOAuthAndWait(ctx context.Context, req approval.OAuthPendingRequest) (approval.Decision, error)
}
// waitForOAuthAndRetry blocks for user authorization when err indicates an
// OAuth-enabled MCP service has not been authorized by the current user. It
// emits an "authorize" prompt to the chat (via the gate) and waits until the
// user authorizes, the wait times out, or the request is canceled.
//
// It returns (retryCtx, cancel, true) only when the user authorized in time, in
// which case the caller should replay the tool call using retryCtx (a fresh
// timeout budget, since the wait may have consumed the original per-tool ctx)
// and must defer cancel. In all other cases it returns (ctx, noop, false) and
// the caller surfaces the original error.
func (t *MCPTool) waitForOAuthAndRetry(
ctx context.Context, err error,
) (context.Context, context.CancelFunc, bool) {
noop := func() {}
if !t.service.AuthConfig.IsOAuth() || !isAuthorizationRequired(err) {
return ctx, noop, false
}
ow, ok := t.gate.(oauthWaiter)
if !ok || ow == nil {
return ctx, noop, false
}
meta, ok := ToolExecFromContext(ctx)
if !ok || meta == nil || meta.EventBus == nil {
return ctx, noop, false
}
tenantID, _ := types.TenantIDFromContext(ctx)
// Wait on the round-level ctx (no per-tool timeout) so authorization can
// legitimately take longer than the per-tool exec budget; user-stop /
// request cancel still propagates because it is a child of the request ctx.
waitCtx := ctx
if meta.ApprovalCtx != nil {
waitCtx = meta.ApprovalCtx
}
decision, waitErr := ow.RequestOAuthAndWait(waitCtx, approval.OAuthPendingRequest{
TenantID: tenantID,
UserID: meta.UserID,
SessionID: meta.SessionID,
AssistantMessageID: meta.AssistantMessageID,
RequestID: meta.RequestID,
EventBus: meta.EventBus,
ServiceID: t.service.ID,
ServiceName: t.service.Name,
MCPToolName: t.mcpTool.Name,
ToolCallID: meta.ToolCallID,
})
if waitErr != nil || !decision.Approved {
return ctx, noop, false
}
// Drop any stale (token-less) cached connection so the retry rebuilds the
// client with the freshly stored token.
_ = t.mcpManager.CloseClient(t.service.ID)
// Re-derive a fresh tool-exec ctx; the authorization wait likely consumed
// the per-tool budget the engine applied to ctx (mirrors the approval gate).
if meta.ApprovalCtx != nil {
freshTimeout := meta.ExecTimeout
if freshTimeout <= 0 {
freshTimeout = 60 * time.Second
}
freshCtx, cancel := context.WithTimeout(meta.ApprovalCtx, freshTimeout)
return freshCtx, cancel, true
}
return ctx, noop, true
}
// 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
+7
View File
@@ -60,6 +60,13 @@ const (
EventToolApprovalRequired EventType = "tool_approval_required"
EventToolApprovalResolved EventType = "tool_approval_resolved"
// MCP OAuth in-conversation authorization prompt: emitted when an
// OAuth-enabled MCP service is invoked but the current user has not
// authorized it yet. The agent pauses until the user authorizes (or the
// wait times out / is canceled).
EventMCPOAuthRequired EventType = "mcp_oauth_required"
EventMCPOAuthResolved EventType = "mcp_oauth_resolved"
// Error events
EventError EventType = "error" // 错误事件
+28
View File
@@ -237,3 +237,31 @@ type ToolApprovalResolvedData struct {
TimedOut bool `json:"timed_out,omitempty"`
Canceled bool `json:"canceled,omitempty"`
}
// MCPOAuthRequiredData is emitted when an OAuth-enabled MCP service is invoked
// during a conversation but the current user has not authorized it yet. The
// UI surfaces an "Authorize" card; the agent pauses until the user authorizes.
type MCPOAuthRequiredData struct {
PendingID string `json:"pending_id"`
TenantID uint64 `json:"tenant_id"`
SessionID string `json:"session_id"`
AssistantMessageID string `json:"assistant_message_id"`
ServiceID string `json:"service_id"`
ServiceName string `json:"service_name"`
MCPToolName string `json:"mcp_tool_name"`
TimeoutSeconds int `json:"timeout_seconds"`
RequestedAtUnix int64 `json:"requested_at"`
ToolCallID string `json:"tool_call_id"`
RequestID string `json:"request_id,omitempty"`
}
// MCPOAuthResolvedData confirms the outcome of an in-conversation OAuth prompt
// (authorized / timeout / cancel).
type MCPOAuthResolvedData struct {
PendingID string `json:"pending_id"`
ServiceID string `json:"service_id"`
Authorized bool `json:"authorized"`
Reason string `json:"reason,omitempty"`
TimedOut bool `json:"timed_out,omitempty"`
Canceled bool `json:"canceled,omitempty"`
}
+88 -1
View File
@@ -1,9 +1,11 @@
package handler
import (
stderrors "errors"
"net/http"
"strings"
"github.com/Tencent/WeKnora/internal/agent/approval"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/mcp"
@@ -21,6 +23,7 @@ type MCPOAuthHandler struct {
oauth *mcp.OAuthManager
mcpManager *mcp.MCPManager
svc interfaces.MCPServiceService
gate *approval.Gate
}
// NewMCPOAuthHandler constructs the handler.
@@ -28,8 +31,9 @@ func NewMCPOAuthHandler(
oauth *mcp.OAuthManager,
mcpManager *mcp.MCPManager,
svc interfaces.MCPServiceService,
gate *approval.Gate,
) *MCPOAuthHandler {
return &MCPOAuthHandler{oauth: oauth, mcpManager: mcpManager, svc: svc}
return &MCPOAuthHandler{oauth: oauth, mcpManager: mcpManager, svc: svc, gate: gate}
}
type mcpOAuthAuthorizeRequest struct {
@@ -202,3 +206,86 @@ func (h *MCPOAuthHandler) Revoke(c *gin.Context) {
_ = h.mcpManager.CloseClient(serviceID)
c.Status(http.StatusNoContent)
}
type resolveMCPOAuthBody struct {
// ServiceID is the MCP service the pending prompt belongs to; used to
// verify the user actually holds a token before resuming the agent.
ServiceID string `json:"service_id" binding:"required"`
}
// ResolveMCPOAuth resumes an agent run that paused on an in-conversation OAuth
// prompt. The frontend calls this once the per-user authorization popup has
// completed; the backend verifies a token now exists for (tenant, user,
// service) before unblocking, so a premature/failed authorization does not
// resume the tool into another failure.
//
// ResolveMCPOAuth godoc
// @Summary 完成对话内 MCP OAuth 授权
// @Description 用户在对话中完成 OAuth 授权后调用,校验令牌存在后恢复被暂停的 Agent 工具调用
// @Tags MCP服务
// @Accept json
// @Produce json
// @Param pending_id path string true "待授权 ID"
// @Param request body map[string]interface{} true "{service_id: string}"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} errors.AppError
// @Failure 409 {object} errors.AppError "用户尚未完成授权"
// @Security Bearer
// @Router /agent/mcp-oauth-resolutions/{pending_id} [post]
func (h *MCPOAuthHandler) ResolveMCPOAuth(c *gin.Context) {
ctx := c.Request.Context()
pendingID := c.Param("pending_id")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
userID, _ := types.UserIDFromContext(ctx)
if tenantID == 0 || strings.TrimSpace(userID) == "" {
c.Error(errors.NewUnauthorizedError("authentication required"))
return
}
if h.gate == nil {
c.Error(errors.NewInternalServerError("OAuth gate is not configured"))
return
}
var body resolveMCPOAuthBody
if err := c.ShouldBindJSON(&body); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
serviceID := strings.TrimSpace(body.ServiceID)
if serviceID == "" {
c.Error(errors.NewValidationError("service_id is required"))
return
}
// Only resume once the user genuinely holds a token; otherwise the retry
// would just fail again with another authorization-required error.
authorized, err := h.oauth.IsAuthorized(ctx, tenantID, userID, serviceID)
if err != nil {
c.Error(errors.NewInternalServerError("failed to verify authorization: " + err.Error()))
return
}
if !authorized {
c.Error(errors.NewConflictError("authorization not completed yet for this MCP service"))
return
}
if err := h.gate.Resolve(tenantID, userID, pendingID, approval.Decision{Approved: true}); err != nil {
switch {
case stderrors.Is(err, approval.ErrPendingNotFound):
c.Error(errors.NewNotFoundError("pending authorization not found or already completed"))
case stderrors.Is(err, approval.ErrAlreadyResolved):
c.Error(errors.NewBadRequestError("pending authorization already resolved (timeout / cancel raced your action)"))
case stderrors.Is(err, approval.ErrTenantMismatch):
c.Error(errors.NewBadRequestError("tenant mismatch"))
case stderrors.Is(err, approval.ErrUserMismatch):
c.Error(errors.NewBadRequestError("user mismatch: only the session owner may resolve this prompt"))
default:
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"pending_id": secutils.SanitizeForLog(pendingID),
})
c.Error(errors.NewInternalServerError(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
@@ -110,6 +110,8 @@ func (h *AgentStreamHandler) Subscribe() {
h.eventBus.On(event.EventAgentComplete, h.handleComplete)
h.eventBus.On(event.EventToolApprovalRequired, h.handleToolApprovalRequired)
h.eventBus.On(event.EventToolApprovalResolved, h.handleToolApprovalResolved)
h.eventBus.On(event.EventMCPOAuthRequired, h.handleMCPOAuthRequired)
h.eventBus.On(event.EventMCPOAuthResolved, h.handleMCPOAuthResolved)
}
// handleThought handles agent thought events
@@ -324,6 +326,49 @@ func (h *AgentStreamHandler) handleToolApprovalResolved(ctx context.Context, evt
return nil
}
// handleMCPOAuthRequired forwards an in-conversation "authorize this MCP
// service" prompt to the SSE stream so the UI can render an Authorize card.
func (h *AgentStreamHandler) handleMCPOAuthRequired(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.MCPOAuthRequiredData)
if !ok {
return nil
}
meta := toolApprovalDataToMap(data)
meta["pending_id"] = data.PendingID
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeMCPOAuthRequired,
Content: "MCP service requires OAuth authorization",
Done: true,
Timestamp: time.Now(),
Data: meta,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append mcp oauth required event failed", "error", err)
}
return nil
}
// handleMCPOAuthResolved forwards the outcome of an in-conversation OAuth prompt.
func (h *AgentStreamHandler) handleMCPOAuthResolved(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.MCPOAuthResolvedData)
if !ok {
return nil
}
meta := toolApprovalDataToMap(data)
meta["pending_id"] = data.PendingID
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeMCPOAuthResolved,
Content: "MCP OAuth authorization resolved",
Done: true,
Timestamp: time.Now(),
Data: meta,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append mcp oauth resolved event failed", "error", err)
}
return nil
}
// handleReferences handles knowledge references events
func (h *AgentStreamHandler) handleReferences(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentReferencesData)
+3
View File
@@ -887,6 +887,9 @@ func RegisterMCPServiceRoutes(
// people who actually have context to approve, so the gate is
// kept at "anyone in the tenant" instead.
agentTool.POST("/tool-approvals/:pending_id", g.Viewer(), handler.ResolveToolApproval)
// Resume an agent run paused on an in-conversation MCP OAuth prompt.
// Same tenant-member (Viewer+) gating rationale as tool-approvals.
agentTool.POST("/mcp-oauth-resolutions/:pending_id", g.Viewer(), oauthHandler.ResolveMCPOAuth)
}
}
+7
View File
@@ -102,6 +102,13 @@ const (
ResponseTypeToolApprovalRequired ResponseType = "tool_approval_required"
// ToolApprovalResolved: user approved/rejected (or timeout); informational for UI replay
ResponseTypeToolApprovalResolved ResponseType = "tool_approval_resolved"
// MCPOAuthRequired: an OAuth-enabled MCP service was invoked but the user
// has not authorized it — UI must surface an "Authorize" prompt and the
// agent pauses until authorization completes (or the wait times out).
ResponseTypeMCPOAuthRequired ResponseType = "mcp_oauth_required"
// MCPOAuthResolved: authorization completed / timed out / canceled;
// informational for UI replay.
ResponseTypeMCPOAuthResolved ResponseType = "mcp_oauth_resolved"
)
// StreamResponse stream response