mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
6 files changed
+108
-3
No files matched your search
@@ -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 後自動跳過 — 按任意鍵保留",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user