Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/features/oauth-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,25 @@ MCPProxy automatically refreshes tokens before expiration:
2. Uses refresh token to get new access token
3. Falls back to browser re-authentication if refresh fails

### Signing in from the Web UI

When an OAuth-protected server has no usable token, the Web UI does **not**
render it as a generic red "Server Error". Instead it surfaces a calm,
actionable **Sign-in** state:

- The server's status chip reads an amber **"Sign-in required"** (rather than a
red "Disconnected"/"Unhealthy"), on both the Servers list and the server
detail page.
- The server detail page shows a calm amber **"🔑 Sign in to {server}"** panel
with a primary **Log in** button (it triggers the same browser OAuth flow as
`mcpproxy auth login`) and a link to this page. No "file a bug" prompt is
shown for OAuth states — signing in is the fix.
- If a prior session **expired or was revoked**, the panel keeps an error tone
and leads with a **Re-login** button.
- A server can be both quarantined and login-required: you may sign in while it
is quarantined, but its tools stay blocked until you **Approve** the server.
The panel makes both gates explicit.

## CLI Commands

### Start Authentication
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/ServerCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ import { useServersStore } from '@/stores/servers'
import { useSystemStore } from '@/stores/system'
import { useSecurityScannerStatus } from '@/composables/useSecurityScannerStatus'
import { serverDetailPath, serverDisplayName } from '@/utils/serverRoute'
import { oauthSignInState } from '@/utils/health'

