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.
This commit is contained in:
@@ -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<void> {
|
||||
await post(`/api/v1/agent/mcp-oauth-resolutions/${encodeURIComponent(pendingId)}`, body)
|
||||
}
|
||||
|
||||
export async function cancelMCPOAuth(pendingId: string): Promise<void> {
|
||||
await post(`/api/v1/agent/mcp-oauth-resolutions/${encodeURIComponent(pendingId)}/cancel`, {})
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '지식베이스 검색',
|
||||
|
||||
@@ -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: 'Поиск по базе знаний',
|
||||
|
||||
@@ -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: "知识库检索",
|
||||
|
||||
@@ -1,52 +1,36 @@
|
||||
<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 class="action-card interaction-inline" :class="cardClass">
|
||||
<div class="action-header no-results">
|
||||
<div class="action-title">
|
||||
<t-icon class="action-title-icon" :name="headerIcon" />
|
||||
<span class="action-name">{{ titleLine }}</span>
|
||||
<span v-if="!resolved" class="inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-action"
|
||||
:disabled="canceling || authorizing"
|
||||
@click="skip"
|
||||
>
|
||||
{{ $t('agentStream.mcpOAuth.skip') }}
|
||||
</button>
|
||||
<span class="inline-dot">·</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-action is-primary"
|
||||
:disabled="authorizing || canceling"
|
||||
@click="authorize"
|
||||
>
|
||||
{{ $t('agentStream.mcpOAuth.authorize') }}
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-if="!resolved && secondsLeft >= 0"
|
||||
class="action-timer"
|
||||
:class="timerClass"
|
||||
>
|
||||
{{ formatCountdown(secondsLeft) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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<typeof setInterval> | null = null
|
||||
let poll: ReturnType<typeof setInterval> | 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(() => {
|
||||
</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; }
|
||||
}
|
||||
@import './agent-interaction-card.less';
|
||||
</style>
|
||||
|
||||
@@ -1,80 +1,60 @@
|
||||
<template>
|
||||
<div class="approval-card" :class="cardClass">
|
||||
<!-- Status strip -->
|
||||
<div class="approval-strip">
|
||||
<span class="approval-strip-icon">
|
||||
<t-icon v-if="!resolved" name="info-circle-filled" />
|
||||
<t-icon v-else-if="approved" name="check-circle-filled" />
|
||||
<t-icon v-else name="close-circle-filled" />
|
||||
</span>
|
||||
<span class="approval-strip-text">
|
||||
<template v-if="!resolved">{{ $t('agentStream.toolApproval.banner') }}</template>
|
||||
<template v-else-if="approved">{{ $t('agentStream.toolApproval.approvedTag') }}</template>
|
||||
<template v-else>{{ $t('agentStream.toolApproval.rejectedTag') }}</template>
|
||||
</span>
|
||||
<span v-if="!resolved && secondsLeft >= 0" class="approval-strip-timer" :class="timerClass">
|
||||
<t-icon name="time" />
|
||||
{{ formatCountdown(secondsLeft) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Identity row -->
|
||||
<div class="approval-identity">
|
||||
<span class="ident-service">{{ serviceName }}</span>
|
||||
<t-icon name="chevron-right" class="ident-sep" />
|
||||
<span class="ident-tool">{{ mcpToolName }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="description" class="approval-desc">{{ description }}</div>
|
||||
|
||||
<!-- Args (editable while pending, read-only after resolve) -->
|
||||
<div class="approval-args">
|
||||
<div class="approval-args-label">
|
||||
<span class="args-label-text">{{ $t('agentStream.toolApproval.argsLabel') }}</span>
|
||||
<span v-if="!resolved && !isJsonValid" class="args-status args-invalid">
|
||||
<t-icon name="error-circle" /> {{ $t('agentStream.toolApproval.invalidJson') }}
|
||||
<div class="action-card interaction-inline" :class="cardClass">
|
||||
<div class="action-header no-results">
|
||||
<div class="action-title">
|
||||
<t-icon class="action-title-icon" :name="headerIcon" />
|
||||
<span class="action-name">{{ titleLine }}</span>
|
||||
<span
|
||||
v-if="!resolved && secondsLeft >= 0"
|
||||
class="action-timer"
|
||||
:class="timerClass"
|
||||
>
|
||||
{{ formatCountdown(secondsLeft) }}
|
||||
</span>
|
||||
<span v-else-if="!resolved && isJsonValid && argsDirty" class="args-status args-dirty">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!resolved" class="interaction-panel">
|
||||
<div v-if="!isJsonValid || argsDirty" class="interaction-args-meta">
|
||||
<span v-if="!isJsonValid" class="args-status args-invalid">
|
||||
<t-icon name="error-circle" />
|
||||
{{ $t('agentStream.toolApproval.invalidJson') }}
|
||||
</span>
|
||||
<span v-else-if="argsDirty" class="args-status args-dirty">
|
||||
{{ $t('agentStream.toolApproval.argsModified') }}
|
||||
</span>
|
||||
</div>
|
||||
<t-textarea
|
||||
v-if="!resolved"
|
||||
v-model="argsText"
|
||||
class="approval-args-input"
|
||||
:autosize="{ minRows: 3, maxRows: 14 }"
|
||||
class="interaction-args-input"
|
||||
:autosize="{ minRows: 2, maxRows: 8 }"
|
||||
placeholder="{}"
|
||||
/>
|
||||
<pre v-else class="approval-args-readonly"><code>{{ argsText }}</code></pre>
|
||||
<div class="interaction-panel-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-action"
|
||||
:disabled="submitting"
|
||||
@click="submit('reject')"
|
||||
>
|
||||
{{ $t('agentStream.toolApproval.reject') }}
|
||||
</button>
|
||||
<span class="inline-dot">·</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-action is-primary"
|
||||
:disabled="submitting || !isJsonValid"
|
||||
@click="submit('approve')"
|
||||
>
|
||||
{{ $t('agentStream.toolApproval.approve') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer (pending) -->
|
||||
<div v-if="!resolved" class="approval-footer">
|
||||
<span class="approval-spacer" />
|
||||
<t-button
|
||||
theme="default"
|
||||
variant="outline"
|
||||
size="small"
|
||||
:loading="submitting && pendingDecision === 'reject'"
|
||||
:disabled="submitting"
|
||||
@click="submit('reject')"
|
||||
>
|
||||
{{ $t('agentStream.toolApproval.reject') }}
|
||||
</t-button>
|
||||
<t-button
|
||||
theme="primary"
|
||||
size="small"
|
||||
:loading="submitting && pendingDecision === 'approve'"
|
||||
:disabled="submitting || !isJsonValid"
|
||||
@click="submit('approve')"
|
||||
>
|
||||
{{ $t('agentStream.toolApproval.approve') }}
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<!-- Footer (resolved) -->
|
||||
<div v-else class="approval-resolved-footer">
|
||||
<span v-if="resolveReason" class="approval-resolved-reason">{{ resolveReason }}</span>
|
||||
<div v-else-if="argsText" class="interaction-panel">
|
||||
<div class="detail-output-wrapper">
|
||||
<div class="detail-output">{{ argsText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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<typeof setInterval> | 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<string, unknown> | undefined
|
||||
if (decision === 'approve') {
|
||||
@@ -193,238 +189,10 @@ const submit = async (decision: 'approve' | 'reject') => {
|
||||
MessagePlugin.error(msg)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
pendingDecision.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@warning-rgb: 237, 122, 11;
|
||||
@success-rgb: 7, 192, 95;
|
||||
@danger-rgb: 232, 80, 91;
|
||||
|
||||
.approval-card {
|
||||
--strip-color: var(--td-warning-color);
|
||||
--strip-rgb: @warning-rgb;
|
||||
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-pending {
|
||||
box-shadow: 0 1px 6px rgba(@warning-rgb, 0.08);
|
||||
}
|
||||
|
||||
&.is-approved {
|
||||
--strip-color: var(--td-success-color);
|
||||
--strip-rgb: @success-rgb;
|
||||
opacity: 0.94;
|
||||
}
|
||||
|
||||
&.is-rejected {
|
||||
--strip-color: var(--td-error-color);
|
||||
--strip-rgb: @danger-rgb;
|
||||
opacity: 0.94;
|
||||
}
|
||||
}
|
||||
|
||||
.approval-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);
|
||||
|
||||
.approval-strip-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
.t-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
.approval-strip-text {
|
||||
flex: 1;
|
||||
color: var(--strip-color);
|
||||
}
|
||||
.approval-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.approval-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;
|
||||
}
|
||||
}
|
||||
|
||||
.approval-desc {
|
||||
padding: 0 12px 4px 14px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
.approval-args {
|
||||
padding: 8px 12px 0 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.approval-args-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
|
||||
.args-label-text {
|
||||
color: var(--td-text-color-placeholder);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.args-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
.t-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.args-invalid {
|
||||
color: var(--td-error-color);
|
||||
}
|
||||
.args-dirty {
|
||||
color: var(--td-warning-color);
|
||||
}
|
||||
}
|
||||
|
||||
.approval-args-input {
|
||||
:deep(.t-textarea__inner) {
|
||||
font-family: var(--td-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-color: var(--td-component-stroke);
|
||||
color: var(--td-text-color-primary);
|
||||
padding: 8px 10px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--td-brand-color-hover);
|
||||
}
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
border-color: var(--td-brand-color);
|
||||
box-shadow: 0 0 0 2px rgba(@success-rgb, 0.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.approval-args-readonly {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border: 1px solid var(--td-component-stroke);
|
||||
border-radius: 4px;
|
||||
font-family: var(--td-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--td-text-color-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.approval-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 12px 14px;
|
||||
}
|
||||
|
||||
.approval-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.approval-resolved-footer {
|
||||
padding: 6px 12px 10px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-secondary);
|
||||
min-height: 0;
|
||||
|
||||
.approval-resolved-reason {
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes timerPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
@import './agent-interaction-card.less';
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user