feat(mcp): sign-in asks which host the server sends you to before the browser opens

This commit is contained in:
esengine committed 2026-10-17 01:52:04 -07:00
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",
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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 仅在用户
+3
View File
@@ -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 後自動跳過 — 按任意鍵保留",
},
+7
View File
@@ -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
}
+3
View File
@@ -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
+94
View File
@@ -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")
}
}
+9 -2
View File
@@ -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
}
+38
View File
@@ -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)
}
}
+1
View File
@@ -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)
+83
View File
@@ -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]
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+2
View File
@@ -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.
+154
View File
@@ -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()
}
+226
View File
@@ -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)
}
}
+10
View File
@@ -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.