From b5a1c88f75cdafdddd13fd8c9b413c8aaaa07474 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Mon, 29 Jun 2026 23:20:00 +0800 Subject: [PATCH] fix(mcp): add OAuth skip endpoint and streamline in-chat interaction cards Add a dedicated cancel route so users can skip mid-conversation OAuth without token verification, and restyle OAuth/approval cards to match AgentStreamDisplay with compact inline actions. --- frontend/src/api/mcp-service.ts | 6 +- frontend/src/i18n/locales/en-US.ts | 20 +- frontend/src/i18n/locales/ko-KR.ts | 16 + frontend/src/i18n/locales/ru-RU.ts | 16 + frontend/src/i18n/locales/zh-CN.ts | 16 + .../views/chat/components/McpOAuthCard.vue | 278 ++++--------- .../chat/components/ToolApprovalCard.vue | 374 ++++-------------- .../components/agent-interaction-card.less | 190 +++++++++ internal/handler/mcp_oauth.go | 90 +++++ internal/router/router.go | 1 + 10 files changed, 499 insertions(+), 508 deletions(-) create mode 100644 frontend/src/views/chat/components/agent-interaction-card.less diff --git a/frontend/src/api/mcp-service.ts b/frontend/src/api/mcp-service.ts index cce08784..448de1ca 100644 --- a/frontend/src/api/mcp-service.ts +++ b/frontend/src/api/mcp-service.ts @@ -226,7 +226,11 @@ export async function resolveToolApproval( // the token exists before unblocking the paused tool call. export async function resolveMCPOAuth( pendingId: string, - body: { service_id: string } + body: { service_id: string; decision?: 'authorize' | 'cancel' } ): Promise { await post(`/api/v1/agent/mcp-oauth-resolutions/${encodeURIComponent(pendingId)}`, body) } + +export async function cancelMCPOAuth(pendingId: string): Promise { + await post(`/api/v1/agent/mcp-oauth-resolutions/${encodeURIComponent(pendingId)}/cancel`, {}) +} diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index f9693640..cf008865 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -5035,11 +5035,17 @@ export default { agentStream: { toolApproval: { banner: 'This MCP tool requires human approval. Review parameters before execution.', + waiting: 'Awaiting review · {target}', + targetWithTool: '{service} › {tool}', + titleWithTarget: 'Review · {service} › {tool}', + resolvedApproved: 'Approved · {target}', + resolvedRejected: 'Rejected · {target}', service: 'Service', tool: 'Tool', argsLabel: 'Arguments', argsModified: 'Modified', countdown: 'About {seconds}s remaining', + countdownShort: '{seconds}s', approve: 'Approve & run', reject: 'Reject', approvedTag: 'Approved', @@ -5051,15 +5057,25 @@ export default { }, mcpOAuth: { banner: 'This MCP service requires OAuth authorization before it can be used', + waiting: 'Awaiting authorization · {target}', + targetWithTool: '{service} › {tool}', + titleWithService: 'OAuth · {service}', + titleWithTool: 'OAuth · {service} › {tool}', + resolvedAuthorized: 'Authorized · {target}', + resolvedTimedOut: 'Timed out · {target}', + resolvedCanceled: 'Skipped · {target}', desc: 'Authorizing opens a new window to sign in. The tool call resumes automatically once authorization succeeds.', authorize: 'Authorize', + skip: 'Skip', countdown: 'About {seconds}s remaining', + countdownShort: '{seconds}s', authorizedTag: 'Authorized', timedOutTag: 'Authorization timed out', canceledTag: 'Canceled', - authorizedToast: 'Authorized, resuming', + authorizedToast: 'Authorized. Resuming…', startFailed: 'Failed to start authorization', - resumeFailed: 'Failed to resume execution, please retry', + resumeFailed: 'Failed to resume. Please try again.', + skipFailed: 'Failed to skip. Please try again.', }, tools: { searchKnowledge: 'Knowledge Search', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 669b04fb..3efeb769 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -5052,11 +5052,17 @@ export default { agentStream: { toolApproval: { banner: '이 MCP 도구는 수동 승인이 필요합니다. 실행 전 매개변수를 확인하세요.', + waiting: '승인 대기 · {target}', + targetWithTool: '{service} › {tool}', + titleWithTarget: '검토 · {service} › {tool}', + resolvedApproved: '승인됨 · {target}', + resolvedRejected: '거부됨 · {target}', service: '서비스', tool: '도구', argsLabel: '인수', argsModified: '수정됨', countdown: '약 {seconds}초 남음', + countdownShort: '{seconds}초', approve: '승인 후 실행', reject: '거부', approvedTag: '승인됨', @@ -5068,15 +5074,25 @@ export default { }, mcpOAuth: { banner: '이 MCP 서비스는 OAuth 인증 후에 사용할 수 있습니다', + waiting: '인증 대기 · {target}', + targetWithTool: '{service} › {tool}', + titleWithService: 'OAuth · {service}', + titleWithTool: 'OAuth · {service} › {tool}', + resolvedAuthorized: '인증됨 · {target}', + resolvedTimedOut: '시간 초과 · {target}', + resolvedCanceled: '건너뜀 · {target}', desc: '인증을 누르면 새 창에서 로그인합니다. 인증이 완료되면 이 도구 호출이 자동으로 계속됩니다.', authorize: '인증하기', + skip: '건너뛰기', countdown: '약 {seconds}초 남음', + countdownShort: '{seconds}초', authorizedTag: '인증됨', timedOutTag: '인증 시간 초과', canceledTag: '취소됨', authorizedToast: '인증 성공, 계속 진행 중', startFailed: '인증 시작 실패', resumeFailed: '실행 재개 실패, 다시 시도하세요', + skipFailed: '건너뛰기 실패, 다시 시도하세요', }, tools: { searchKnowledge: '지식베이스 검색', diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index 40f9a4c8..1221acbc 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -4552,11 +4552,17 @@ export default { agentStream: { toolApproval: { banner: 'Этот инструмент MCP требует подтверждения. Проверьте параметры.', + waiting: 'Ожидание проверки · {target}', + targetWithTool: '{service} › {tool}', + titleWithTarget: 'Проверка · {service} › {tool}', + resolvedApproved: 'Подтверждено · {target}', + resolvedRejected: 'Отклонено · {target}', service: 'Сервис', tool: 'Инструмент', argsLabel: 'Аргументы', argsModified: 'Изменено', countdown: 'Осталось около {seconds} с', + countdownShort: '{seconds} с', approve: 'Подтвердить и выполнить', reject: 'Отклонить', approvedTag: 'Подтверждено', @@ -4568,15 +4574,25 @@ export default { }, mcpOAuth: { banner: 'Этот MCP-сервис требует авторизации OAuth перед использованием', + waiting: 'Ожидание авторизации · {target}', + targetWithTool: '{service} › {tool}', + titleWithService: 'OAuth · {service}', + titleWithTool: 'OAuth · {service} › {tool}', + resolvedAuthorized: 'Авторизовано · {target}', + resolvedTimedOut: 'Тайм-аут · {target}', + resolvedCanceled: 'Пропущено · {target}', desc: 'Авторизация откроет новое окно для входа. После успешной авторизации вызов инструмента продолжится автоматически.', authorize: 'Авторизовать', + skip: 'Пропустить', countdown: 'Осталось примерно {seconds}с', + countdownShort: '{seconds} с', authorizedTag: 'Авторизовано', timedOutTag: 'Время авторизации истекло', canceledTag: 'Отменено', authorizedToast: 'Авторизация успешна, продолжаем', startFailed: 'Не удалось начать авторизацию', resumeFailed: 'Не удалось возобновить выполнение, повторите попытку', + skipFailed: 'Не удалось пропустить, повторите попытку', }, tools: { searchKnowledge: 'Поиск по базе знаний', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index a0938cd4..80f587cb 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -5048,11 +5048,17 @@ export default { agentStream: { toolApproval: { banner: "该 MCP 工具已标记为「需人工审核」,确认参数后再执行", + waiting: "等待审核 · {target}", + targetWithTool: "{service} › {tool}", + titleWithTarget: "人工审核 · {service} › {tool}", + resolvedApproved: "已通过 · {target}", + resolvedRejected: "已拒绝 · {target}", service: "服务", tool: "工具", argsLabel: "调用参数", argsModified: "已修改", countdown: "剩余约 {seconds} 秒", + countdownShort: "{seconds}s", approve: "通过并执行", reject: "拒绝", approvedTag: "已通过", @@ -5064,15 +5070,25 @@ export default { }, mcpOAuth: { banner: "该 MCP 服务需要 OAuth 授权后才能调用", + waiting: "等待授权 · {target}", + targetWithTool: "{service} › {tool}", + titleWithService: "OAuth 授权 · {service}", + titleWithTool: "OAuth 授权 · {service} › {tool}", + resolvedAuthorized: "已授权 · {target}", + resolvedTimedOut: "授权超时 · {target}", + resolvedCanceled: "已跳过 · {target}", desc: "点击授权将打开新窗口完成登录,授权成功后会自动继续本次工具调用。", authorize: "去授权", + skip: "跳过", countdown: "剩余约 {seconds} 秒", + countdownShort: "{seconds}s", authorizedTag: "已授权", timedOutTag: "授权超时", canceledTag: "已取消", authorizedToast: "授权成功,正在继续", startFailed: "发起授权失败", resumeFailed: "恢复执行失败,请重试", + skipFailed: "跳过失败,请重试", }, tools: { searchKnowledge: "知识库检索", diff --git a/frontend/src/views/chat/components/McpOAuthCard.vue b/frontend/src/views/chat/components/McpOAuthCard.vue index b2712fdb..6fb262dc 100644 --- a/frontend/src/views/chat/components/McpOAuthCard.vue +++ b/frontend/src/views/chat/components/McpOAuthCard.vue @@ -1,52 +1,36 @@ @@ -56,6 +40,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { MessagePlugin } from 'tdesign-vue-next' import { useI18n } from 'vue-i18n' import { + cancelMCPOAuth, getMCPOAuthAuthorizeURL, getMCPOAuthStatus, resolveMCPOAuth, @@ -79,6 +64,7 @@ const props = defineProps<{ const { t } = useI18n() const authorizing = ref(false) +const canceling = ref(false) const now = ref(Date.now()) let clock: ReturnType | null = null let poll: ReturnType | null = null @@ -100,15 +86,37 @@ const timerClass = computed(() => { return '' }) +const headerIcon = computed(() => { + if (!props.resolved) return 'lock-on' + if (props.authorized) return 'check-circle' + return 'close-circle' +}) + +const targetLabel = computed(() => ( + props.mcpToolName + ? t('agentStream.mcpOAuth.targetWithTool', { service: props.serviceName, tool: props.mcpToolName }) + : props.serviceName +)) + +const titleLine = computed(() => { + if (!props.resolved) { + return t('agentStream.mcpOAuth.waiting', { target: targetLabel.value }) + } + if (props.authorized) { + return t('agentStream.mcpOAuth.resolvedAuthorized', { target: targetLabel.value }) + } + if (props.timedOut) return t('agentStream.mcpOAuth.resolvedTimedOut', { target: targetLabel.value }) + return t('agentStream.mcpOAuth.resolvedCanceled', { target: targetLabel.value }) +}) + const cardClass = computed(() => ({ - 'is-resolved': !!props.resolved, - 'is-authorized': !!props.resolved && !!props.authorized, - 'is-failed': !!props.resolved && !props.authorized, - 'is-pending': !props.resolved, + 'action-pending': !props.resolved, + 'action-success': !!props.resolved && !!props.authorized, + 'action-error': !!props.resolved && !props.authorized, })) function formatCountdown(s: number): string { - if (s < 60) return t('agentStream.mcpOAuth.countdown', { seconds: s }) + if (s < 60) return t('agentStream.mcpOAuth.countdownShort', { seconds: s }) const m = Math.floor(s / 60) const r = s % 60 return `${m}:${r.toString().padStart(2, '0')}` @@ -121,6 +129,19 @@ function stopPoll() { } } +const skip = async () => { + if (props.resolved || canceling.value) return + canceling.value = true + try { + await cancelMCPOAuth(props.pendingId) + } catch (e: any) { + const msg = e?.response?.data?.error?.message || e?.message || t('agentStream.mcpOAuth.skipFailed') + MessagePlugin.error(msg) + } finally { + canceling.value = false + } +} + const authorize = async () => { if (props.resolved || authorizing.value) return authorizing.value = true @@ -137,7 +158,6 @@ const authorize = async () => { 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 @@ -150,7 +170,7 @@ const authorize = async () => { stopPoll() try { popup?.close() } catch { /* cross-origin close may throw */ } try { - await resolveMCPOAuth(props.pendingId, { service_id: props.serviceId }) + await resolveMCPOAuth(props.pendingId, { service_id: props.serviceId, decision: 'authorize' }) MessagePlugin.success(t('agentStream.mcpOAuth.authorizedToast')) } catch (e: any) { const msg = e?.response?.data?.error?.message || e?.message || t('agentStream.mcpOAuth.resumeFailed') @@ -182,151 +202,5 @@ onBeforeUnmount(() => { diff --git a/frontend/src/views/chat/components/ToolApprovalCard.vue b/frontend/src/views/chat/components/ToolApprovalCard.vue index 57ec1c3c..da29ab66 100644 --- a/frontend/src/views/chat/components/ToolApprovalCard.vue +++ b/frontend/src/views/chat/components/ToolApprovalCard.vue @@ -1,80 +1,60 @@ @@ -111,7 +91,6 @@ function formatJson(raw: string): string { const initialArgs = formatJson(props.argsJson || '{}') const argsText = ref(initialArgs) const submitting = ref(false) -const pendingDecision = ref<'approve' | 'reject' | null>(null) const now = ref(Date.now()) let timer: ReturnType | null = null @@ -144,15 +123,33 @@ const timerClass = computed(() => { return '' }) +const headerIcon = computed(() => { + if (!props.resolved) return 'secured' + if (props.approved) return 'check-circle' + return 'close-circle' +}) + +const targetLabel = computed(() => ( + t('agentStream.toolApproval.targetWithTool', { + service: props.serviceName, + tool: props.mcpToolName, + }) +)) + +const titleLine = computed(() => { + if (!props.resolved) return t('agentStream.toolApproval.waiting', { target: targetLabel.value }) + if (props.approved) return t('agentStream.toolApproval.resolvedApproved', { target: targetLabel.value }) + return t('agentStream.toolApproval.resolvedRejected', { target: targetLabel.value }) +}) + const cardClass = computed(() => ({ - 'is-resolved': !!props.resolved, - 'is-approved': !!props.resolved && !!props.approved, - 'is-rejected': !!props.resolved && !props.approved, - 'is-pending': !props.resolved, + 'action-pending': !props.resolved, + 'action-success': !!props.resolved && !!props.approved, + 'action-error': !!props.resolved && !props.approved, })) function formatCountdown(s: number): string { - if (s < 60) return t('agentStream.toolApproval.countdown', { seconds: s }) + if (s < 60) return t('agentStream.toolApproval.countdownShort', { seconds: s }) const m = Math.floor(s / 60) const r = s % 60 return `${m}:${r.toString().padStart(2, '0')}` @@ -169,9 +166,8 @@ onBeforeUnmount(() => { }) const submit = async (decision: 'approve' | 'reject') => { - if (props.resolved) return + if (props.resolved || submitting.value) return submitting.value = true - pendingDecision.value = decision try { let modified: Record | undefined if (decision === 'approve') { @@ -193,238 +189,10 @@ const submit = async (decision: 'approve' | 'reject') => { MessagePlugin.error(msg) } finally { submitting.value = false - pendingDecision.value = null } } diff --git a/frontend/src/views/chat/components/agent-interaction-card.less b/frontend/src/views/chat/components/agent-interaction-card.less new file mode 100644 index 00000000..97ab5703 --- /dev/null +++ b/frontend/src/views/chat/components/agent-interaction-card.less @@ -0,0 +1,190 @@ +// Inline interaction rows for OAuth / tool-approval in agent stream. + +.action-card.interaction-inline { + gap: 0; +} + +.action-header { + display: flex; + align-items: center; + min-height: 24px; + padding: 0; + cursor: default; + user-select: none; +} + +.action-card { + background: transparent; + border: 0; + box-shadow: none; + display: flex; + flex-direction: column; + + &.action-error .action-title-icon.t-icon { + color: var(--td-error-color); + } + + &.action-success .action-title-icon.t-icon { + color: var(--td-success-color); + } +} + +.inline-dot { + color: var(--td-text-color-placeholder); + font-size: 12px; + line-height: 1; + user-select: none; +} + +.inline-action { + appearance: none; + border: 0; + background: transparent; + padding: 0; + margin: 0; + font-size: var(--agent-step-summary-size, 13px); + line-height: 1.55; + color: var(--td-text-color-placeholder); + cursor: pointer; + transition: color 0.15s ease; + + &:hover:not(:disabled) { + color: var(--td-text-color-secondary); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + &.is-primary { + color: var(--td-brand-color); + + &:hover:not(:disabled) { + color: var(--td-brand-color-hover); + } + } +} + +.action-title { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; + flex-wrap: wrap; + row-gap: 2px; + + .action-title-icon.t-icon { + flex-shrink: 0; + width: 18px; + height: 18px; + color: var(--agent-step-icon-color, var(--td-text-color-placeholder)); + } + + .action-name { + font-size: var(--agent-step-text-size, 14px); + line-height: 1.55; + font-weight: 400; + color: var(--td-text-color-secondary); + white-space: nowrap; + flex-shrink: 0; + } + + .inline-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + } + + .action-timer { + margin-left: auto; + font-size: 12px; + line-height: 1.55; + font-variant-numeric: tabular-nums; + color: var(--td-text-color-placeholder); + white-space: nowrap; + flex-shrink: 0; + + &.timer-warning { + color: var(--td-warning-color); + } + + &.timer-critical { + color: var(--td-error-color); + } + } +} + +.interaction-panel { + padding: 4px 0 0 30px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.interaction-args-input { + :deep(.t-textarea__inner) { + font-family: var(--app-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 11px; + line-height: 1.55; + background: var(--td-bg-color-container); + border-color: var(--td-component-stroke); + color: var(--td-text-color-primary); + padding: 8px 10px; + border-radius: 6px; + min-height: 48px; + } +} + +.interaction-args-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--td-text-color-placeholder); + + .args-status { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 11px; + } + + .args-invalid { + color: var(--td-error-color); + } + + .args-dirty { + color: var(--td-warning-color); + } +} + +.detail-output-wrapper { + background: var(--td-bg-color-secondarycontainer); + border: 1px solid var(--td-component-stroke); + border-radius: 6px; + overflow: hidden; + + .detail-output { + font-family: var(--app-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 11px; + color: var(--td-text-color-primary); + padding: 10px 12px; + margin: 0; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.55; + max-height: 160px; + overflow: auto; + background: var(--td-bg-color-container); + } +} + +.interaction-panel-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} diff --git a/internal/handler/mcp_oauth.go b/internal/handler/mcp_oauth.go index 51a79638..7ef32046 100644 --- a/internal/handler/mcp_oauth.go +++ b/internal/handler/mcp_oauth.go @@ -211,6 +211,8 @@ 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"` + // Decision is "authorize" (default) or "cancel" when the user skips OAuth. + Decision string `json:"decision"` } // ResolveMCPOAuth resumes an agent run that paused on an in-conversation OAuth @@ -257,6 +259,43 @@ func (h *MCPOAuthHandler) ResolveMCPOAuth(c *gin.Context) { return } + decision := strings.TrimSpace(strings.ToLower(body.Decision)) + if decision == "" { + decision = "authorize" + } + + switch decision { + case "cancel", "reject", "skip": + if err := h.gate.Resolve(tenantID, userID, pendingID, approval.Decision{ + Approved: false, + Reason: "user canceled", + }); 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}) + return + case "authorize": + // continue below + default: + c.Error(errors.NewBadRequestError("decision must be authorize or cancel")) + 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) @@ -289,3 +328,54 @@ func (h *MCPOAuthHandler) ResolveMCPOAuth(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"success": true}) } + +// CancelMCPOAuth lets the user skip an in-conversation OAuth prompt without +// completing authorization. This unblocks the paused agent with a denial. +// +// CancelMCPOAuth godoc +// @Summary 跳过对话内 MCP OAuth 授权 +// @Description 用户主动跳过 OAuth 授权,解除 Agent 阻塞 +// @Tags MCP服务 +// @Produce json +// @Param pending_id path string true "待授权 ID" +// @Success 200 {object} map[string]interface{} +// @Failure 404 {object} errors.AppError +// @Security Bearer +// @Router /agent/mcp-oauth-resolutions/{pending_id}/cancel [post] +func (h *MCPOAuthHandler) CancelMCPOAuth(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 + } + + if err := h.gate.Resolve(tenantID, userID, pendingID, approval.Decision{ + Approved: false, + Reason: "user canceled", + }); 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}) +} diff --git a/internal/router/router.go b/internal/router/router.go index de7f8daf..45e0eba5 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -890,6 +890,7 @@ func RegisterMCPServiceRoutes( // 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) + agentTool.POST("/mcp-oauth-resolutions/:pending_id/cancel", g.Viewer(), oauthHandler.CancelMCPOAuth) } }