mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(mcp): sign-in asks which host the server sends you to before the browser opens
This commit is contained in:
1 parent
3d983ca0f3
commit
a3337ebbf7
23 files changed
+816
-8
No files matched your search
@@ -78,6 +78,15 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"What this server launches changed since you enabled it; review the command and enable it again",
|
||||
"这个服务没有被暂挡的工具定义": "This server has no held tool definitions",
|
||||
"你看到的被挡定义已经变了,没有接受;请重新查看": "The held definitions changed since you saw them, so nothing was accepted; review them again",
|
||||
"这个客户端不能登录 MCP 服务器": "This client cannot sign in to MCP servers",
|
||||
"你没有同意前往该登录站点,已取消登录": "You did not agree to go to that sign-in site, so sign-in was cancelled",
|
||||
"这个服务器不使用 OAuth 登录": "This server does not use OAuth sign-in",
|
||||
"这个服务器已经在登录中": "A sign-in for this server is already open",
|
||||
"服务器给出的登录页没有主机名,已停止": "The server's sign-in page names no host, so it was stopped",
|
||||
"这台机器打不开浏览器": "This machine cannot open a browser",
|
||||
"登录没有在时限内完成": "Sign-in was not completed in time",
|
||||
"登录已取消": "Sign-in was cancelled",
|
||||
"登录没有完成:{detail}": "Sign-in did not complete: {detail}",
|
||||
"开关未能保存:其存储文件无法读取或写入": "The switch was not stored: its file could not be read or written",
|
||||
// 待送达:条目、队列、这份存档各自会拒
|
||||
"该条已不在待送达队列中": "That entry is no longer in the pending queue",
|
||||
|
||||
@@ -382,6 +382,15 @@ const SAID: Record<string, string> = {
|
||||
"mcp.approval_owed": "这个服务要启动的内容在你启用后变了,请先查看命令并重新启用",
|
||||
"mcp.nothing_held": "这个服务没有被暂挡的工具定义",
|
||||
"mcp.digest_mismatch": "你看到的被挡定义已经变了,没有接受;请重新查看",
|
||||
"mcp.editing_disabled": "这个客户端不能登录 MCP 服务器",
|
||||
"mcp.signin_declined": "你没有同意前往该登录站点,已取消登录",
|
||||
"mcp.signin_ineligible": "这个服务器不使用 OAuth 登录",
|
||||
"mcp.signin_busy": "这个服务器已经在登录中",
|
||||
"mcp.signin_no_host": "服务器给出的登录页没有主机名,已停止",
|
||||
"mcp.signin_no_browser": "这台机器打不开浏览器",
|
||||
"mcp.signin_timed_out": "登录没有在时限内完成",
|
||||
"mcp.signin_cancelled": "登录已取消",
|
||||
"mcp.signin_failed": "登录没有完成:{detail}",
|
||||
"activation.unavailable": "开关未能保存:其存储文件无法读取或写入",
|
||||
|
||||
// ── 待送达:条目、队列、这份存档各自会拒 ─────────────────────────
|
||||
|
||||
+3
-2
@@ -1226,12 +1226,13 @@ headers = { IJ_MCP_SERVER_PROJECT_PATH = "${REASONIX_WORKSPACE_ROOT}" }
|
||||
|
||||
For a remote HTTP server without a static `Authorization` header, an
|
||||
authentication challenge (HTTP 401 with a `Bearer` challenge) is reported as
|
||||
needing sign-in. Run `reasonix mcp auth <name>` in the CLI; Studio has no
|
||||
sign-in control yet. Reasonix performs OAuth metadata discovery, dynamic
|
||||
needing sign-in. Run `reasonix mcp auth <name>` in the CLI. Reasonix performs OAuth metadata discovery, dynamic
|
||||
client registration, PKCE S256 authorization, and refresh-token
|
||||
rotation. Discovery and token requests use the same Reasonix network-proxy
|
||||
settings as the MCP connection.
|
||||
|
||||
Before a browser opens, Reasonix names the authorization host the server chose and asks. "Always" is remembered for that server and host only, beside its OAuth state, and cleared with it.
|
||||
|
||||
OAuth client and token state is kept outside the workspace in the server's
|
||||
private Reasonix state directory, written with mode `0600`, and bound to the
|
||||
full configured resource URL. An explicit static `Authorization` header always
|
||||
|
||||
+3
-1
@@ -968,10 +968,12 @@ headers = { IJ_MCP_SERVER_PROJECT_PATH = "${REASONIX_WORKSPACE_ROOT}" }
|
||||
```
|
||||
|
||||
远程 HTTP server 未配置静态 `Authorization` header 时,认证要求(HTTP 401 且带 `Bearer` 质询)会被识别为需要登录。
|
||||
CLI 可运行 `reasonix mcp auth <name>`;Studio 暂时还没有登录入口。
|
||||
CLI 可运行 `reasonix mcp auth <name>`。
|
||||
Reasonix 会执行 OAuth 元数据发现、动态客户端注册、PKCE S256 授权与
|
||||
refresh token 轮换;发现和 token 请求与 MCP 连接使用相同的 Reasonix 网络代理设置。
|
||||
|
||||
打开浏览器之前,Reasonix 会先说明服务器指定的授权主机并询问;“始终允许”只对该服务器和该主机生效,记在其 OAuth 状态旁,清除认证时一并清掉。
|
||||
|
||||
OAuth client 与 token 状态保存在工作区之外、该 server 私有的 Reasonix 状态目录中,文件权限
|
||||
为 `0600`,并绑定完整的 resource URL。显式静态 `Authorization` header 始终优先。
|
||||
**清除认证** 只删除 Reasonix 本地 OAuth 状态,不会退出第三方浏览器会话。Reasonix 仅在用户
|
||||
|
||||
@@ -5,6 +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."},
|
||||
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",
|
||||
},
|
||||
@@ -20,6 +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 登录。只有认得这个站点时才继续;批准之前不会向它发送任何东西。"},
|
||||
AskTimeoutMessages: AskTimeoutMessages{
|
||||
CmdAskTimeout: "设置提问无人回答多久后自动跳过", AskTimeoutFmt: "提问等待超时:%s — 用 /ask-timeout <秒数|off> 修改", AskTimeoutOff: "关闭", AskTimeoutBadFmt: "ask-timeout:%q 既不是秒数也不是 off", AskSkipFmt: "%s 后自动跳过 — 按任意键保留",
|
||||
},
|
||||
@@ -35,6 +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 登入。只有認得這個站點時才繼續;批准之前不會向它傳送任何東西。"},
|
||||
AskTimeoutMessages: AskTimeoutMessages{
|
||||
CmdAskTimeout: "設定提問無人回答多久後自動跳過", AskTimeoutFmt: "提問等待逾時:%s — 用 /ask-timeout <秒數|off> 修改", AskTimeoutOff: "關閉", AskTimeoutBadFmt: "ask-timeout:%q 既不是秒數也不是 off", AskSkipFmt: "%s 後自動跳過 — 按任意鍵保留",
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ type NotifyMessages struct {
|
||||
AskTimeoutMessages
|
||||
UnattendedMessages
|
||||
PaidSearchMessages
|
||||
MCPSignInMessages
|
||||
}
|
||||
|
||||
// AskTimeoutMessages is the /ask-timeout command's text.
|
||||
@@ -30,3 +31,9 @@ type PaidSearchMessages struct {
|
||||
PaidSearchApprovalReasonFmt string // %[1]s backend, %[2]s price, %[3]s query
|
||||
PaidFetchApprovalReasonFmt string // %[1]s backend, %[2]s price, %[3]s address
|
||||
}
|
||||
|
||||
// MCPSignInMessages is the consent card shown before a browser opens for an MCP
|
||||
// server's sign-in.
|
||||
type MCPSignInMessages struct {
|
||||
MCPSignInReasonFmt string // %[1]s server, %[2]s authorization host
|
||||
}
|
||||
@@ -261,6 +261,9 @@ func reconcileHTTPMCPOAuth(spec Spec, remainingResource string) (bool, error) {
|
||||
if err := bumpMCPOAuthGeneration(spec.StateDir); err != nil {
|
||||
return false, fmt.Errorf("clear MCP OAuth state: %w", err)
|
||||
}
|
||||
if err := ForgetSignInConsent(spec.StateDir); err != nil {
|
||||
return false, fmt.Errorf("clear MCP OAuth state: %w", err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/fileutil"
|
||||
)
|
||||
|
||||
const signInConsentFile = "signin-consent.json"
|
||||
|
||||
type signInConsent struct {
|
||||
Version int `json:"version"`
|
||||
Hosts []string `json:"hosts"`
|
||||
}
|
||||
|
||||
// AuthorizationHost is the host a browser would be sent to for this
|
||||
// authorization URL, lower-cased with its port; "" when the URL names none.
|
||||
func AuthorizationHost(rawURL string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(u.Host)
|
||||
}
|
||||
|
||||
func signInConsentPath(stateDir string) string {
|
||||
if strings.TrimSpace(stateDir) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(stateDir, signInConsentFile)
|
||||
}
|
||||
|
||||
func readSignInConsent(stateDir string) signInConsent {
|
||||
var c signInConsent
|
||||
path := signInConsentPath(stateDir)
|
||||
if path == "" {
|
||||
return c
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || json.Unmarshal(raw, &c) != nil || c.Version != 1 {
|
||||
return signInConsent{}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// SignInHostRemembered reports whether the user already agreed, for this
|
||||
// server, to be sent to host for sign-in. The file sits beside the server's
|
||||
// OAuth state, so removing that state forgets the agreement with it.
|
||||
func SignInHostRemembered(stateDir, host string) bool {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
return host != "" && slices.Contains(readSignInConsent(stateDir).Hosts, host)
|
||||
}
|
||||
|
||||
// RememberSignInHost records the user's agreement to be sent to host for this
|
||||
// server's sign-in. It only ever skips the question; the browser is still sent
|
||||
// to the authorization endpoint the server's metadata names.
|
||||
func RememberSignInHost(stateDir, host string) error {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
path := signInConsentPath(stateDir)
|
||||
if host == "" || path == "" {
|
||||
return errors.New("MCP sign-in consent: no host or state directory to record it in")
|
||||
}
|
||||
c := readSignInConsent(stateDir)
|
||||
if slices.Contains(c.Hosts, host) {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
c = signInConsent{Version: 1, Hosts: append(slices.Clone(c.Hosts), host)}
|
||||
raw, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.AtomicWriteFile(path, raw, 0o600)
|
||||
}
|
||||
|
||||
// ForgetSignInConsent withdraws every remembered host of one server.
|
||||
func ForgetSignInConsent(stateDir string) error {
|
||||
path := signInConsentPath(stateDir)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestAuthorizationHostIsTheHostWithItsPortAndNothingElse(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://Auth.Example.test/authorize?x=1": "auth.example.test",
|
||||
"https://auth.example.test:8443/a": "auth.example.test:8443",
|
||||
"https://user:[email protected]/a": "auth.example.test",
|
||||
"/relative": "",
|
||||
"not a url": "",
|
||||
"": "",
|
||||
}
|
||||
for raw, want := range cases {
|
||||
if got := AuthorizationHost(raw); got != want {
|
||||
t.Errorf("AuthorizationHost(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignInConsentIsPerHostPrivateAndWithdrawable(t *testing.T) {
|
||||
dir := filepath.Join(testenv.TempDir(t), "figma")
|
||||
if SignInHostRemembered(dir, "auth.example.test") {
|
||||
t.Fatal("a server nobody agreed for is remembered")
|
||||
}
|
||||
if err := RememberSignInHost(dir, "Auth.Example.test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RememberSignInHost(dir, "auth.example.test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !SignInHostRemembered(dir, "auth.example.test") || SignInHostRemembered(dir, "evil.example.test") {
|
||||
t.Fatal("consent is not bound to the host the user saw")
|
||||
}
|
||||
if got := readSignInConsent(dir).Hosts; len(got) != 1 {
|
||||
t.Fatalf("hosts = %v, want one entry", got)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(signInConsentPath(dir))
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("consent file mode: %v %v", info, err)
|
||||
}
|
||||
}
|
||||
if err := ForgetSignInConsent(dir); err != nil || SignInHostRemembered(dir, "auth.example.test") {
|
||||
t.Fatalf("forget: %v", err)
|
||||
}
|
||||
if err := ForgetSignInConsent(dir); err != nil {
|
||||
t.Fatalf("forgetting nothing failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACorruptConsentFileGrantsNothing(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
if err := os.WriteFile(signInConsentPath(dir), []byte(`{"version":2,"hosts":["auth.example.test"]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if SignInHostRemembered(dir, "auth.example.test") {
|
||||
t.Fatal("an unknown file version was honoured")
|
||||
}
|
||||
if err := os.WriteFile(signInConsentPath(dir), []byte(`{not json`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if SignInHostRemembered(dir, "auth.example.test") {
|
||||
t.Fatal("a malformed file was honoured")
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,10 @@ import (
|
||||
"reasonix/internal/ext/pluginspec"
|
||||
)
|
||||
|
||||
var mcpAuthorizeForCLI = plugin.AuthorizeHTTPMCP
|
||||
var (
|
||||
mcpAuthorizeForCLI = plugin.AuthorizeHTTPMCP
|
||||
mcpOpenForCLI = openMCPAuthorizationURL
|
||||
)
|
||||
|
||||
func mcpAuthCLI(args []string) int {
|
||||
if len(args) == 0 {
|
||||
@@ -58,7 +61,11 @@ func mcpAuthCLI(args []string) int {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := mcpAuthorizeForCLI(ctx, specs[0], openMCPAuthorizationURL); err != nil {
|
||||
announce := func(rawURL string) error {
|
||||
fmt.Printf("opening %s in your browser to sign in to %q; close it if you do not recognise that site\n", plugin.AuthorizationHost(rawURL), name)
|
||||
return mcpOpenForCLI(rawURL)
|
||||
}
|
||||
if err := mcpAuthorizeForCLI(ctx, specs[0], announce); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -77,3 +79,39 @@ func TestMCPAuthCLIRejectsIneligibleConfigurations(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The terminal names the host before the browser opens, so the person typing the
|
||||
// command sees where the server sends them.
|
||||
func TestMCPAuthCLINamesTheHostBeforeOpeningTheBrowser(t *testing.T) {
|
||||
isolateCLIConfigHome(t)
|
||||
workspace := testenv.TempDir(t)
|
||||
t.Chdir(workspace)
|
||||
entry := config.PluginEntry{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp", Source: config.MCPSourceUserConfig}
|
||||
if _, err := config.InstallUserPluginForRoot(workspace, entry, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var opened string
|
||||
prevAuth, prevOpen := mcpAuthorizeForCLI, mcpOpenForCLI
|
||||
mcpOpenForCLI = func(raw string) error { opened = raw; return nil }
|
||||
mcpAuthorizeForCLI = func(_ context.Context, _ plugin.Spec, open func(string) error) error {
|
||||
return open("https://auth.example.test:8443/authorize?x=1")
|
||||
}
|
||||
t.Cleanup(func() { mcpAuthorizeForCLI, mcpOpenForCLI = prevAuth, prevOpen })
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdout := os.Stdout
|
||||
os.Stdout = w
|
||||
code := mcpAuthCLI([]string{"figma"})
|
||||
os.Stdout = stdout
|
||||
_ = w.Close()
|
||||
out, _ := io.ReadAll(r)
|
||||
if code != 0 || opened == "" {
|
||||
t.Fatalf("exit=%d opened=%q", code, opened)
|
||||
}
|
||||
if !strings.Contains(string(out), "auth.example.test:8443") {
|
||||
t.Fatalf("the host was not named:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -425,6 +425,7 @@ func (s *Server) mcpRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /mcp", s.mcp)
|
||||
mux.HandleFunc("POST /mcp/reconnect", s.mcpReconnect)
|
||||
mux.HandleFunc("POST /mcp/trust", s.mcpTrust)
|
||||
mux.HandleFunc("POST /mcp/signin", s.mcpSignIn)
|
||||
mux.HandleFunc("POST /mcp/enabled", s.mcpEnabled)
|
||||
mux.HandleFunc("POST /mcp/load", s.mcpLoad)
|
||||
mux.HandleFunc("POST /mcp/parse", s.mcpParse)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
// openAuthorizationPage starts the machine's browser on the authorization page.
|
||||
// The controller decides whether it may be called; this only says how.
|
||||
var openAuthorizationPage = func(rawURL string) error {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", rawURL)
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
|
||||
default:
|
||||
cmd = exec.Command("xdg-open", rawURL)
|
||||
}
|
||||
cmd.Env = os.Environ()
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// mcpSignIn signs in to one MCP server. It answers when the sign-in ends, up to
|
||||
// the controller's bound, and rides the provider-edit grant: it opens a browser
|
||||
// on this machine and stores a credential, which a networked client must not do.
|
||||
func (s *Server) mcpSignIn(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.grants.at(r).providerEdit {
|
||||
refuse(w, http.StatusForbidden, "mcp.editing_disabled", "MCP sign-in is not enabled for this client", nil)
|
||||
return
|
||||
}
|
||||
name, ok := decodeMCPName(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res, err := s.ctl().AuthorizeMCPServer(r.Context(), name, openAuthorizationPage)
|
||||
if err != nil {
|
||||
refuseMCPSignIn(w, name, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"name": name, "state": control.MCPHealthReady, "host": res.Host, "tools": res.Tools})
|
||||
}
|
||||
|
||||
func refuseMCPSignIn(w http.ResponseWriter, name string, err error) {
|
||||
switch {
|
||||
case errors.Is(err, control.ErrMCPSignInDeclined):
|
||||
refuse(w, http.StatusConflict, "mcp.signin_declined", "sign-in was declined", nil)
|
||||
case errors.Is(err, control.ErrMCPSignInIneligible):
|
||||
refuse(w, http.StatusBadRequest, "mcp.signin_ineligible", "this MCP server does not use OAuth sign-in", map[string]any{"name": name})
|
||||
case errors.Is(err, control.ErrMCPSignInBusy):
|
||||
refuse(w, http.StatusConflict, "mcp.signin_busy", "a sign-in for this MCP server is already open", map[string]any{"name": name})
|
||||
case errors.Is(err, control.ErrMCPSignInNoHost):
|
||||
refuse(w, http.StatusBadGateway, "mcp.signin_no_host", "the server's authorization page names no host", map[string]any{"name": name})
|
||||
case errors.Is(err, control.ErrMCPSignInNoBrowser):
|
||||
refuse(w, http.StatusInternalServerError, "mcp.signin_no_browser", "this machine cannot open a browser", nil)
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
refuse(w, http.StatusGatewayTimeout, "mcp.signin_timed_out", "sign-in was not completed in time", map[string]any{"name": name})
|
||||
case errors.Is(err, context.Canceled):
|
||||
refuse(w, http.StatusConflict, "mcp.signin_cancelled", "sign-in was cancelled", map[string]any{"name": name})
|
||||
case errors.Is(err, control.ErrMCPApprovalOwed):
|
||||
refuse(w, http.StatusConflict, "mcp.approval_owed", err.Error(), nil)
|
||||
default:
|
||||
writeCatalogErrorOr(w, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeCatalogErrorOr names an unknown server as such and reports any other
|
||||
// failure as the dependency it is: the server's authorization side said no.
|
||||
func writeCatalogErrorOr(w http.ResponseWriter, name string, err error) {
|
||||
var unknown *config.ServerNotFoundError
|
||||
if errors.As(err, &unknown) {
|
||||
writeCatalogError(w, err)
|
||||
return
|
||||
}
|
||||
refuse(w, http.StatusBadGateway, "mcp.signin_failed", err.Error(), map[string]any{"name": name, "detail": err.Error()})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
func signInServer(t *testing.T, allowEdit bool, pluginsTOML string) string {
|
||||
t.Helper()
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
if err := os.WriteFile(config.UserConfigPath(), []byte(pluginsTOML), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := testenv.TempDir(t)
|
||||
path := filepath.Join(dir, "sessions", "me.jsonl")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bc := NewBroadcaster()
|
||||
ctrl := control.New(control.Options{Sink: bc, Label: "me", ModelRef: config.UnconfiguredRef, WorkspaceRoot: dir, SessionDir: filepath.Dir(path), SessionPath: path})
|
||||
t.Cleanup(ctrl.Close)
|
||||
server := New(ctrl, bc, config.ServeConfig{})
|
||||
if allowEdit {
|
||||
server.AllowProviderEdit()
|
||||
}
|
||||
srv := httptest.NewServer(operatorHandler(server))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
func TestMCPSignInRouteRefusalsCarryTheirOwnCodes(t *testing.T) {
|
||||
withKey := "[[plugins]]\nname = \"keyed\"\ntype = \"http\"\nurl = \"https://mcp.example.test/mcp\"\n[plugins.headers]\nAuthorization = \"Bearer x\"\n"
|
||||
url := signInServer(t, true, withKey)
|
||||
cases := []struct {
|
||||
body map[string]any
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{map[string]any{"name": "keyed"}, http.StatusBadRequest, "mcp.signin_ineligible"},
|
||||
{map[string]any{"name": "nobody"}, http.StatusNotFound, ""},
|
||||
{map[string]any{}, http.StatusBadRequest, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
status, raw := postSide(t, url+"/mcp/signin", c.body)
|
||||
if status != c.status || !strings.Contains(string(raw), c.code) {
|
||||
t.Fatalf("%v = %d %s", c.body, status, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestANetworkedClientCannotStartAMCPSignIn(t *testing.T) {
|
||||
url := signInServer(t, false, "[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.example.test/mcp\"\n")
|
||||
status, raw := postSide(t, url+"/mcp/signin", map[string]any{"name": "figma"})
|
||||
if status != http.StatusForbidden || !strings.Contains(string(raw), "mcp.editing_disabled") {
|
||||
t.Fatalf("without the grant = %d %s", status, raw)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,11 @@ const NetworkEgress = "network_egress"
|
||||
// the subject is the backend. Only the user can agree to be charged.
|
||||
const PaidSearch = "paid_search"
|
||||
|
||||
// MCPSignIn asks whether the browser may be sent to an authorization host to sign
|
||||
// in to an MCP server; the subject names the server and the host. The server
|
||||
// chose that host, so only the user answers.
|
||||
const MCPSignIn = "mcp_signin"
|
||||
|
||||
// subjectSensitiveTools maps each tool whose authorization reads its subject to
|
||||
// the question it asks of that subject. Membership is one declaration carrying
|
||||
// three consequences, so none can arrive without the others: the decision
|
||||
@@ -29,6 +34,7 @@ var subjectSensitiveTools = map[string]func(subject string) bool{
|
||||
ExtendWritePaths: func(string) bool { return true },
|
||||
NetworkEgress: func(string) bool { return true },
|
||||
PaidSearch: func(string) bool { return true },
|
||||
MCPSignIn: func(string) bool { return true },
|
||||
installSourceTool: func(s string) bool { return strings.HasPrefix(s, selfExtendHumanRisk) },
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,18 @@ func TestSessionGrantForAnInstallPlanNamesTheTicket(t *testing.T) {
|
||||
|
||||
// A session grant for one host must not answer for another, or approving a
|
||||
// harmless host first would open every host after it.
|
||||
func TestSessionGrantForMCPSignInNamesTheServerAndHost(t *testing.T) {
|
||||
rule := SessionGrantRuleForScope(MCPSignIn, "figma → auth.example.test")
|
||||
if !SessionGrantMatches(rule, MCPSignIn, "figma → auth.example.test") {
|
||||
t.Errorf("rule %q does not cover the sign-in it was granted for", rule)
|
||||
}
|
||||
for _, other := range []string{"figma → evil.example", "other → auth.example.test"} {
|
||||
if SessionGrantMatches(rule, MCPSignIn, other) {
|
||||
t.Errorf("rule %q also covers %q", rule, other)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionGrantForEgressNamesTheHost(t *testing.T) {
|
||||
rule := SessionGrantRuleForScope(NetworkEgress, "pypi.org")
|
||||
if !SessionGrantMatches(rule, NetworkEgress, "pypi.org") {
|
||||
@@ -145,6 +157,7 @@ func TestEverySubjectSensitiveToolActuallyAsksSomething(t *testing.T) {
|
||||
installSourceTool: {selfExtendHumanRisk + "sha256:abc"},
|
||||
NetworkEgress: {"pypi.org"},
|
||||
PaidSearch: {"deepseek_api"},
|
||||
MCPSignIn: {"figma → auth.example.test"},
|
||||
}
|
||||
for tool := range subjectSensitiveTools {
|
||||
subjects, ok := probes[tool]
|
||||
|
||||
@@ -632,7 +632,7 @@ func ParseToolApprovalMode(mode string) (string, bool) {
|
||||
// narrow operation in interactive or headless mode.
|
||||
func RequiresFreshHumanApprovalTool(tool string) bool {
|
||||
switch tool {
|
||||
case planApprovalTool, memoryRememberTool, memoryForgetTool, SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool, NetworkEgressApprovalTool, PaidSearchApprovalTool:
|
||||
case planApprovalTool, memoryRememberTool, memoryForgetTool, SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool, NetworkEgressApprovalTool, PaidSearchApprovalTool, MCPSignInApprovalTool:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -670,7 +670,7 @@ func approvalGrantsForRequest(tool string, fresh, requireHuman bool) (session, p
|
||||
|
||||
func allowsFreshSessionGrantTool(tool string) bool {
|
||||
switch tool {
|
||||
case SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool, NetworkEgressApprovalTool, PaidSearchApprovalTool:
|
||||
case SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool, NetworkEgressApprovalTool, PaidSearchApprovalTool, MCPSignInApprovalTool:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -55,7 +55,7 @@ func (c *Controller) offersPersist(tool string, persist bool) bool {
|
||||
if tool == NetworkEgressApprovalTool {
|
||||
return c.egressPersistable()
|
||||
}
|
||||
if tool == PaidSearchApprovalTool {
|
||||
if tool == PaidSearchApprovalTool || tool == MCPSignInApprovalTool {
|
||||
return true
|
||||
}
|
||||
return persist && c.onRemember != nil
|
||||
|
||||
@@ -40,6 +40,8 @@ type mcpManager struct {
|
||||
// notice tells the model what a catalog refresh took away. It is the
|
||||
// executor's turn tail, bound once at construction.
|
||||
notice func(string)
|
||||
// signingIn names the servers whose sign-in is in flight, under mu.
|
||||
signingIn map[string]bool
|
||||
}
|
||||
|
||||
// seal makes the manager refuse to connect or register any server.
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/i18n"
|
||||
"reasonix/internal/contract/mcpdiag"
|
||||
"reasonix/internal/ext/plugin"
|
||||
"reasonix/internal/safety/permission"
|
||||
)
|
||||
|
||||
// MCPSignInApprovalTool is the internal tool name of the card shown before a
|
||||
// browser opens for an MCP server's sign-in.
|
||||
const MCPSignInApprovalTool = permission.MCPSignIn
|
||||
|
||||
const mcpSignInBound = 5 * time.Minute
|
||||
|
||||
var (
|
||||
// ErrMCPSignInIneligible is a server that cannot sign in with OAuth: not a
|
||||
// Streamable HTTP server, or one with credentials configured.
|
||||
ErrMCPSignInIneligible = errors.New("this MCP server does not use OAuth sign-in")
|
||||
// ErrMCPSignInDeclined is the user refusing to be sent to the authorization host.
|
||||
ErrMCPSignInDeclined = errors.New("sign-in was declined")
|
||||
// ErrMCPSignInBusy is a second sign-in for a server whose first is still open.
|
||||
ErrMCPSignInBusy = errors.New("a sign-in for this MCP server is already open")
|
||||
// ErrMCPSignInNoBrowser is a frontend that cannot open a browser.
|
||||
ErrMCPSignInNoBrowser = errors.New("this frontend cannot open a browser")
|
||||
// ErrMCPSignInNoHost is an authorization URL that names no host to ask about.
|
||||
ErrMCPSignInNoHost = errors.New("the authorization page names no host")
|
||||
)
|
||||
|
||||
// MCPSignInResult is a finished sign-in: the host the user was sent to and the
|
||||
// tool count of the server once it reconnected.
|
||||
type MCPSignInResult struct {
|
||||
Server string `json:"server"`
|
||||
Host string `json:"host"`
|
||||
Tools int `json:"tools"`
|
||||
}
|
||||
|
||||
// AuthorizeMCPServer signs in to one configured MCP server. The frontend only
|
||||
// supplies how a browser is opened; whether it is opened at all is decided here,
|
||||
// after the user has seen which host the server sends them to. A browser is
|
||||
// never opened from a tool call or a background failure, only from this verb.
|
||||
func (c *Controller) AuthorizeMCPServer(ctx context.Context, name string, open func(url string) error) (MCPSignInResult, error) {
|
||||
result, err := c.authorizeMCPServer(ctx, name, open)
|
||||
if err != nil {
|
||||
return MCPSignInResult{}, err
|
||||
}
|
||||
if result.Tools, err = c.ReconnectMCPServer(result.Server); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// authorizeMCPServer is the sign-in itself, up to the stored token; the caller
|
||||
// reconnects the server with it.
|
||||
func (c *Controller) authorizeMCPServer(ctx context.Context, name string, open func(url string) error) (MCPSignInResult, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
entry, err := c.configuredMCPServer(name)
|
||||
if err != nil {
|
||||
return MCPSignInResult{}, err
|
||||
}
|
||||
if !mcpdiag.CanUseHTTPMCPOAuth(entry.Type, entry.URL, mcpdiag.HasAuthConfig(entry.Headers, entry.Env, entry.URL)) {
|
||||
return MCPSignInResult{}, ErrMCPSignInIneligible
|
||||
}
|
||||
if open == nil {
|
||||
return MCPSignInResult{}, ErrMCPSignInNoBrowser
|
||||
}
|
||||
if !c.mcp.beginSignIn(name) {
|
||||
return MCPSignInResult{}, ErrMCPSignInBusy
|
||||
}
|
||||
defer c.mcp.endSignIn(name)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, mcpSignInBound)
|
||||
defer cancel()
|
||||
spec := c.mcpSpec(entry)
|
||||
result := MCPSignInResult{Server: name}
|
||||
err = plugin.AuthorizeHTTPMCP(ctx, spec, func(authURL string) error {
|
||||
host := plugin.AuthorizationHost(authURL)
|
||||
if err := c.consentMCPSignIn(ctx, name, host, spec.StateDir); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Host = host
|
||||
return open(authURL)
|
||||
})
|
||||
if err != nil {
|
||||
return MCPSignInResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// consentMCPSignIn asks before the browser opens, unless the user already agreed
|
||||
// to this host for this server. "Always" is kept beside the server's own OAuth
|
||||
// state, so a different server sending the user to the same host is asked again.
|
||||
func (c *Controller) consentMCPSignIn(ctx context.Context, server, host, stateDir string) error {
|
||||
if host == "" {
|
||||
return ErrMCPSignInNoHost
|
||||
}
|
||||
if plugin.SignInHostRemembered(stateDir, host) {
|
||||
return nil
|
||||
}
|
||||
subject := server + " → " + host
|
||||
if c.approval.preApprovedForDecision(MCPSignInApprovalTool, subject, nil, true) {
|
||||
return nil
|
||||
}
|
||||
args, _ := json.Marshal(map[string]string{"server": server, "host": host})
|
||||
reply, err := c.requestApprovalDecision(ctx, approvalRequest{tool: MCPSignInApprovalTool, subject: subject, args: args,
|
||||
reason: fmt.Sprintf(i18n.M.MCPSignInReasonFmt, server, host), fresh: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !reply.allow {
|
||||
return ErrMCPSignInDeclined
|
||||
}
|
||||
if reply.session || reply.persist {
|
||||
c.approval.grantSession(MCPSignInApprovalTool, subject)
|
||||
}
|
||||
if reply.persist {
|
||||
c.emitRememberResult(rememberMCPSignInHost(server, host, stateDir))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rememberMCPSignInHost(server, host, stateDir string) RememberResult {
|
||||
rule := "mcp sign-in " + server + " += " + host
|
||||
if err := plugin.RememberSignInHost(stateDir, host); err != nil {
|
||||
return RememberResult{Rule: rule, Err: err}
|
||||
}
|
||||
return RememberResult{Rule: rule, Saved: true}
|
||||
}
|
||||
|
||||
func (m *mcpManager) beginSignIn(name string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.signingIn[name] {
|
||||
return false
|
||||
}
|
||||
if m.signingIn == nil {
|
||||
m.signingIn = map[string]bool{}
|
||||
}
|
||||
m.signingIn[name] = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *mcpManager) endSignIn(name string) {
|
||||
m.mu.Lock()
|
||||
delete(m.signingIn, name)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/ext/plugin"
|
||||
)
|
||||
|
||||
func fakeOAuthMCP(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/mcp":
|
||||
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata=%q`, srv.URL+"/.well-known/oauth-protected-resource"))
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
case "/.well-known/oauth-protected-resource":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"resource": srv.URL + "/mcp", "authorization_servers": []string{srv.URL}})
|
||||
case "/.well-known/oauth-authorization-server":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": srv.URL, "authorization_endpoint": srv.URL + "/authorize", "token_endpoint": srv.URL + "/token",
|
||||
"registration_endpoint": srv.URL + "/register", "code_challenge_methods_supported": []string{"S256"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic"},
|
||||
})
|
||||
case "/register":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"client_id": "c", "client_secret": "s", "token_endpoint_auth_method": "client_secret_basic"})
|
||||
case "/token":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "a", "refresh_token": "r", "token_type": "Bearer", "expires_in": 3600})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func signInController(t *testing.T, serverURL string) (*Controller, chan event.Approval) {
|
||||
t.Helper()
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
body := "[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"" + serverURL + "/mcp\"\n"
|
||||
if err := os.WriteFile(config.UserConfigPath(), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requests := make(chan event.Approval, 4)
|
||||
root := testenv.TempDir(t)
|
||||
c := New(Options{WorkspaceRoot: root, MCPConfigureSpec: func(s *plugin.Spec) {
|
||||
s.StateDir = plugin.MCPStateDir(home, root, s.Name)
|
||||
}, Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.ApprovalRequest {
|
||||
requests <- e.Approval
|
||||
}
|
||||
})})
|
||||
t.Cleanup(c.Close)
|
||||
return c, requests
|
||||
}
|
||||
|
||||
func browserThatApproves(opened *atomic.Int32) func(string) error {
|
||||
return func(raw string) error {
|
||||
opened.Add(1)
|
||||
authURL, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := authURL.Query()
|
||||
callback, err := url.Parse(query.Get("redirect_uri"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values := callback.Query()
|
||||
values.Set("code", "code")
|
||||
values.Set("state", query.Get("state"))
|
||||
callback.RawQuery = values.Encode()
|
||||
go func() {
|
||||
if resp, err := http.Get(callback.String()); err == nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type signInAnswer struct {
|
||||
res MCPSignInResult
|
||||
err error
|
||||
}
|
||||
|
||||
func signIn(c *Controller, open func(string) error) chan signInAnswer {
|
||||
done := make(chan signInAnswer, 1)
|
||||
go func() {
|
||||
res, err := c.authorizeMCPServer(context.Background(), "figma", open)
|
||||
done <- signInAnswer{res, err}
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
func settle(t *testing.T, done chan signInAnswer) signInAnswer {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-done:
|
||||
return got
|
||||
case <-time.After(testenv.Budget(t)):
|
||||
t.Fatal("sign-in did not finish")
|
||||
return signInAnswer{}
|
||||
}
|
||||
}
|
||||
|
||||
// The server picked the host, so a person is asked before any browser opens,
|
||||
// whatever the approval mode; the card names the server and the host.
|
||||
func TestMCPSignInAsksBeforeTheBrowserOpensEvenInYolo(t *testing.T) {
|
||||
srv := fakeOAuthMCP(t)
|
||||
c, requests := signInController(t, srv.URL)
|
||||
c.SetAutoApproveTools(true)
|
||||
var opened atomic.Int32
|
||||
done := signIn(c, browserThatApproves(&opened))
|
||||
approval := nextApproval(t, requests)
|
||||
wantHost := plugin.AuthorizationHost(srv.URL)
|
||||
if approval.Tool != MCPSignInApprovalTool || approval.Subject != "figma → "+wantHost || !approval.Fresh || !approval.AllowsPersist {
|
||||
t.Fatalf("approval = %+v", approval)
|
||||
}
|
||||
if opened.Load() != 0 {
|
||||
t.Fatal("the browser opened before the user answered")
|
||||
}
|
||||
c.Approve(approval.ID, true, false, false)
|
||||
got := settle(t, done)
|
||||
if got.err != nil || got.res.Host != wantHost || opened.Load() != 1 {
|
||||
t.Fatalf("approved sign-in = %+v opened=%d", got, opened.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPSignInDeclinedOpensNothingAndStoresNoToken(t *testing.T) {
|
||||
srv := fakeOAuthMCP(t)
|
||||
c, requests := signInController(t, srv.URL)
|
||||
var opened atomic.Int32
|
||||
done := signIn(c, browserThatApproves(&opened))
|
||||
c.Approve(nextApproval(t, requests).ID, false, false, false)
|
||||
got := settle(t, done)
|
||||
if !errors.Is(got.err, ErrMCPSignInDeclined) || opened.Load() != 0 {
|
||||
t.Fatalf("declined = %+v opened=%d", got, opened.Load())
|
||||
}
|
||||
entry, _ := c.configuredMCPServer("figma")
|
||||
if _, err := os.Stat(filepath.Join(c.mcpSpec(entry).StateDir, "oauth.json")); err == nil {
|
||||
t.Fatal("a declined sign-in left a token behind")
|
||||
}
|
||||
}
|
||||
|
||||
// "Do not ask again" belongs to one server and one host: the same server is not
|
||||
// asked again, and removing the server's sign-in state takes the agreement with it.
|
||||
func TestMCPSignInRememberedHostSkipsTheCardUntilTheStateIsCleared(t *testing.T) {
|
||||
srv := fakeOAuthMCP(t)
|
||||
c, requests := signInController(t, srv.URL)
|
||||
var opened atomic.Int32
|
||||
done := signIn(c, browserThatApproves(&opened))
|
||||
c.Approve(nextApproval(t, requests).ID, true, false, true)
|
||||
if got := settle(t, done); got.err != nil {
|
||||
t.Fatal(got.err)
|
||||
}
|
||||
entry, _ := c.configuredMCPServer("figma")
|
||||
stateDir := c.mcpSpec(entry).StateDir
|
||||
if !plugin.SignInHostRemembered(stateDir, plugin.AuthorizationHost(srv.URL)) {
|
||||
t.Fatal("always allow did not record the host")
|
||||
}
|
||||
if got := settle(t, signIn(c, browserThatApproves(&opened))); got.err != nil || opened.Load() != 2 {
|
||||
t.Fatalf("second sign-in = %+v opened=%d", got, opened.Load())
|
||||
}
|
||||
select {
|
||||
case a := <-requests:
|
||||
t.Fatalf("a remembered host was asked again: %+v", a)
|
||||
default:
|
||||
}
|
||||
if _, err := plugin.ReconcileHTTPMCPOAuthAfterRemoval(plugin.Spec{Name: "figma", StateDir: stateDir}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plugin.SignInHostRemembered(stateDir, plugin.AuthorizationHost(srv.URL)) {
|
||||
t.Fatal("clearing the sign-in state kept the agreement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPSignInRefusalsNeedNoCard(t *testing.T) {
|
||||
srv := fakeOAuthMCP(t)
|
||||
c, requests := signInController(t, srv.URL)
|
||||
if _, err := c.authorizeMCPServer(context.Background(), "nope", func(string) error { return nil }); err == nil {
|
||||
t.Fatal("an unconfigured server signed in")
|
||||
}
|
||||
if _, err := c.authorizeMCPServer(context.Background(), "figma", nil); !errors.Is(err, ErrMCPSignInNoBrowser) {
|
||||
t.Fatalf("no browser: %v", err)
|
||||
}
|
||||
if !c.mcp.beginSignIn("figma") {
|
||||
t.Fatal("first claim refused")
|
||||
}
|
||||
if _, err := c.authorizeMCPServer(context.Background(), "figma", func(string) error { return nil }); !errors.Is(err, ErrMCPSignInBusy) {
|
||||
t.Fatalf("second sign-in while one is open: %v", err)
|
||||
}
|
||||
c.mcp.endSignIn("figma")
|
||||
select {
|
||||
case a := <-requests:
|
||||
t.Fatalf("a refusal raised a card: %+v", a)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPSignInIneligibleServerIsRefused(t *testing.T) {
|
||||
srv := fakeOAuthMCP(t)
|
||||
c, _ := signInController(t, srv.URL)
|
||||
body := "[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"" + srv.URL + "/mcp\"\n[plugins.headers]\nAuthorization = \"Bearer x\"\n"
|
||||
if err := os.WriteFile(config.UserConfigPath(), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.authorizeMCPServer(context.Background(), "figma", func(string) error { return nil }); !errors.Is(err, ErrMCPSignInIneligible) {
|
||||
t.Fatalf("configured credentials: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -364,6 +364,14 @@ type Status interface {
|
||||
BrowserClose(ctx context.Context, tabID string) error
|
||||
}
|
||||
|
||||
// MCPSignIn signs in to an MCP server that needs OAuth. The frontend supplies
|
||||
// how a browser opens; the controller decides whether it may, after the user has
|
||||
// seen which host they are sent to. An editor integration has no browser of its
|
||||
// own to offer, so only the full session port carries it.
|
||||
type MCPSignIn interface {
|
||||
AuthorizeMCPServer(ctx context.Context, name string, open func(url string) error) (MCPSignInResult, error)
|
||||
}
|
||||
|
||||
// BackgroundJobs stops work a session left running outside any turn.
|
||||
type BackgroundJobs interface {
|
||||
CancelJob(id string) bool
|
||||
@@ -503,6 +511,7 @@ type SessionAPI interface {
|
||||
BackgroundJobs
|
||||
ToolCallStopper
|
||||
PeerMail
|
||||
MCPSignIn
|
||||
LocalShell
|
||||
DiffRendering
|
||||
Feedback
|
||||
@@ -540,6 +549,7 @@ var (
|
||||
_ BackgroundJobs = (*Controller)(nil)
|
||||
_ ToolCallStopper = (*Controller)(nil)
|
||||
_ PeerMail = (*Controller)(nil)
|
||||
_ MCPSignIn = (*Controller)(nil)
|
||||
_ SessionHistory = (*Controller)(nil)
|
||||
_ MemoryControl = (*Controller)(nil)
|
||||
_ SlashDispatch = (*Controller)(nil)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
- MCP 登录:打开浏览器之前先告诉你服务器要把你带去哪个主机,批准后才打开;可选“始终允许”,只对这个服务器和这个主机生效。关联 #12357。
|
||||
- MCP sign-in names the host a server sends you to and asks before the browser opens; "always" is remembered per server and host only. Refs #12357.
|
||||
Reference in new issue
Block a user