mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
Merge pull request #5971 from Hmbown/fix/mcp-footer-need-login-5926
feat(tui): /mcp leads with need-login servers and a failed refresh keeps the endpoint's receipt
This commit is contained in:
21 files changed
+692
-53
No files matched your search
+16
-1
@@ -30,7 +30,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
answerable work and no longer inflates the `blocked` chip; the receipts
|
||||
roster and the wire `state` gain `parked` (#5906, #5921).
|
||||
|
||||
|
||||
- `codewhale account keys set|remove|list` no longer carry a hardcoded
|
||||
eight-provider list. Provider ids come from the control plane's public
|
||||
catalog (`GET /api/model-providers`), are validated locally against
|
||||
@@ -39,6 +38,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
maps a catalog row onto the local runtime provider through the catalog's own
|
||||
`runtimeProvider` field, so a newly supported provider needs no CLI release.
|
||||
|
||||
- `/mcp` lists the servers that need a login first, as their own
|
||||
`Needs login` group above `Needs attention`, and opens with the cursor
|
||||
already on the first such row so the Enter the screen advertises runs
|
||||
`/mcp login <server>` straight away; translated in all 15 packs. A
|
||||
snapshot test pins the footer shape the chip landed with (`MCP · N
|
||||
connected · N ◆ auth required · N failed`) so an expired login never
|
||||
regresses into the failed count (#5926).
|
||||
|
||||
### Fixed
|
||||
|
||||
- The posture bar states how long the session has been working and how long
|
||||
@@ -46,6 +53,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
tool, a sub-agent or the operator; the 0.9.12 shell had dropped the overall
|
||||
working-time indicator from the place a glancing user checks (#5914).
|
||||
|
||||
- An MCP token refresh that fails to parse the provider's answer keeps the
|
||||
endpoint's receipt — status line, content type, and a 200-byte excerpt
|
||||
with every credential-shaped value (`access_token`, `refresh_token`,
|
||||
`client_secret`, `id_token`, bearer schemes) masked before the cut —
|
||||
instead of rmcp's bare `Failed to parse server response`, so a provider
|
||||
outage answering an HTML 502 reads differently from a parser defect, and
|
||||
the login remedy stays named (#5926; remedy wording landed in #5959).
|
||||
|
||||
### Added
|
||||
|
||||
- The `rusty-alloc` cargo feature on `codewhale-tui` and `codewhale-cli`
|
||||
|
||||
+16
-1
@@ -30,7 +30,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
answerable work and no longer inflates the `blocked` chip; the receipts
|
||||
roster and the wire `state` gain `parked` (#5906, #5921).
|
||||
|
||||
|
||||
- `codewhale account keys set|remove|list` no longer carry a hardcoded
|
||||
eight-provider list. Provider ids come from the control plane's public
|
||||
catalog (`GET /api/model-providers`), are validated locally against
|
||||
@@ -39,6 +38,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
maps a catalog row onto the local runtime provider through the catalog's own
|
||||
`runtimeProvider` field, so a newly supported provider needs no CLI release.
|
||||
|
||||
- `/mcp` lists the servers that need a login first, as their own
|
||||
`Needs login` group above `Needs attention`, and opens with the cursor
|
||||
already on the first such row so the Enter the screen advertises runs
|
||||
`/mcp login <server>` straight away; translated in all 15 packs. A
|
||||
snapshot test pins the footer shape the chip landed with (`MCP · N
|
||||
connected · N ◆ auth required · N failed`) so an expired login never
|
||||
regresses into the failed count (#5926).
|
||||
|
||||
### Fixed
|
||||
|
||||
- The posture bar states how long the session has been working and how long
|
||||
@@ -46,6 +53,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
tool, a sub-agent or the operator; the 0.9.12 shell had dropped the overall
|
||||
working-time indicator from the place a glancing user checks (#5914).
|
||||
|
||||
- An MCP token refresh that fails to parse the provider's answer keeps the
|
||||
endpoint's receipt — status line, content type, and a 200-byte excerpt
|
||||
with every credential-shaped value (`access_token`, `refresh_token`,
|
||||
`client_secret`, `id_token`, bearer schemes) masked before the cut —
|
||||
instead of rmcp's bare `Failed to parse server response`, so a provider
|
||||
outage answering an HTML 502 reads differently from a parser defect, and
|
||||
the login remedy stays named (#5926; remedy wording landed in #5959).
|
||||
|
||||
### Added
|
||||
|
||||
- The `rusty-alloc` cargo feature on `codewhale-tui` and `codewhale-cli`
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"ExtensionsGroupRecommended": "Recomanat",
|
||||
"ExtensionsGroupServers": "Servidors",
|
||||
"ExtensionsGroupNeedsAttention": "Necessita atenció",
|
||||
"ExtensionsGroupNeedsLogin": "Cal iniciar sessió",
|
||||
"ExtensionsGroupStatus": "{count} elements · Enter plega",
|
||||
"ExtensionsGroupUser": "Usuari",
|
||||
"ExtensionsGroupWorkspace": "Espai de treball",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"ExtensionsGroupRecommended": "Empfohlen",
|
||||
"ExtensionsGroupServers": "Server",
|
||||
"ExtensionsGroupNeedsAttention": "Braucht Aufmerksamkeit",
|
||||
"ExtensionsGroupNeedsLogin": "Anmeldung nötig",
|
||||
"ExtensionsGroupStatus": "{count} Einträge · Enter klappt ein",
|
||||
"ExtensionsGroupUser": "Benutzer",
|
||||
"ExtensionsGroupWorkspace": "Arbeitsbereich",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"ExtensionsGroupRecommended": "Recommended",
|
||||
"ExtensionsGroupServers": "Servers",
|
||||
"ExtensionsGroupNeedsAttention": "Needs attention",
|
||||
"ExtensionsGroupNeedsLogin": "Needs login",
|
||||
"ExtensionsGroupStatus": "{count} items · Enter folds",
|
||||
"ExtensionsGroupUser": "User",
|
||||
"ExtensionsGroupWorkspace": "Workspace",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"ExtensionsGroupRecommended": "Recomendado",
|
||||
"ExtensionsGroupServers": "Servidores",
|
||||
"ExtensionsGroupNeedsAttention": "Necesita atención",
|
||||
"ExtensionsGroupNeedsLogin": "Requiere inicio de sesión",
|
||||
"ExtensionsGroupStatus": "{count} elementos · Enter pliega",
|
||||
"ExtensionsGroupUser": "Usuario",
|
||||
"ExtensionsGroupWorkspace": "Espacio de trabajo",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"ExtensionsGroupRecommended": "Recommandés",
|
||||
"ExtensionsGroupServers": "Serveurs",
|
||||
"ExtensionsGroupNeedsAttention": "Nécessite votre attention",
|
||||
"ExtensionsGroupNeedsLogin": "Connexion requise",
|
||||
"ExtensionsGroupStatus": "{count} éléments · Entrée replie",
|
||||
"ExtensionsGroupUser": "Utilisateur",
|
||||
"ExtensionsGroupWorkspace": "Espace de travail",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"ExtensionsGroupRecommended": "अनुशंसित",
|
||||
"ExtensionsGroupServers": "सर्वर",
|
||||
"ExtensionsGroupNeedsAttention": "ध्यान चाहिए",
|
||||
"ExtensionsGroupNeedsLogin": "लॉगिन आवश्यक",
|
||||
"ExtensionsGroupStatus": "{count} आइटम · समेटने के लिए Enter",
|
||||
"ExtensionsGroupUser": "उपयोगकर्ता",
|
||||
"ExtensionsGroupWorkspace": "कार्यस्थान",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"ExtensionsGroupRecommended": "Disarankan",
|
||||
"ExtensionsGroupServers": "Server",
|
||||
"ExtensionsGroupNeedsAttention": "Perlu perhatian",
|
||||
"ExtensionsGroupNeedsLogin": "Perlu masuk",
|
||||
"ExtensionsGroupStatus": "{count} item · Enter untuk melipat",
|
||||
"ExtensionsGroupUser": "Pengguna",
|
||||
"ExtensionsGroupWorkspace": "Ruang kerja",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"ExtensionsGroupRecommended": "おすすめ",
|
||||
"ExtensionsGroupServers": "サーバー",
|
||||
"ExtensionsGroupNeedsAttention": "要対応",
|
||||
"ExtensionsGroupNeedsLogin": "ログインが必要",
|
||||
"ExtensionsGroupStatus": "{count} 件 · Enter で折りたたみ",
|
||||
"ExtensionsGroupUser": "ユーザー",
|
||||
"ExtensionsGroupWorkspace": "ワークスペース",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"ExtensionsGroupRecommended": "추천",
|
||||
"ExtensionsGroupServers": "서버",
|
||||
"ExtensionsGroupNeedsAttention": "조치 필요",
|
||||
"ExtensionsGroupNeedsLogin": "로그인 필요",
|
||||
"ExtensionsGroupStatus": "{count}개 항목 · Enter로 접기",
|
||||
"ExtensionsGroupUser": "사용자",
|
||||
"ExtensionsGroupWorkspace": "작업 공간",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"ExtensionsGroupRecommended": "Recomendados",
|
||||
"ExtensionsGroupServers": "Servidores",
|
||||
"ExtensionsGroupNeedsAttention": "Precisa de atenção",
|
||||
"ExtensionsGroupNeedsLogin": "Precisa de login",
|
||||
"ExtensionsGroupStatus": "{count} itens · Enter recolhe",
|
||||
"ExtensionsGroupUser": "Usuário",
|
||||
"ExtensionsGroupWorkspace": "Área de trabalho",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"ExtensionsGroupRecommended": "Рекомендуемые",
|
||||
"ExtensionsGroupServers": "Серверы",
|
||||
"ExtensionsGroupNeedsAttention": "Требует внимания",
|
||||
"ExtensionsGroupNeedsLogin": "Требуется вход",
|
||||
"ExtensionsGroupStatus": "Элементов: {count} · Enter сворачивает",
|
||||
"ExtensionsGroupUser": "Пользователь",
|
||||
"ExtensionsGroupWorkspace": "Рабочая область",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"ExtensionsGroupRecommended": "Рекомендовані",
|
||||
"ExtensionsGroupServers": "Сервери",
|
||||
"ExtensionsGroupNeedsAttention": "Потребує уваги",
|
||||
"ExtensionsGroupNeedsLogin": "Потрібен вхід",
|
||||
"ExtensionsGroupStatus": "Елементів: {count} · Enter згортає",
|
||||
"ExtensionsGroupUser": "Користувач",
|
||||
"ExtensionsGroupWorkspace": "Робочий простір",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"ExtensionsGroupRecommended": "Đề xuất",
|
||||
"ExtensionsGroupServers": "Máy chủ",
|
||||
"ExtensionsGroupNeedsAttention": "Cần chú ý",
|
||||
"ExtensionsGroupNeedsLogin": "Cần đăng nhập",
|
||||
"ExtensionsGroupStatus": "{count} mục · Enter để thu gọn",
|
||||
"ExtensionsGroupUser": "Người dùng",
|
||||
"ExtensionsGroupWorkspace": "Không gian làm việc",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"ExtensionsGroupRecommended": "推荐",
|
||||
"ExtensionsGroupServers": "服务器",
|
||||
"ExtensionsGroupNeedsAttention": "需要处理",
|
||||
"ExtensionsGroupNeedsLogin": "需要登录",
|
||||
"ExtensionsGroupStatus": "{count} 项 · 按 Enter 折叠",
|
||||
"ExtensionsGroupUser": "用户",
|
||||
"ExtensionsGroupWorkspace": "工作区",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"ExtensionsGroupRecommended": "推薦",
|
||||
"ExtensionsGroupServers": "伺服器",
|
||||
"ExtensionsGroupNeedsAttention": "需要處理",
|
||||
"ExtensionsGroupNeedsLogin": "需要登入",
|
||||
"ExtensionsGroupStatus": "{count} 項 · 按 Enter 摺疊",
|
||||
"ExtensionsGroupUser": "使用者",
|
||||
"ExtensionsGroupWorkspace": "工作區",
|
||||
|
||||
@@ -483,6 +483,7 @@ pub enum MessageId {
|
||||
ExtensionsGroupRecommended,
|
||||
ExtensionsGroupServers,
|
||||
ExtensionsGroupNeedsAttention,
|
||||
ExtensionsGroupNeedsLogin,
|
||||
ExtensionsGroupStatus,
|
||||
ExtensionsGroupUser,
|
||||
ExtensionsGroupWorkspace,
|
||||
@@ -2665,6 +2666,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::ExtensionsGroupRecommended,
|
||||
MessageId::ExtensionsGroupServers,
|
||||
MessageId::ExtensionsGroupNeedsAttention,
|
||||
MessageId::ExtensionsGroupNeedsLogin,
|
||||
MessageId::ExtensionsGroupStatus,
|
||||
MessageId::ExtensionsGroupUser,
|
||||
MessageId::ExtensionsGroupWorkspace,
|
||||
@@ -5835,7 +5837,7 @@ mod tests {
|
||||
.filter(|key| key.starts_with("Extensions"))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(keys.len(), 93, "the complete extensions locale set changed");
|
||||
assert_eq!(keys.len(), 94, "the complete extensions locale set changed");
|
||||
|
||||
let prose_keys = [
|
||||
"ExtensionsMarketplaceUnavailable",
|
||||
|
||||
+391
-25
@@ -7,11 +7,12 @@ use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use oauth2::TokenResponse;
|
||||
use reqwest::Url;
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
|
||||
use rmcp::transport::AuthorizationManager;
|
||||
use rmcp::transport::AuthorizationSession;
|
||||
use rmcp::transport::auth::{
|
||||
AuthError, AuthorizationRequest, OAuthClientConfig, OAuthState, OAuthTokenResponse,
|
||||
AuthError, AuthorizationRequest, OAuthClientConfig, OAuthHttpClient, OAuthHttpClientError,
|
||||
OAuthHttpClientFuture, OAuthHttpRequest, OAuthState, OAuthTokenResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -52,10 +53,20 @@ impl std::fmt::Display for McpAuthStatus {
|
||||
/// other failure (a token endpoint answering something the client could
|
||||
/// not parse, a transport error) names the same remedy in words, because
|
||||
/// the operator otherwise sees only the provider's parse error (#5926).
|
||||
fn refresh_failure_context(server_name: &str, names_remedy: bool) -> String {
|
||||
/// When the token endpoint did answer, its receipt (status line,
|
||||
/// content-type, masked excerpt) rides along so a provider outage — an HTML
|
||||
/// 502 page — reads differently from a parser defect on JSON it should
|
||||
/// have accepted.
|
||||
fn refresh_failure_context(
|
||||
server_name: &str,
|
||||
names_remedy: bool,
|
||||
receipt: Option<&TokenEndpointReceipt>,
|
||||
) -> String {
|
||||
if names_remedy {
|
||||
let answered =
|
||||
receipt.map_or_else(String::new, |receipt| format!(" (it answered {receipt})"));
|
||||
format!(
|
||||
"refreshing MCP OAuth token for server {server_name}: the token endpoint did not answer the way the client expects; \
|
||||
"refreshing MCP OAuth token for server {server_name}: the token endpoint did not answer the way the client expects{answered}; \
|
||||
if this persists, run `codewhale mcp login {server_name}` (or `/mcp login {server_name}`) to re-authorize"
|
||||
)
|
||||
} else {
|
||||
@@ -63,6 +74,273 @@ fn refresh_failure_context(server_name: &str, names_remedy: bool) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest excerpt of a token-endpoint body a refresh failure keeps.
|
||||
const TOKEN_RECEIPT_EXCERPT_BYTES: usize = 200;
|
||||
|
||||
/// rmcp's own cap on an OAuth response body, mirrored so the recording
|
||||
/// client refuses the same oversized answers the stock one does.
|
||||
const MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// Response fields whose values are credentials. Their values are masked
|
||||
/// before any body excerpt is kept; the field names themselves are not
|
||||
/// secrets and stay so the operator can see which fields the answer had.
|
||||
const OAUTH_SECRET_FIELDS: &[&str] = &[
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"client_secret",
|
||||
"id_token",
|
||||
"authorization",
|
||||
];
|
||||
|
||||
/// What the token endpoint actually answered. rmcp collapses an
|
||||
/// unparseable answer to `Failed to parse server response` and drops the
|
||||
/// body; this is the receipt it drops, with every credential-shaped value
|
||||
/// masked and the body cut to its first [`TOKEN_RECEIPT_EXCERPT_BYTES`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct TokenEndpointReceipt {
|
||||
status: u16,
|
||||
reason: Option<&'static str>,
|
||||
content_type: Option<String>,
|
||||
excerpt: String,
|
||||
}
|
||||
|
||||
impl TokenEndpointReceipt {
|
||||
fn from_response(status: reqwest::StatusCode, headers: &HeaderMap, body: &[u8]) -> Self {
|
||||
let content_type = headers
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
Self {
|
||||
status: status.as_u16(),
|
||||
reason: status.canonical_reason(),
|
||||
content_type,
|
||||
excerpt: token_response_excerpt(body),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TokenEndpointReceipt {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "HTTP {}", self.status)?;
|
||||
if let Some(reason) = self.reason {
|
||||
write!(f, " {reason}")?;
|
||||
}
|
||||
match self.content_type.as_deref() {
|
||||
Some(content_type) => write!(f, " ({content_type})")?,
|
||||
None => f.write_str(" (no content-type)")?,
|
||||
}
|
||||
if self.excerpt.is_empty() {
|
||||
f.write_str(" with an empty body")
|
||||
} else {
|
||||
write!(f, ": {}", self.excerpt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Masked, whitespace-collapsed, byte-capped excerpt of a token-endpoint
|
||||
/// body. Masking runs before the cut so a truncated credential never leaks
|
||||
/// its prefix.
|
||||
fn token_response_excerpt(body: &[u8]) -> String {
|
||||
let masked = mask_oauth_secrets(&String::from_utf8_lossy(body));
|
||||
let collapsed = masked.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if collapsed.len() <= TOKEN_RECEIPT_EXCERPT_BYTES {
|
||||
return collapsed;
|
||||
}
|
||||
let mut end = TOKEN_RECEIPT_EXCERPT_BYTES;
|
||||
while !collapsed.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}…", &collapsed[..end])
|
||||
}
|
||||
|
||||
fn is_word_byte(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || byte == b'_'
|
||||
}
|
||||
|
||||
/// Replace every credential-shaped value in `text` with `***`: JSON
|
||||
/// members (`"access_token": "…"`), form/query pairs (`refresh_token=…`),
|
||||
/// and bearer schemes (`Bearer …`). Field names, separators and everything
|
||||
/// else survive so the shape of the answer stays readable.
|
||||
pub(crate) fn mask_oauth_secrets(text: &str) -> String {
|
||||
let lower = text.to_ascii_lowercase();
|
||||
let bytes = text.as_bytes();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut index = 0;
|
||||
while index < text.len() {
|
||||
let at_word_start = index == 0 || !is_word_byte(bytes[index - 1]);
|
||||
if at_word_start
|
||||
&& let Some((value_start, value_end)) = secret_value_span(text, &lower, index)
|
||||
{
|
||||
out.push_str(&text[index..value_start]);
|
||||
out.push_str("***");
|
||||
index = value_end;
|
||||
continue;
|
||||
}
|
||||
let ch = text[index..]
|
||||
.chars()
|
||||
.next()
|
||||
.expect("index sits on a char boundary");
|
||||
out.push(ch);
|
||||
index += ch.len_utf8();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The byte span of the secret value that starts at `start`, if a secret
|
||||
/// field or bearer scheme begins there. Every scan step consumes ASCII
|
||||
/// bytes only, so both ends land on char boundaries.
|
||||
fn secret_value_span(text: &str, lower: &str, start: usize) -> Option<(usize, usize)> {
|
||||
let bytes = text.as_bytes();
|
||||
let skip_spaces = |mut cursor: usize| {
|
||||
while bytes
|
||||
.get(cursor)
|
||||
.is_some_and(|byte| *byte == b' ' || *byte == b'\t')
|
||||
{
|
||||
cursor += 1;
|
||||
}
|
||||
cursor
|
||||
};
|
||||
let unquoted_end = |mut cursor: usize| {
|
||||
while bytes.get(cursor).is_some_and(|byte| {
|
||||
!matches!(byte, b'&' | b',' | b';' | b'}' | b'"' | b'\'') && !byte.is_ascii_whitespace()
|
||||
}) {
|
||||
cursor += 1;
|
||||
}
|
||||
cursor
|
||||
};
|
||||
if lower[start..].starts_with("bearer ") {
|
||||
let value_start = skip_spaces(start + "bearer".len());
|
||||
let value_end = unquoted_end(value_start);
|
||||
return (value_end > value_start).then_some((value_start, value_end));
|
||||
}
|
||||
for field in OAUTH_SECRET_FIELDS {
|
||||
if !lower[start..].starts_with(field) {
|
||||
continue;
|
||||
}
|
||||
let mut cursor = start + field.len();
|
||||
if bytes.get(cursor).is_some_and(|byte| is_word_byte(*byte)) {
|
||||
continue;
|
||||
}
|
||||
if bytes.get(cursor) == Some(&b'"') {
|
||||
cursor += 1;
|
||||
}
|
||||
cursor = skip_spaces(cursor);
|
||||
match bytes.get(cursor) {
|
||||
Some(b':' | b'=') => cursor += 1,
|
||||
_ => continue,
|
||||
}
|
||||
cursor = skip_spaces(cursor);
|
||||
if bytes.get(cursor) == Some(&b'"') {
|
||||
let value_start = cursor + 1;
|
||||
let mut value_end = value_start;
|
||||
while let Some(byte) = bytes.get(value_end) {
|
||||
match byte {
|
||||
b'\\' => value_end += 2,
|
||||
b'"' => break,
|
||||
_ => value_end += 1,
|
||||
}
|
||||
}
|
||||
return Some((value_start, value_end.min(text.len())));
|
||||
}
|
||||
// An unquoted `Authorization: Bearer <token>` carries its scheme in
|
||||
// front of the credential; the whole value is the secret.
|
||||
let value_start = cursor;
|
||||
let mut value_end = unquoted_end(cursor);
|
||||
if matches!(lower[value_start..value_end].as_ref(), "bearer" | "basic")
|
||||
&& bytes.get(value_end) == Some(&b' ')
|
||||
{
|
||||
value_end = unquoted_end(skip_spaces(value_end));
|
||||
}
|
||||
return Some((value_start, value_end));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The OAuth HTTP client behind a stored-credential runtime. It executes
|
||||
/// exactly what rmcp's stock reqwest client would — one caller-configured
|
||||
/// client for every operation, redirects followed, body capped — and keeps
|
||||
/// the receipt of the latest token-endpoint answer (every token request is
|
||||
/// a POST; discovery is GET) so a failed refresh can say what came back.
|
||||
pub(crate) struct RecordingOAuthHttpClient {
|
||||
client: reqwest::Client,
|
||||
last_token_response: std::sync::Mutex<Option<TokenEndpointReceipt>>,
|
||||
}
|
||||
|
||||
impl RecordingOAuthHttpClient {
|
||||
fn new(default_headers: &HeaderMap) -> Result<Self> {
|
||||
let client = apply_default_headers(crate::tls::reqwest_client_builder(), default_headers)
|
||||
.build()
|
||||
.context("building MCP OAuth metadata client")?;
|
||||
Ok(Self {
|
||||
client,
|
||||
last_token_response: std::sync::Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
fn take_token_endpoint_receipt(&self) -> Option<TokenEndpointReceipt> {
|
||||
self.last_token_response
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take()
|
||||
}
|
||||
}
|
||||
|
||||
impl OAuthHttpClient for RecordingOAuthHttpClient {
|
||||
fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> {
|
||||
Box::pin(async move {
|
||||
let OAuthHttpRequest {
|
||||
request, timeout, ..
|
||||
} = request;
|
||||
let is_token_request = request.method() == reqwest::Method::POST;
|
||||
let mut request = reqwest::Request::try_from(request)
|
||||
.map_err(|error| Box::new(error) as OAuthHttpClientError)?;
|
||||
if let Some(timeout) = timeout {
|
||||
*request.timeout_mut() = Some(timeout);
|
||||
}
|
||||
let mut response = self
|
||||
.client
|
||||
.execute(request)
|
||||
.await
|
||||
.map_err(|error| Box::new(error) as OAuthHttpClientError)?;
|
||||
let status = response.status();
|
||||
let version = response.version();
|
||||
let headers = response.headers().clone();
|
||||
let mut body = Vec::new();
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|error| Box::new(error) as OAuthHttpClientError)?
|
||||
{
|
||||
if chunk.len() > MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES - body.len() {
|
||||
return Err(anyhow!(
|
||||
"OAuth HTTP response body exceeds {MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES} bytes"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
if is_token_request {
|
||||
*self
|
||||
.last_token_response
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) =
|
||||
Some(TokenEndpointReceipt::from_response(status, &headers, &body));
|
||||
}
|
||||
let mut builder = oauth2::http::Response::builder()
|
||||
.status(status)
|
||||
.version(version);
|
||||
for (name, value) in &headers {
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
builder
|
||||
.body(body)
|
||||
.map_err(|error| Box::new(error) as OAuthHttpClientError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_looks_auth_required(error: &anyhow::Error) -> bool {
|
||||
error_text_looks_auth_required(&format!("{error:#}"))
|
||||
}
|
||||
@@ -206,9 +484,10 @@ struct McpOAuthRuntimeInner {
|
||||
/// though the rejected grant is never replayed. `None` while a
|
||||
/// credential is held.
|
||||
rejection: Mutex<Option<String>>,
|
||||
/// Default headers the runtime was built with, retained so an adopted
|
||||
/// on-disk rotation can rebuild the manager with identical HTTP shape.
|
||||
default_headers: HeaderMap,
|
||||
/// The HTTP client the runtime was built with, shared with the manager
|
||||
/// so an adopted on-disk rotation rebuilds it with identical HTTP shape
|
||||
/// and a failed refresh can read the token endpoint's receipt.
|
||||
http_client: Arc<RecordingOAuthHttpClient>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -266,12 +545,10 @@ impl std::error::Error for OAuthProviderError {}
|
||||
async fn manager_from_stored_tokens(
|
||||
url: &str,
|
||||
tokens: &StoredMcpOAuthTokens,
|
||||
default_headers: &HeaderMap,
|
||||
http_client: &Arc<RecordingOAuthHttpClient>,
|
||||
) -> Result<AuthorizationManager> {
|
||||
let client = apply_default_headers(crate::tls::reqwest_client_builder(), default_headers)
|
||||
.build()
|
||||
.context("building MCP OAuth metadata client")?;
|
||||
let mut state = OAuthState::new(url.to_string(), Some(client)).await?;
|
||||
let client = Arc::clone(http_client) as Arc<dyn OAuthHttpClient>;
|
||||
let mut state = OAuthState::new_with_oauth_http_client(url.to_string(), client).await?;
|
||||
state
|
||||
.set_credentials(&tokens.client_id, tokens.token_response.0.clone())
|
||||
.await
|
||||
@@ -313,7 +590,8 @@ impl McpOAuthRuntime {
|
||||
default_headers: HeaderMap,
|
||||
) -> Result<Self> {
|
||||
refresh_expires_in_from_timestamp(&mut tokens);
|
||||
let manager = manager_from_stored_tokens(url, &tokens, &default_headers).await?;
|
||||
let http_client = Arc::new(RecordingOAuthHttpClient::new(&default_headers)?);
|
||||
let manager = manager_from_stored_tokens(url, &tokens, &http_client).await?;
|
||||
|
||||
Ok(Self {
|
||||
inner: Arc::new(McpOAuthRuntimeInner {
|
||||
@@ -322,7 +600,7 @@ impl McpOAuthRuntime {
|
||||
manager: Arc::new(Mutex::new(manager)),
|
||||
last_tokens: Mutex::new(Some(tokens)),
|
||||
rejection: Mutex::new(None),
|
||||
default_headers,
|
||||
http_client,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -410,6 +688,8 @@ impl McpOAuthRuntime {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Only this refresh's answer may explain this refresh's failure.
|
||||
self.inner.http_client.take_token_endpoint_receipt();
|
||||
let mut err = match self.try_refresh_and_persist().await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(err) => err,
|
||||
@@ -448,7 +728,9 @@ impl McpOAuthRuntime {
|
||||
}
|
||||
let server_name = self.inner.server_name.clone();
|
||||
let names_remedy = !error_looks_auth_required(&err);
|
||||
Err(err).with_context(|| refresh_failure_context(&server_name, names_remedy))
|
||||
let receipt = self.inner.http_client.take_token_endpoint_receipt();
|
||||
Err(err)
|
||||
.with_context(|| refresh_failure_context(&server_name, names_remedy, receipt.as_ref()))
|
||||
}
|
||||
|
||||
async fn try_refresh_and_persist(&self) -> Result<()> {
|
||||
@@ -477,8 +759,7 @@ impl McpOAuthRuntime {
|
||||
return Ok(false);
|
||||
}
|
||||
let manager =
|
||||
manager_from_stored_tokens(&self.inner.url, &stored, &self.inner.default_headers)
|
||||
.await?;
|
||||
manager_from_stored_tokens(&self.inner.url, &stored, &self.inner.http_client).await?;
|
||||
*self.inner.manager.lock().await = manager;
|
||||
*self.inner.last_tokens.lock().await = Some(stored);
|
||||
*self.inner.rejection.lock().await = None;
|
||||
@@ -506,12 +787,9 @@ impl McpOAuthRuntime {
|
||||
server = %self.inner.server_name,
|
||||
"MCP OAuth credential was rotated by another process; keeping the on-disk winner"
|
||||
);
|
||||
let manager = manager_from_stored_tokens(
|
||||
&self.inner.url,
|
||||
&stored,
|
||||
&self.inner.default_headers,
|
||||
)
|
||||
.await?;
|
||||
let manager =
|
||||
manager_from_stored_tokens(&self.inner.url, &stored, &self.inner.http_client)
|
||||
.await?;
|
||||
*self.inner.manager.lock().await = manager;
|
||||
*self.inner.last_tokens.lock().await = Some(stored);
|
||||
*self.inner.rejection.lock().await = None;
|
||||
@@ -1630,16 +1908,104 @@ impl McpServerConfig {
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_refresh_parse_failure_names_the_login_remedy_and_the_server() {
|
||||
let text = super::refresh_failure_context("supabase", true);
|
||||
let text = super::refresh_failure_context("supabase", true, None);
|
||||
assert!(text.contains("server supabase"));
|
||||
assert!(text.contains("codewhale mcp login supabase"), "{text}");
|
||||
assert!(text.contains("/mcp login supabase"), "{text}");
|
||||
assert!(!text.contains("it answered"), "{text}");
|
||||
// An auth-required failure keeps the plain context: the typed state
|
||||
// and the login tool already carry the remedy.
|
||||
let plain = super::refresh_failure_context("supabase", false);
|
||||
let plain = super::refresh_failure_context("supabase", false, None);
|
||||
assert_eq!(plain, "refreshing MCP OAuth token for server supabase");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refresh_parse_failure_keeps_the_token_endpoints_receipt() {
|
||||
// The supabase receipt (#5926): rmcp said only "Failed to parse
|
||||
// server response". With the status line and a masked excerpt the
|
||||
// operator can tell a provider's HTML 502 from our parser.
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
let receipt = TokenEndpointReceipt::from_response(
|
||||
reqwest::StatusCode::BAD_GATEWAY,
|
||||
&headers,
|
||||
b"<html>\n <body>502 Bad Gateway</body>\n</html>",
|
||||
);
|
||||
let text = super::refresh_failure_context("supabase", true, Some(&receipt));
|
||||
assert!(
|
||||
text.contains(
|
||||
"it answered HTTP 502 Bad Gateway (text/html; charset=utf-8): <html> <body>502 Bad Gateway</body> </html>"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("codewhale mcp login supabase"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_receipt_masks_credentials_before_it_cuts_the_body() {
|
||||
let secret = "sk-live-0123456789abcdef";
|
||||
let long_tail = "x".repeat(400);
|
||||
let body = format!(
|
||||
"{{\"token_type\":\"Bearer\",\"access_token\":\"{secret}\",\"refresh_token\": \"{secret}-r\",\"id_token\":\"{secret}-id\",\"expires_in\":\"soon\",\"note\":\"{long_tail}\"}}"
|
||||
);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
let receipt =
|
||||
TokenEndpointReceipt::from_response(reqwest::StatusCode::OK, &headers, body.as_bytes());
|
||||
let text = receipt.to_string();
|
||||
assert!(!text.contains(secret), "{text}");
|
||||
assert!(text.contains("\"access_token\":\"***\""), "{text}");
|
||||
assert!(text.contains("\"refresh_token\": \"***\""), "{text}");
|
||||
assert!(text.contains("\"id_token\":\"***\""), "{text}");
|
||||
// Non-secret members survive so the shape of the answer is readable.
|
||||
assert!(text.contains("\"expires_in\":\"soon\""), "{text}");
|
||||
assert!(text.contains("\"token_type\":\"Bearer\""), "{text}");
|
||||
assert!(text.ends_with('…'), "{text}");
|
||||
assert!(receipt.excerpt.len() <= TOKEN_RECEIPT_EXCERPT_BYTES + '…'.len_utf8());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_secret_masking_covers_form_pairs_bearer_schemes_and_case() {
|
||||
assert_eq!(
|
||||
mask_oauth_secrets("client_secret=abc123&grant_type=refresh_token&refresh_token=zzz"),
|
||||
"client_secret=***&grant_type=refresh_token&refresh_token=***"
|
||||
);
|
||||
assert_eq!(
|
||||
mask_oauth_secrets("Authorization: Bearer eyJhbGciOi.payload.sig, retry"),
|
||||
"Authorization: ***, retry"
|
||||
);
|
||||
assert_eq!(
|
||||
mask_oauth_secrets("{\"Access_Token\": \"quoted \\\" inside\", \"scope\": \"read\"}"),
|
||||
"{\"Access_Token\": \"***\", \"scope\": \"read\"}"
|
||||
);
|
||||
// A field name that merely contains a secret name is not a secret.
|
||||
assert_eq!(
|
||||
mask_oauth_secrets("{\"error_code\":\"invalid_request\",\"my_access_token_count\":3}"),
|
||||
"{\"error_code\":\"invalid_request\",\"my_access_token_count\":3}"
|
||||
);
|
||||
// Multi-byte text around a secret stays intact.
|
||||
assert_eq!(
|
||||
mask_oauth_secrets("トークン access_token=秘密 終わり"),
|
||||
"トークン access_token=*** 終わり"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_receipt_names_an_empty_body_and_a_missing_content_type() {
|
||||
let receipt = TokenEndpointReceipt::from_response(
|
||||
reqwest::StatusCode::SERVICE_UNAVAILABLE,
|
||||
&HeaderMap::new(),
|
||||
b"",
|
||||
);
|
||||
assert_eq!(
|
||||
receipt.to_string(),
|
||||
"HTTP 503 Service Unavailable (no content-type) with an empty body"
|
||||
);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
|
||||
@@ -4449,6 +4449,74 @@ fn failed_mcp_is_a_footer_chip_not_multiline_chat_boot_output() {
|
||||
assert!(!rendered.contains("/mcp retry alpha"), "{rendered}");
|
||||
}
|
||||
|
||||
/// The founder's receipt (#5926): of the eight servers the footer called
|
||||
/// "failed", seven only needed a login. Rendered, the footer summary now
|
||||
/// keeps the three states apart — connected, ◆ auth required, failed — so
|
||||
/// an expired login never reads as a broken server.
|
||||
#[test]
|
||||
fn footer_summary_separates_connected_need_login_and_failed() {
|
||||
fn server(
|
||||
name: &str,
|
||||
connected: bool,
|
||||
auth_required: bool,
|
||||
error: Option<&str>,
|
||||
) -> crate::mcp::McpServerSnapshot {
|
||||
crate::mcp::McpServerSnapshot {
|
||||
name: name.to_string(),
|
||||
enabled: true,
|
||||
required: false,
|
||||
auth_required,
|
||||
transport: "streamable_http".to_string(),
|
||||
command_or_url: format!("https://{name}.example/mcp"),
|
||||
connect_timeout: 5,
|
||||
execute_timeout: 5,
|
||||
read_timeout: 5,
|
||||
connected,
|
||||
error: error.map(str::to_string),
|
||||
capability_metadata: crate::mcp::McpServerCapabilityMetadata::NotObserved,
|
||||
tools: Vec::new(),
|
||||
resources: Vec::new(),
|
||||
prompts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
let mut app = create_test_app();
|
||||
app.onboarding_workspace_trust_gate = false;
|
||||
app.onboarding = OnboardingState::None;
|
||||
app.launch.visible = false;
|
||||
app.mcp_snapshot = Some(crate::mcp::McpManagerSnapshot {
|
||||
config_path: PathBuf::from("mcp.json"),
|
||||
config_exists: true,
|
||||
reload_required: false,
|
||||
servers: vec![
|
||||
server("alpha", true, false, None),
|
||||
server(
|
||||
"slack",
|
||||
false,
|
||||
true,
|
||||
Some("401 Unauthorized: the session is no longer accepted"),
|
||||
),
|
||||
server(
|
||||
"supabase",
|
||||
false,
|
||||
false,
|
||||
Some("OAuth token refresh failed: Failed to parse server response"),
|
||||
),
|
||||
],
|
||||
});
|
||||
let rendered = render_underwater_test_app(&mut app, 100, 30);
|
||||
|
||||
let expected = format!(
|
||||
"MCP · 1 connected · 1 {} · 1 failed",
|
||||
crate::tui::session_boot::mcp_auth_required_state_label()
|
||||
);
|
||||
assert!(
|
||||
rendered.contains(&expected),
|
||||
"expected {expected:?} in:\n{rendered}"
|
||||
);
|
||||
assert!(!rendered.contains("2 failed"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_placement_draws_the_work_strip_under_the_composer() {
|
||||
// Round 3 (2026-09-01): the bar's information lives under the composer.
|
||||
|
||||
@@ -1202,31 +1202,8 @@ fn mcp_model(app: &App, locale: Locale) -> ExtensionsTabModel {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Everything that needs a human leads. With twenty servers configured, the
|
||||
// four that failed or want re-auth were impossible to pick out of a flat
|
||||
// alphabetical list — founder live-test on the same screen. A server is
|
||||
// "attention" exactly when it carries a recovery command; a healthy one
|
||||
// renders its state and sorts below.
|
||||
let (attention, healthy): (Vec<_>, Vec<_>) = items
|
||||
.into_iter()
|
||||
.partition(|item| item.action.as_ref().is_some_and(|a| a.command().is_some()));
|
||||
let mut groups = Vec::new();
|
||||
if !attention.is_empty() {
|
||||
groups.push(ExtensionGroup {
|
||||
id: "attention".into(),
|
||||
label: tr(locale, MessageId::ExtensionsGroupNeedsAttention).into_owned(),
|
||||
items: attention,
|
||||
});
|
||||
}
|
||||
if !healthy.is_empty() {
|
||||
groups.push(ExtensionGroup {
|
||||
id: "servers".into(),
|
||||
label: tr(locale, MessageId::ExtensionsGroupServers).into_owned(),
|
||||
items: healthy,
|
||||
});
|
||||
}
|
||||
ExtensionsTabModel {
|
||||
groups,
|
||||
groups: mcp_groups(locale, items),
|
||||
problem: (configured.is_none() && app.mcp_configured_count > total).then(|| {
|
||||
localize(
|
||||
locale,
|
||||
@@ -1237,6 +1214,54 @@ fn mcp_model(app: &App, locale: Locale) -> ExtensionsTabModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Group id of the `/mcp` rows whose one action is a login.
|
||||
const MCP_LOGIN_GROUP_ID: &str = "login";
|
||||
|
||||
/// Whether a row's one action is the login flow.
|
||||
fn mcp_item_needs_login(item: &ExtensionItem) -> bool {
|
||||
item.action
|
||||
.as_ref()
|
||||
.and_then(ExtensionAction::command)
|
||||
.is_some_and(|command| command.starts_with("/mcp login "))
|
||||
}
|
||||
|
||||
/// Order the `/mcp` rows by what a person has to do about them. Everything
|
||||
/// that needs a human leads: with twenty servers configured, the four that
|
||||
/// failed or want re-auth were impossible to pick out of a flat alphabetical
|
||||
/// list — founder live-test on the same screen. Within that, the servers
|
||||
/// that only need a login come first, as their own group, because "failed"
|
||||
/// is the wrong word for an expired login and the fix is one key (#5926):
|
||||
/// Enter on the row runs `/mcp login <server>`. Real failures follow with
|
||||
/// their reason in the detail line; a healthy server renders its state and
|
||||
/// sorts below.
|
||||
fn mcp_groups(locale: Locale, items: Vec<ExtensionItem>) -> Vec<ExtensionGroup> {
|
||||
let (login, rest): (Vec<_>, Vec<_>) = items.into_iter().partition(mcp_item_needs_login);
|
||||
let (attention, healthy): (Vec<_>, Vec<_>) = rest
|
||||
.into_iter()
|
||||
.partition(|item| item.action.as_ref().is_some_and(|a| a.command().is_some()));
|
||||
[
|
||||
(
|
||||
MCP_LOGIN_GROUP_ID,
|
||||
MessageId::ExtensionsGroupNeedsLogin,
|
||||
login,
|
||||
),
|
||||
(
|
||||
"attention",
|
||||
MessageId::ExtensionsGroupNeedsAttention,
|
||||
attention,
|
||||
),
|
||||
("servers", MessageId::ExtensionsGroupServers, healthy),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, _, items)| !items.is_empty())
|
||||
.map(|(id, label, items)| ExtensionGroup {
|
||||
id: id.into(),
|
||||
label: tr(locale, label).into_owned(),
|
||||
items,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ExtensionsFocus {
|
||||
Tabs,
|
||||
@@ -1288,7 +1313,7 @@ impl ExtensionsView {
|
||||
tab: ExtensionsTab,
|
||||
locale: Locale,
|
||||
) -> Self {
|
||||
Self {
|
||||
let mut view = Self {
|
||||
snapshot,
|
||||
locale,
|
||||
active_tab: tab,
|
||||
@@ -1299,7 +1324,20 @@ impl ExtensionsView {
|
||||
folded_groups: BTreeSet::new(),
|
||||
theme: crate::palette::UI_THEME,
|
||||
hits: RefCell::new(HitAreas::default()),
|
||||
};
|
||||
// `/mcp` opens on the first server that needs a login, not on that
|
||||
// group's heading, so the one key the screen advertises — Enter —
|
||||
// runs the login flow straight away (#5926).
|
||||
if view
|
||||
.snapshot
|
||||
.tab(tab)
|
||||
.groups
|
||||
.first()
|
||||
.is_some_and(|group| group.id == MCP_LOGIN_GROUP_ID)
|
||||
{
|
||||
view.selected[tab.index()] = 1;
|
||||
}
|
||||
view
|
||||
}
|
||||
|
||||
fn fold_key(&self, group: &ExtensionGroup) -> String {
|
||||
@@ -1882,4 +1920,124 @@ mod tests {
|
||||
view.handle_key(key(KeyCode::Tab));
|
||||
assert_eq!(view.active_tab, ExtensionsTab::Hooks);
|
||||
}
|
||||
|
||||
fn mcp_row(name: &str, state: &str, detail: &str, action: ExtensionAction) -> ExtensionItem {
|
||||
ExtensionItem {
|
||||
id: name.into(),
|
||||
label: name.into(),
|
||||
description: String::new(),
|
||||
state: state.into(),
|
||||
tone: ExtensionTone::Attention,
|
||||
detail: detail.into(),
|
||||
action: Some(action),
|
||||
}
|
||||
}
|
||||
|
||||
fn login_row(name: &str) -> ExtensionItem {
|
||||
mcp_row(
|
||||
name,
|
||||
&crate::tui::session_boot::mcp_auth_required_state_label(),
|
||||
"401 Unauthorized: the session is no longer accepted",
|
||||
ExtensionAction::Command {
|
||||
label: "re-auth".into(),
|
||||
command: McpRecoveryKind::Reauth.slash_command(name),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The founder's receipt (#5926): seven OAuth servers whose login
|
||||
/// expired and one that really failed. The expired logins lead in their
|
||||
/// own group with the login command on the row; the real failure keeps
|
||||
/// its reason; the connected server sorts last.
|
||||
#[test]
|
||||
fn mcp_rows_list_expired_logins_first_then_failures_with_their_reason() {
|
||||
let rows = vec![
|
||||
mcp_row(
|
||||
"alpha",
|
||||
"connected",
|
||||
"3 tools",
|
||||
ExtensionAction::Status {
|
||||
label: "connected".into(),
|
||||
},
|
||||
),
|
||||
mcp_row(
|
||||
"supabase",
|
||||
"error",
|
||||
"OAuth token refresh failed: Failed to parse server response",
|
||||
ExtensionAction::Command {
|
||||
label: "diagnose".into(),
|
||||
command: McpRecoveryKind::Diagnose.slash_command("supabase"),
|
||||
},
|
||||
),
|
||||
login_row("slack"),
|
||||
login_row("stripe"),
|
||||
];
|
||||
let groups = mcp_groups(Locale::En, rows);
|
||||
let shape: Vec<(&str, Vec<&str>)> = groups
|
||||
.iter()
|
||||
.map(|group| {
|
||||
(
|
||||
group.id.as_str(),
|
||||
group.items.iter().map(|item| item.label.as_str()).collect(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shape,
|
||||
vec![
|
||||
("login", vec!["slack", "stripe"]),
|
||||
("attention", vec!["supabase"]),
|
||||
("servers", vec!["alpha"]),
|
||||
]
|
||||
);
|
||||
assert_eq!(groups[0].label, "Needs login");
|
||||
assert_eq!(groups[1].label, "Needs attention");
|
||||
let slack = &groups[0].items[0];
|
||||
assert_eq!(
|
||||
slack.action.as_ref().and_then(ExtensionAction::command),
|
||||
Some("/mcp login slack")
|
||||
);
|
||||
assert!(!slack.state.contains("failed"), "{}", slack.state);
|
||||
assert_eq!(
|
||||
groups[1].items[0].detail,
|
||||
"OAuth token refresh failed: Failed to parse server response"
|
||||
);
|
||||
}
|
||||
|
||||
/// Opening `/mcp` lands on the first server that needs a login, so Enter
|
||||
/// is the login key, not a fold of the group heading. A tab without a
|
||||
/// login group keeps the heading-first default.
|
||||
#[test]
|
||||
fn mcp_tab_opens_on_the_first_login_row() {
|
||||
let mut snapshot = ExtensionsSnapshot::default();
|
||||
snapshot.tabs[ExtensionsTab::Mcp.index()] = ExtensionsTabModel {
|
||||
groups: mcp_groups(Locale::En, vec![login_row("slack"), login_row("stripe")]),
|
||||
problem: None,
|
||||
};
|
||||
let view = ExtensionsView::from_snapshot_with_locale(
|
||||
snapshot.clone(),
|
||||
ExtensionsTab::Mcp,
|
||||
Locale::En,
|
||||
);
|
||||
assert_eq!(view.selected[ExtensionsTab::Mcp.index()], 1);
|
||||
let entries = view.visible_entries();
|
||||
match entries[1] {
|
||||
VisibleEntry::Item(group, item) => {
|
||||
assert_eq!(group.id, MCP_LOGIN_GROUP_ID);
|
||||
assert_eq!(item.label, "slack");
|
||||
assert_eq!(
|
||||
item.action.as_ref().and_then(ExtensionAction::command),
|
||||
Some("/mcp login slack")
|
||||
);
|
||||
}
|
||||
other => panic!("expected the first login row, got {other:?}"),
|
||||
}
|
||||
|
||||
let plain = ExtensionsView::from_snapshot_with_locale(
|
||||
ExtensionsSnapshot::default(),
|
||||
ExtensionsTab::Mcp,
|
||||
Locale::En,
|
||||
);
|
||||
assert_eq!(plain.selected[ExtensionsTab::Mcp.index()], 0);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user