Merge pull request #13732 from esengine/a5/tui-mcp-signin

feat(tui): sign in to an MCP server, or grant it more access, from /mcp
This commit is contained in:
YHH authored and GitHub committed 2026-10-17 23:12:14 -07:00
commit 6f68a14a9d
6 files changed
+108 -3

No files matched your search

+3 -3
View File
@@ -5,7 +5,7 @@ package i18n
var notifyEN = NotifyMessages{
UnattendedMessages: unattendedEN,
PaidSearchMessages: PaidSearchMessages{PaidSearchApprovalReasonFmt: "web_search wants to send this query to DeepSeek's paid Search API: %[3]s. DeepSeek bills %[2]s. Allow it for %[1]s?", PaidFetchApprovalReasonFmt: "web_fetch could not read this page from here and wants DeepSeek's paid Fetch API to fetch it: %[3]s. DeepSeek bills %[2]s. Allow it for %[1]s?"},
MCPSignInMessages: MCPSignInMessages{MCPSignInReasonFmt: "The MCP server %[1]s wants to send you to %[2]s to sign in. Only continue if you recognise that site; nothing is sent there until you approve.", MCPScopeUpgradeReasonFmt: "The MCP server %[1]s asks for more access than you granted it: %[3]s. These names come from the server, not from Reasonix. Only continue if you want it to have that access; you will be sent to %[2]s to review and grant it."},
MCPSignInMessages: MCPSignInMessages{MCPSignInReasonFmt: "The MCP server %[1]s wants to send you to %[2]s to sign in. Only continue if you recognise that site; nothing is sent there until you approve.", MCPScopeUpgradeReasonFmt: "The MCP server %[1]s asks for more access than you granted it: %[3]s. These names come from the server, not from Reasonix. Only continue if you want it to have that access; you will be sent to %[2]s to review and grant it.", McpPanelSignIn: "this server needs you to sign in: press a", McpPanelUpgradeFmt: "press u to review: this server asks for more access: %s (its own words)"},
AskTimeoutMessages: AskTimeoutMessages{
CmdAskTimeout: "skip a question nobody answers after a wait", AskTimeoutFmt: "ask timeout: %s — set it with /ask-timeout <seconds|off>", AskTimeoutOff: "off", AskTimeoutBadFmt: "ask-timeout: %q is neither a number of seconds nor off", AskSkipFmt: "skipped in %s — press any key to keep it",
},
@@ -21,7 +21,7 @@ var notifyEN = NotifyMessages{
var notifyZH = NotifyMessages{
UnattendedMessages: unattendedZH,
PaidSearchMessages: PaidSearchMessages{PaidSearchApprovalReasonFmt: "web_search 想把这条搜索词发给 DeepSeek 的付费搜索接口:%[3]s。DeepSeek 按次计费:%[2]s。是否允许使用 %[1]s?", PaidFetchApprovalReasonFmt: "web_fetch 在本机读不到这个页面,想用 DeepSeek 的付费抓取接口来抓:%[3]s。DeepSeek 按次计费:%[2]s。是否允许使用 %[1]s?"},
MCPSignInMessages: MCPSignInMessages{MCPSignInReasonFmt: "MCP 服务器 %[1]s 想让你去 %[2]s 登录。只有认得这个站点时才继续;批准之前不会向它发送任何东西。", MCPScopeUpgradeReasonFmt: "MCP 服务器 %[1]s 要求比你授予它的更多权限:%[3]s。这些名称来自服务器,不是 Reasonix 写的。只有你愿意让它拥有这些权限时才继续;之后会带你到 %[2]s 查看并授予。"},
MCPSignInMessages: MCPSignInMessages{MCPSignInReasonFmt: "MCP 服务器 %[1]s 想让你去 %[2]s 登录。只有认得这个站点时才继续;批准之前不会向它发送任何东西。", MCPScopeUpgradeReasonFmt: "MCP 服务器 %[1]s 要求比你授予它的更多权限:%[3]s。这些名称来自服务器,不是 Reasonix 写的。只有你愿意让它拥有这些权限时才继续;之后会带你到 %[2]s 查看并授予。", McpPanelSignIn: "这个服务器需要你登录:按 a", McpPanelUpgradeFmt: "按 u 查看并授权:这个服务器要求更多权限:%s(是它自己写的名称)"},
AskTimeoutMessages: AskTimeoutMessages{
CmdAskTimeout: "设置提问无人回答多久后自动跳过", AskTimeoutFmt: "提问等待超时:%s — 用 /ask-timeout <秒数|off> 修改", AskTimeoutOff: "关闭", AskTimeoutBadFmt: "ask-timeout:%q 既不是秒数也不是 off", AskSkipFmt: "%s 后自动跳过 — 按任意键保留",
},
@@ -37,7 +37,7 @@ var notifyZH = NotifyMessages{
var notifyZHTW = NotifyMessages{
UnattendedMessages: unattendedZHTW,
PaidSearchMessages: PaidSearchMessages{PaidSearchApprovalReasonFmt: "web_search 想把這條搜尋詞傳給 DeepSeek 的付費搜尋介面:%[3]s。DeepSeek 按次計費:%[2]s。是否允許使用 %[1]s?", PaidFetchApprovalReasonFmt: "web_fetch 在本機讀不到這個頁面,想用 DeepSeek 的付費抓取介面來抓:%[3]s。DeepSeek 按次計費:%[2]s。是否允許使用 %[1]s?"},
MCPSignInMessages: MCPSignInMessages{MCPSignInReasonFmt: "MCP 伺服器 %[1]s 想讓你去 %[2]s 登入。只有認得這個站點時才繼續;批准之前不會向它傳送任何東西。", MCPScopeUpgradeReasonFmt: "MCP 伺服器 %[1]s 要求比你授予它的更多權限:%[3]s。這些名稱來自伺服器,不是 Reasonix 寫的。只有你願意讓它擁有這些權限時才繼續;之後會帶你到 %[2]s 查看並授予。"},
MCPSignInMessages: MCPSignInMessages{MCPSignInReasonFmt: "MCP 伺服器 %[1]s 想讓你去 %[2]s 登入。只有認得這個站點時才繼續;批准之前不會向它傳送任何東西。", MCPScopeUpgradeReasonFmt: "MCP 伺服器 %[1]s 要求比你授予它的更多權限:%[3]s。這些名稱來自伺服器,不是 Reasonix 寫的。只有你願意讓它擁有這些權限時才繼續;之後會帶你到 %[2]s 查看並授予。", McpPanelSignIn: "這個伺服器需要你登入:按 a", McpPanelUpgradeFmt: "按 u 查看並授權:這個伺服器要求更多權限:%s(是它自己寫的名稱)"},
AskTimeoutMessages: AskTimeoutMessages{
CmdAskTimeout: "設定提問無人回答多久後自動跳過", AskTimeoutFmt: "提問等待逾時:%s — 用 /ask-timeout <秒數|off> 修改", AskTimeoutOff: "關閉", AskTimeoutBadFmt: "ask-timeout:%q 既不是秒數也不是 off", AskSkipFmt: "%s 後自動跳過 — 按任意鍵保留",
},
+2
View File
@@ -38,4 +38,6 @@ type MCPSignInMessages struct {
MCPSignInReasonFmt string // %[1]s server, %[2]s authorization host
// MCPScopeUpgradeReasonFmt asks for more access than the user granted: %[1]s server, %[2]s host, %[3]s the scopes it named.
MCPScopeUpgradeReasonFmt string
// McpPanelSignIn and McpPanelUpgradeFmt are the /mcp panel hints for the sign-in and upgrade keys.
McpPanelSignIn, McpPanelUpgradeFmt string
}
+39
View File
@@ -35,6 +35,10 @@ type MCPServer struct {
Launch string `json:"launch"`
// PendingReason is the kernel's code for why a pending server waits.
PendingReason string `json:"pendingReason"`
// HTTPStatus is what the endpoint answered when the failure was an HTTP one.
HTTPStatus int `json:"httpStatus"`
// ScopeUpgrade is the access a signed-in server asked for, in its own words.
ScopeUpgrade []string `json:"scopeUpgrade"`
}
func (c *Client) MCPServers(ctx context.Context) ([]MCPServer, error) {
@@ -54,6 +58,16 @@ func (c *Client) ReconnectMCP(ctx context.Context, name string) error {
return c.do(ctx, http.MethodPost, "/mcp/reconnect", map[string]string{"name": name}, nil)
}
// SignInMCP runs the browser sign-in for one server and returns when it ends.
func (c *Client) SignInMCP(ctx context.Context, name string) error {
return c.do(ctx, http.MethodPost, "/mcp/signin", map[string]string{"name": name}, nil)
}
// UpgradeMCP grants a signed-in server the access it asked for.
func (c *Client) UpgradeMCP(ctx context.Context, name string) error {
return c.do(ctx, http.MethodPost, "/mcp/upgrade", map[string]string{"name": name}, nil)
}
// mcpPanel lists the MCP servers; Enter on one shows its tools.
type mcpPanel struct {
servers []MCPServer
@@ -145,6 +159,18 @@ func (m *model) mcpKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
return nil, true
}
return m.mcpToggle(cur), true
case "a":
if !cur.Enabled || cur.State != "failed" || cur.HTTPStatus != http.StatusUnauthorized {
return nil, true
}
p.busy = true
return m.mcpAction(cur.Name, func(ctx context.Context) error { return m.client.SignInMCP(ctx, cur.Name) }), true
case "u":
if !cur.Enabled || len(cur.ScopeUpgrade) == 0 {
return nil, true
}
p.busy = true
return m.mcpAction(cur.Name, func(ctx context.Context) error { return m.client.UpgradeMCP(ctx, cur.Name) }), true
case "r":
if !cur.Enabled || cur.State != "failed" {
return nil, true
@@ -242,11 +268,24 @@ func (m *model) mcpPanelLines() []string {
if cur.State == "pending" && cur.Launch != "" {
lines = append(lines, termrender.Yellow(" "+fmt.Sprintf(i18n.M.McpPanelLaunchFmt, clipVisible(textutil.SanitizeLaunch(cur.Launch), width-8))))
}
lines = append(lines, mcpAuthHint(cur, width)...)
lines = append(lines, termrender.Dim(i18n.M.McpPanelHint))
}
return panel(lines, m.width, accentEdge)
}
// mcpAuthHint names the one key that answers the server under the cursor, when
// one does. The scope names are the server's words and are shown as written.
func mcpAuthHint(s MCPServer, width int) []string {
switch {
case s.Enabled && len(s.ScopeUpgrade) > 0:
return []string{termrender.Yellow(" " + clipVisible(fmt.Sprintf(i18n.M.McpPanelUpgradeFmt, textutil.SanitizeLaunch(strings.Join(s.ScopeUpgrade, " "))), width))}
case s.Enabled && s.State == "failed" && s.HTTPStatus == http.StatusUnauthorized:
return []string{termrender.Yellow(" " + i18n.M.McpPanelSignIn)}
}
return nil
}
func (m *model) mcpDetail(s MCPServer) []string {
width := max(m.width-8, 12)
lines := []string{termrender.Accent(textutil.SanitizeLaunch(s.Name)), termrender.Dim(" " + textutil.SanitizeLaunch(s.State+" · "+s.Transport+" · "+s.Source))}
@@ -0,0 +1,56 @@
package tui
import (
"strings"
"testing"
)
func authRows() []map[string]any {
return []map[string]any{
{"name": "figma", "state": "failed", "enabled": true, "transport": "http", "httpStatus": 401},
{"name": "notes", "state": "ready", "enabled": true, "transport": "http", "tools": 1, "scopeUpgrade": []string{"files:write", "admin"}},
{"name": "docs", "state": "ready", "enabled": true, "transport": "http", "tools": 2},
{"name": "down", "state": "failed", "enabled": true, "transport": "http", "httpStatus": 500},
}
}
func TestMCPPanelSignsInOnlyAServerThatAnswered401(t *testing.T) {
m, k := testModel(t)
k.mcpRows = authRows()
enterMCPLine(m, "/mcp")
if v := m.View().Content; !strings.Contains(v, "press a") {
t.Fatalf("no sign-in hint for a 401 row:\n%s", v)
}
run(m, keyRune(m, 'a'))
if calls := strings.Join(k.seen(), "\n"); !strings.Contains(calls, `POST /mcp/signin {"name":"figma"}`) {
t.Fatalf("calls:\n%s", calls)
}
for _, sel := range []int{2, 3} {
m.mcp.sel = sel
before := len(k.seen())
run(m, keyRune(m, 'a'))
if len(k.seen()) != before {
t.Fatalf("a signed in a server (row %d) that did not answer 401", sel)
}
}
}
func TestMCPPanelUpgradeShowsTheServersScopesAndAsksOnlyForAServerThatAsked(t *testing.T) {
m, k := testModel(t)
k.mcpRows = authRows()
enterMCPLine(m, "/mcp")
m.mcp.sel = 1
if v := m.View().Content; !strings.Contains(v, "files:write admin") || !strings.Contains(v, "press u") {
t.Fatalf("the scope words and the key are not shown:\n%s", v)
}
run(m, keyRune(m, 'u'))
if calls := strings.Join(k.seen(), "\n"); !strings.Contains(calls, `POST /mcp/upgrade {"name":"notes"}`) {
t.Fatalf("calls:\n%s", calls)
}
m.mcp.sel = 2
before := len(k.seen())
run(m, keyRune(m, 'u'))
if len(k.seen()) != before {
t.Fatal("u upgraded a server that asked for nothing")
}
}
+6
View File
@@ -30,6 +30,8 @@ type recordingKernel struct {
refuseText bool
// partialCoverage makes a prepared rewind plan ask for confirmation.
partialCoverage bool
// mcpRows replaces the canned /mcp list when set.
mcpRows []map[string]any
}
func (k *recordingKernel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -109,6 +111,10 @@ func (k *recordingKernel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
{"ref": "beta/solo", "provider": "beta", "model": "solo"},
}})
case "/mcp":
if k.mcpRows != nil {
_ = json.NewEncoder(w).Encode(map[string]any{"servers": k.mcpRows})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"servers": []map[string]any{
{"name": "docs", "state": "ready", "enabled": true, "transport": "http", "source": "user", "tools": 2,
"toolList": []map[string]any{{"name": "search", "readOnly": true}, {"name": "purge", "destructive": true}}},
@@ -0,0 +1,2 @@
- 新增:终端界面的 /mcp 面板里,返回 401 的服务器可按 a 登录,要求更多权限的服务器可按 u 授权,都会先问你。关联 #12357、#11959。
- Added: In the terminal /mcp panel, press a to sign in to a server that answered 401, or u to grant one the extra access it asked for; you are asked first. Refs #12357, #11959.