interface Props {
server: Server
Expand All @@ -367,6 +368,12 @@ const isHttpProtocol = computed(() => {
return props.server.protocol === 'http' || props.server.protocol === 'streamable-http'
})

// MCP-1821 — OAuth sign-in state (null when no sign-in is required). When set,
// the status chip reads a calm amber "Sign-in required" instead of red
// "Disconnected"/"Unhealthy", matching the ServerDetail Sign-in CTA. The
// existing health.action==='login' Login button (below) drives the action.
const signInState = computed(() => oauthSignInState(props.server))

// Unified health status computed properties
const statusBadgeClass = computed(() => {
const health = props.server.health
Expand All @@ -378,6 +385,8 @@ const statusBadgeClass = computed(() => {
case 'quarantined':
return 'badge-secondary' // purple-ish
default:
// MCP-1821 — sign-in required reads amber, not red.
if (signInState.value) return 'badge-warning'
// Use health level
switch (health.level) {
case 'healthy':
Expand All @@ -400,6 +409,8 @@ const statusBadgeClass = computed(() => {
const statusText = computed(() => {
const health = props.server.health
if (health) {
// MCP-1821 — surface an actionable "Sign-in required" for OAuth login states.
if (health.admin_state === 'enabled' && signInState.value) return 'Sign-in required'
return health.summary || health.level
}
// Fallback to legacy logic
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/components/diagnostics/ErrorPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
<div v-if="expanded" class="mt-3 space-y-2" data-testid="error-panel-fix-steps">
<ol class="list-decimal list-inside space-y-2 text-sm">
<li
v-for="(step, idx) in (diagnostic.fix_steps || [])"
v-for="(step, idx) in visibleFixSteps"
:key="idx"
class="flex flex-col gap-1"
>
Expand Down Expand Up @@ -140,6 +140,7 @@ import { computed, ref } from 'vue'
import type { Diagnostic, DiagnosticFixStep } from '@/types'
import api from '@/services/api'
import { useSystemStore } from '@/stores/system'
import { isOAuthDiagnosticCode } from '@/utils/health'

interface Props {
diagnostic: Diagnostic | null | undefined
Expand Down Expand Up @@ -177,6 +178,17 @@ const headerTitle = computed(() => {
return 'Diagnostic'
})

// MCP-1821 — the generic "Report a bug" / issues/new link is only appropriate
// for a genuinely unclassified fault. For OAuth codes the actionable path is to
// sign in (surfaced by SignInPanel), so suppress any bug-report fix step here.
const visibleFixSteps = computed<DiagnosticFixStep[]>(() => {
const steps = props.diagnostic?.fix_steps || []
if (!isOAuthDiagnosticCode(props.diagnostic?.code)) return steps
return steps.filter(
(step) => !(step.type === 'link' && (step.url || '').includes('issues/new')),
)
})

function isFixing(key: string | undefined) {
if (!key) return false
return runningFixers.value.has(key)
Expand Down
98 changes: 98 additions & 0 deletions frontend/src/components/diagnostics/SignInPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<template>
<!--
MCP-1821 — calm OAuth Sign-in CTA.

Replaces the red "Server Error / file a bug" panel for OAuth-protected
upstreams that simply need the user to authenticate. A first-time login is
a calm amber prompt; an expired/revoked session keeps an error tone but
still leads with the actionable Re-login button.
-->
<div
class="alert"
:class="isReauth ? 'alert-error' : 'alert-warning'"
role="alert"
:aria-label="title"
data-test="oauth-signin-panel"
>
<span class="text-2xl shrink-0" aria-hidden="true">🔑</span>
<div class="w-full">
<h3 class="font-bold" data-test="oauth-signin-title">{{ title }}</h3>
<p class="text-sm mt-1" data-test="oauth-signin-body">{{ body }}</p>

<!-- Quarantine coexists: login is allowed while quarantined, but tools
stay blocked until the server is approved. Clarify both gates. -->
<p
v-if="quarantined"
class="text-xs opacity-80 mt-2"
data-test="oauth-signin-quarantine-note"
>
This server is also quarantined. You can sign in now, but its tools stay
blocked until you Approve the server.
</p>

<div class="mt-3 flex items-center gap-3 flex-wrap">
<button
type="button"
class="btn btn-sm"
:class="isReauth ? 'btn-error' : 'btn-warning'"
:disabled="loading"
data-test="oauth-signin-login-btn"
@click="$emit('login')"
>
<span v-if="loading" class="loading loading-spinner loading-xs"></span>
{{ buttonLabel }}
</button>

<a
v-if="resolvedDocsUrl"
:href="resolvedDocsUrl"
target="_blank"
rel="noopener noreferrer"
class="link link-hover text-xs"
data-test="oauth-signin-docs-link"
>
Learn about OAuth sign-in →
</a>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import type { OAuthSignInState } from '@/utils/health'

interface Props {
serverName: string
state: OAuthSignInState
docsUrl?: string
quarantined?: boolean
loading?: boolean
}

const props = defineProps<Props>()

defineEmits<{
(e: 'login'): void
}>()

const DEFAULT_DOCS_URL = 'https://docs.mcpproxy.app/features/oauth-authentication'

const isReauth = computed(() => props.state === 'reauth')

const title = computed(() =>
isReauth.value
? `🔑 Session expired — sign in to ${props.serverName}`
: `🔑 Sign in to ${props.serverName}`,
)

const body = computed(() =>
isReauth.value
? `Your session for ${props.serverName} expired or was revoked. Sign in again to reconnect.`
: `${props.serverName} needs you to sign in before mcpproxy can connect and discover its tools.`,
)

const buttonLabel = computed(() => (isReauth.value ? 'Re-login' : 'Log in'))

const resolvedDocsUrl = computed(() => props.docsUrl || DEFAULT_DOCS_URL)
</script>
52 changes: 52 additions & 0 deletions frontend/src/utils/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,58 @@ export function getHealthBadgeClass(level: string): string {
}
}

/**
* MCP-1821 — OAuth sign-in CTA helpers.
*
* An OAuth-protected upstream that has no usable token surfaces as
* health.action==="login". Rather than render it as a red "Server Error"
* with a file-a-bug CTA, the UI renders a calm "Sign in" panel.
*
* Two flavours:
* - 'login' — first-time / no-session-yet. Calm amber tone, "Log in".
* - 'reauth' — a prior session expired or was revoked. Error tone, "Re-login".
*/

/**
* Diagnostic codes that mean an existing OAuth session expired / was revoked
* and the user must re-authenticate. These keep an error tone (vs. the calm
* amber of a first-time login).
*/
export const OAuthReauthCodes = [
'MCPX_OAUTH_REAUTH_REQUIRED',
'MCPX_OAUTH_REFRESH_EXPIRED',
'MCPX_OAUTH_REFRESH_403',
] as const

/**
* True for any diagnostic code in the OAuth domain (MCPX_OAUTH_*). Used to
* suppress the generic "file a bug" CTA — OAuth faults are actionable via
* sign-in, not a bug report.
*/
export function isOAuthDiagnosticCode(code: string | undefined | null): boolean {
if (!code) return false
return code.toUpperCase().includes('OAUTH')
}

export type OAuthSignInState = 'login' | 'reauth'

/**
* Classify a server's OAuth sign-in state for the calm Sign-in CTA.
*
* @returns 'reauth' when a prior session expired (error tone, "Re-login"),
* 'login' for a first-time login-required state (calm amber, "Log in"),
* or null when no sign-in is required.
*/
export function oauthSignInState(server: Server): OAuthSignInState | null {
const code = server.diagnostic?.code
if (code && (OAuthReauthCodes as readonly string[]).includes(code)) {
return 'reauth'
}
if (server.health?.action === HealthAction.Login) return 'login'
if (code === 'MCPX_OAUTH_LOGIN_REQUIRED') return 'login'
return null
}

/**
* Get the appropriate badge class for an admin state
*
Expand Down
47 changes: 39 additions & 8 deletions frontend/src/views/ServerDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,10 @@

<div class="flex items-center space-x-2">
<div
:class="[
'badge badge-lg',
server.connected ? 'badge-success' :
server.connecting ? 'badge-warning' :
'badge-error'
]"
:class="['badge badge-lg', statusBadgeClass]"
data-test="server-status-badge"
>
{{ server.connected ? 'Connected' : server.connecting ? 'Connecting' : 'Disconnected' }}
{{ statusBadgeText }}
</div>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-outline">
Expand Down Expand Up @@ -173,11 +169,24 @@

<!-- Alerts -->
<div class="space-y-4">
<!-- MCP-1821 — calm OAuth Sign-in CTA. Takes precedence over the red
ErrorPanel whenever the server simply needs the user to sign in
(health.action==='login' or an MCPX_OAUTH_* code). -->
<SignInPanel
v-if="signInState"
:server-name="server.name"
:state="signInState"
:docs-url="server.diagnostic?.docs_url"
:quarantined="server.quarantined"
:loading="actionLoading"
@login="triggerOAuth"
/>

<!-- Spec 044 — structured diagnostic panel (shown when a diagnostic
with warn/error severity is attached). Replaces the generic
last_error alert for those cases. -->
<ErrorPanel
v-if="showDiagnosticPanel"
v-else-if="showDiagnosticPanel"
:diagnostic="server.diagnostic"
:server-name="server.name"
@fixed="handleDiagnosticFixed"
Expand Down Expand Up @@ -1190,12 +1199,14 @@ import { useSystemStore } from '@/stores/system'
import CollapsibleHintsPanel from '@/components/CollapsibleHintsPanel.vue'
import AnnotationBadges from '@/components/AnnotationBadges.vue'
import ErrorPanel from '@/components/diagnostics/ErrorPanel.vue'
import SignInPanel from '@/components/diagnostics/SignInPanel.vue'
import KVValueCell from '@/components/KVValueCell.vue'
import type { Hint } from '@/components/CollapsibleHintsPanel.vue'
import type { Server, Tool, ToolApproval, SecurityScanReport } from '@/types'
import api from '@/services/api'
import { useSecurityScannerStatus } from '@/composables/useSecurityScannerStatus'
import { serverDisplayName } from '@/utils/serverRoute'
import { oauthSignInState } from '@/utils/health'

interface Props {
// MCP-1112: vue-router decodes the percent-encoded ':serverName' param, so
Expand Down Expand Up @@ -1329,6 +1340,26 @@ const healthAction = computed(() => {
return server.value?.health?.action || ''
})

// MCP-1821 — OAuth sign-in state (null when no sign-in is required). Drives the
// calm SignInPanel and the amber "Sign-in required" status badge.
const signInState = computed(() => {
return server.value ? oauthSignInState(server.value) : null
})

const statusBadgeClass = computed(() => {
if (signInState.value) return 'badge-warning'
if (server.value?.connected) return 'badge-success'
if (server.value?.connecting) return 'badge-warning'
return 'badge-error'
})

const statusBadgeText = computed(() => {
if (signInState.value) return 'Sign-in required'
if (server.value?.connected) return 'Connected'
if (server.value?.connecting) return 'Connecting'
return 'Disconnected'
})

// Spec 044 — render the structured diagnostic panel whenever a warn/error
// diagnostic is attached. Info-level diagnostics are ignored (shown only in
// verbose/admin views, per spec).
Expand Down
36 changes: 36 additions & 0 deletions frontend/tests/unit/error-panel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,40 @@ describe('ErrorPanel (spec 044)', () => {
})
expect(wrapper.find('.alert').exists()).toBe(false)
})

// MCP-1821 — the "Report a bug" / issues/new link is appropriate only for a
// genuinely unclassified fault. For OAuth codes the actionable path is to
// sign in, so the bug-report CTA must never render.
it('suppresses the issues/new "file a bug" link for OAUTH_* codes', () => {
const oauthDiag = makeDiag({
code: 'MCPX_OAUTH_LOGIN_REQUIRED',
severity: 'warn',
fix_steps: [
{ type: 'link', label: 'Report a bug', url: 'https://github.com/smart-mcp-proxy/mcpproxy-go/issues/new' },
{ type: 'link', label: 'Docs', url: 'https://docs.mcpproxy.app/oauth' },
],
})
const wrapper = mount(ErrorPanel, {
props: { diagnostic: oauthDiag, serverName: 's' },
global: { plugins: [pinia] },
})
expect(wrapper.html()).not.toContain('issues/new')
// Non-bug-report fix steps still render.
expect(wrapper.html()).toContain('docs.mcpproxy.app/oauth')
})

it('still renders the issues/new link for a genuinely unclassified fault', () => {
const unknownDiag = makeDiag({
code: 'MCPX_UNKNOWN_UNCLASSIFIED',
severity: 'error',
fix_steps: [
{ type: 'link', label: 'Report a bug', url: 'https://github.com/smart-mcp-proxy/mcpproxy-go/issues/new' },
],
})
const wrapper = mount(ErrorPanel, {
props: { diagnostic: unknownDiag, serverName: 's' },
global: { plugins: [pinia] },
})
expect(wrapper.html()).toContain('issues/new')
})
})
Loading
Loading