From bf0f3e3f73dc7f5ebcf794a491ae1e24b40b8642 Mon Sep 17 00:00:00 2001 From: yetone Date: Sat, 3 Oct 2026 09:36:04 +0800 Subject: [PATCH] =?UTF-8?q?library:=20a=20remote=20MCP=20server=20signed?= =?UTF-8?q?=20in=20once=20in=20magpie=20is=20used=20by=20every=20agent=20w?= =?UTF-8?q?ithout=20signing=20in=20again=20(#615)=20=E2=80=94=20a=20stream?= =?UTF-8?q?able-HTTP=20server=20behind=20OAuth=20(Neon,=20Notion,=20Supaba?= =?UTF-8?q?se)=20had=20each=20agent=20run=20its=20own=20sign-in.=20The=20s?= =?UTF-8?q?erver's=20editor=20now=20has=20Sign=20in:=20magpie=20finds=20th?= =?UTF-8?q?e=20authorization=20server=20from=20the=20server's=20401=20(RFC?= =?UTF-8?q?=209728=20protected-resource=20metadata,=20RFC=208414/OIDC=20me?= =?UTF-8?q?tadata,=20else=20the=202025-03=20/authorize,=20/token,=20/regis?= =?UTF-8?q?ter=20at=20its=20origin),=20registers=20itself=20(RFC=207591,?= =?UTF-8?q?=20a=20127.0.0.1=20redirect),=20opens=20the=20browser=20with=20?= =?UTF-8?q?PKCE=20S256=20and=20the=20resource,=20and=20keeps=20the=20token?= =?UTF-8?q?s=20in=20mcp-signins.json=20beside=20its=20other=20account=20se?= =?UTF-8?q?crets=20(0600,=20never=20the=20keychain).=20While=20signed=20in?= =?UTF-8?q?,=20the=20agents=20are=20given=20the=20gateway's=20/mcp/?= =?UTF-8?q?=20for=20that=20server,=20without=20the=20Authorization=20the?= =?UTF-8?q?=20library=20has=20for=20it;=20the=20gateway=20passes=20POST=20?= =?UTF-8?q?(JSON=20or=20event=20stream),=20the=20GET=20stream=20and=20DELE?= =?UTF-8?q?TE=20on=20with=20the=20Mcp-Session-Id=20and=20magpie's=20token,?= =?UTF-8?q?=20renews=20it=202=20minutes=20before=20it=20runs=20out=20and?= =?UTF-8?q?=20once=20on=20a=20401,=20one=20renewal=20per=20server=20at=20a?= =?UTF-8?q?=20time,=20and=20a=20sign-in=20the=20server=20won't=20renew=20i?= =?UTF-8?q?s=20answered=20502=20"sign=20in=20to=20it=20again"=20(never=20a?= =?UTF-8?q?=20401=20the=20agent=20would=20act=20on).=20Signing=20out,=20a?= =?UTF-8?q?=20new=20URL=20or=20an=20SSE=20server=20gives=20the=20agents=20?= =?UTF-8?q?the=20server's=20own=20address=20as=20before;=20a=20rename=20ke?= =?UTF-8?q?eps=20the=20sign-in=20and=20removing=20the=20server=20drops=20i?= =?UTF-8?q?t.=20/mcp/=20is=20local-only=20or=20LAN-shared=20with=20the=20k?= =?UTF-8?q?ey,=20and=20refuses=20a=20request=20with=20an=20Origin=20or=20a?= =?UTF-8?q?=20Host=20not=20naming=20this=20computer,=20so=20a=20web=20page?= =?UTF-8?q?=20can't=20borrow=20the=20sign-in=20through=20the=20browser=20(?= =?UTF-8?q?form=20posts,=20DNS=20rebinding),=20and=20isn't=20counted=20as?= =?UTF-8?q?=20busy=20or=20key-limited.=20WSL=20agents=20are=20given=20thei?= =?UTF-8?q?r=20distro's=20gateway=20address.=20The=20row=20says=20Signed?= =?UTF-8?q?=20in=20or=20Sign-in=20ran=20out;=20zh=20strings=20added.=20Tes?= =?UTF-8?q?ts:=20TestSignIn,=20TestSignInWithoutMetadata,=20TestSignInPage?= =?UTF-8?q?,=20TestNothingToSignIn,=20TestTokenRenewsBeforeItRunsOut=20(8?= =?UTF-8?q?=20calls,=201=20renewal),=20TestRenewRefused,=20TestRenewRefuse?= =?UTF-8?q?dForGood,=20TestMCPProxy,=20TestMCPProxyStaysHere,=20TestMCPPro?= =?UTF-8?q?xyNotForWebPages=20(fails=20without=20the=20Origin/Host=20check?= =?UTF-8?q?),=20TestSignedInServerGoesThroughMagpie=20against=20a=20local?= =?UTF-8?q?=20fake=20OAuth+MCP=20server;=20mcp-signin.test.cjs=20(webkit,?= =?UTF-8?q?=20en=20and=20zh).=20Not=20tried=20against=20a=20real=20Neon/No?= =?UTF-8?q?tion/Supabase=20sign-in.=20go=20vet=20./internal/...,=20go=20bu?= =?UTF-8?q?ild=20-tags=20nogui=20(darwin,=20GOOS=3Dlinux),=20GOOS=3Dwindow?= =?UTF-8?q?s=20go=20build,=20go=20test=20-tags=20nogui=20-count=3D1=20./..?= =?UTF-8?q?.=20under=20a=20temp=20HOME=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/agent.go | 3 + internal/agent/wsl.go | 1 + internal/gateway/busy.go | 2 +- internal/gateway/gateway.go | 1 + internal/gateway/key_limit.go | 2 +- internal/gateway/mcp.go | 182 ++++++ internal/gateway/mcp_test.go | 164 +++++ internal/gui/assets/i18n.js | 10 + internal/gui/assets/library.css | 7 + internal/gui/assets/library.js | 78 ++- internal/gui/library.go | 45 ++ internal/gui/tests/mcp-signin.test.cjs | 123 ++++ internal/library/mcp_signin.go | 78 +++ internal/library/mcp_signin_test.go | 107 ++++ internal/library/project_mcp.go | 6 +- internal/library/sync.go | 23 +- internal/mcpauth/mcpauth.go | 853 +++++++++++++++++++++++++ internal/mcpauth/mcpauth_test.go | 206 ++++++ internal/mcpauth/mcpauthtest/fake.go | 243 +++++++ 19 files changed, 2128 insertions(+), 6 deletions(-) create mode 100644 internal/gateway/mcp.go create mode 100644 internal/gateway/mcp_test.go create mode 100644 internal/gui/tests/mcp-signin.test.cjs create mode 100644 internal/library/mcp_signin.go create mode 100644 internal/library/mcp_signin_test.go create mode 100644 internal/mcpauth/mcpauth.go create mode 100644 internal/mcpauth/mcpauth_test.go create mode 100644 internal/mcpauth/mcpauthtest/fake.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index bd3d0fb8..48c9e159 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -126,6 +126,9 @@ type Agent struct { // for this machine's, and while the distro is stopped: opening it // would start it. Home string + // Gateway is the gateway's address as the agent reaches it, a WSL + // distro's own way to it; nil is gateway.URL. + Gateway func() string // Import, for an app that takes magpie only through an import link of // its own, which the user confirms there (Cindy), is that link; the app // has no fields magpie sets. Added says whether it has magpie already. diff --git a/internal/agent/wsl.go b/internal/agent/wsl.go index d9cf5e22..d7d5f697 100644 --- a/internal/agent/wsl.go +++ b/internal/agent/wsl.go @@ -389,6 +389,7 @@ func wslAgent(k wslKind, d distro) *Agent { id := k.id + "@wsl:" + d.Name a := k.in(d.place(id)) a.ID, a.Name, a.Aliases, a.Bin, a.UA, a.WSL = id, k.name+" · WSL "+d.Name, nil, "", nil, d.Name + a.Gateway = d.base if d.Running { a.Home = d.local(d.Home) } diff --git a/internal/gateway/busy.go b/internal/gateway/busy.go index 0cc3ff7c..4ffab459 100644 --- a/internal/gateway/busy.go +++ b/internal/gateway/busy.go @@ -38,7 +38,7 @@ func (s *Server) Busy() Busy { // counted is h with its requests counted for Busy. func (s *Server) counted(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions || strings.HasPrefix(r.URL.Path, "/_magpie/") { + if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions || strings.HasPrefix(r.URL.Path, "/_magpie/") || strings.HasPrefix(r.URL.Path, "/mcp/") { h.ServeHTTP(w, r) return } diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 815bb5dd..4945482f 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -467,6 +467,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /v1/videos/{id}/content", s.videosContent) mux.HandleFunc("GET /videos/{id}/content", s.videosContent) mux.HandleFunc("POST /_magpie/claude-mcp/{token}", s.subscription.mcpCall) + mux.HandleFunc("/mcp/{name}", s.mcpProxy) mux.HandleFunc(CodexPath+"/", s.codexBackend) mux.HandleFunc("GET /v1beta/models", s.geminiModels) mux.HandleFunc("POST /v1beta/models/{call...}", s.gemini) diff --git a/internal/gateway/key_limit.go b/internal/gateway/key_limit.go index f82c998c..85d86391 100644 --- a/internal/gateway/key_limit.go +++ b/internal/gateway/key_limit.go @@ -22,7 +22,7 @@ import ( func keyLimited(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { who := access.Caller(r.Context()) - if !who.Limit.Limited() || r.Method != http.MethodPost || strings.HasPrefix(r.URL.Path, "/_magpie/") || strings.HasSuffix(r.URL.Path, "/count_tokens") { + if !who.Limit.Limited() || r.Method != http.MethodPost || strings.HasPrefix(r.URL.Path, "/_magpie/") || strings.HasPrefix(r.URL.Path, "/mcp/") || strings.HasSuffix(r.URL.Path, "/count_tokens") { next.ServeHTTP(w, r) return } diff --git a/internal/gateway/mcp.go b/internal/gateway/mcp.go new file mode 100644 index 00000000..e2a3d6f3 --- /dev/null +++ b/internal/gateway/mcp.go @@ -0,0 +1,182 @@ +package gateway + +import ( + "bytes" + "errors" + "io" + "log" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/yetone/magpie/internal/mcpauth" + "github.com/yetone/magpie/internal/netproxy" +) + +// mcpClient relays the agents' requests to a remote MCP server magpie is +// signed in to. It has no timeout: a GET's event stream stays open for as +// long as the agent keeps it. +var mcpClient = &http.Client{Transport: mcpTransport()} + +func mcpTransport() http.RoundTripper { + t := http.DefaultTransport.(*http.Transport).Clone() + t.Proxy = netproxy.Func + t.ResponseHeaderTimeout = 2 * time.Minute + return netproxy.Dispatch(t) +} + +// mcpHop are the headers a relay doesn't pass on. +var mcpHop = map[string]bool{ + "Authorization": true, "Host": true, "Connection": true, "Keep-Alive": true, + "Proxy-Authorization": true, "Proxy-Connection": true, "Te": true, "Trailer": true, + "Transfer-Encoding": true, "Upgrade": true, "Content-Length": true, "Accept-Encoding": true, +} + +// mcpProxy is /mcp/: a remote MCP server magpie signed in to once, +// for every agent the Library gives it to (#615). The agent speaks to it as +// to the server itself (streamable HTTP: a POST answered with JSON or an +// event stream, a GET's event stream, a DELETE that ends the session, with +// Mcp-Session-Id), and magpie sends it on with its own token, renewing it +// when it runs out or the server says it has. +func (s *Server) mcpProxy(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if !local(r) && !sharedWith(r) { + http.Error(w, "magpie lends its MCP sign-ins to another machine only when magpie is shared on the local network and the request carries its API key", http.StatusForbidden) + return + } + // a web page can make the browser send a request here too (a form, or + // a hostname of its own pointed at 127.0.0.1), and magpie would carry + // it to the server with the user's sign-in; agents send no Origin and + // name this computer, as the MCP spec has servers check + if r.Header.Get("Origin") != "" || !sharedWith(r) && !loopbackHost(r.Host) { + http.Error(w, "magpie lends its MCP sign-ins to agents, not to web pages", http.StatusForbidden) + return + } + rec, ok := mcpauth.Get(name) + if !ok { + http.Error(w, "magpie isn't signed in to an MCP server called "+name+": sign in to it in magpie's Library", http.StatusNotFound) + return + } + var body []byte + if r.Body != nil { + b, err := io.ReadAll(io.LimitReader(r.Body, 32<<20)) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + body = b + } + target, err := url.Parse(rec.URL) + if err != nil { + http.Error(w, "the server's URL isn't one: "+err.Error(), http.StatusBadGateway) + return + } + if r.URL.RawQuery != "" { + q := target.Query() + for k, v := range r.URL.Query() { + q[k] = v + } + target.RawQuery = q.Encode() + } + token, err := mcpauth.Token(r.Context(), name) + if err != nil { + mcpRefused(w, name, err) + return + } + send := func(token string) (*http.Response, error) { + req, err := http.NewRequestWithContext(r.Context(), r.Method, target.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + for k, v := range r.Header { + if mcpHop[http.CanonicalHeaderKey(k)] { + continue + } + // the gateway's own key, which the caller check put there + if strings.EqualFold(k, "x-api-key") && len(v) == 1 && v[0] == Token { + continue + } + req.Header[k] = v + } + req.Header.Set("Authorization", "Bearer "+token) + if body == nil { + req.ContentLength = 0 + } + return mcpClient.Do(req) + } + resp, err := send(token) + if err == nil && resp.StatusCode == http.StatusUnauthorized { + io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) + resp.Body.Close() + fresh, rerr := mcpauth.Renew(r.Context(), name, token) + if rerr != nil { + mcpRefused(w, name, rerr) + return + } + resp, err = send(fresh) + } + if err != nil { + if r.Context().Err() == nil { + log.Printf("mcp %s: %v", name, err) + } + http.Error(w, "couldn't reach the MCP server "+name+": "+err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + // the agent would start a sign-in of its own, at magpie's address + io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) + mcpRefused(w, name, mcpauth.ErrExpired) + return + } + for k, v := range resp.Header { + if mcpHop[http.CanonicalHeaderKey(k)] || strings.EqualFold(k, "WWW-Authenticate") { + continue + } + w.Header()[k] = v + } + w.WriteHeader(resp.StatusCode) + flusher, _ := w.(http.Flusher) + buf := make([]byte, 32<<10) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + if _, werr := w.Write(buf[:n]); werr != nil { + return + } + if flusher != nil { + flusher.Flush() + } + } + if err != nil { + return + } + } +} + +// mcpRefused answers an agent whose request magpie has no token for. It is +// not a 401, which would set the agent signing in itself. +func mcpRefused(w http.ResponseWriter, name string, err error) { + status := http.StatusBadGateway + msg := err.Error() + if errors.Is(err, mcpauth.ErrExpired) || errors.Is(err, mcpauth.ErrNotSignedIn) { + msg = "magpie's sign-in to the MCP server " + name + " has run out: sign in to it again in magpie's Library" + } + http.Error(w, msg, status) +} + +// loopbackHost: a Host header naming this computer, localhost or a loopback +// address, with or without a port. +func loopbackHost(host string) bool { + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.Trim(host, "[]") + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/gateway/mcp_test.go b/internal/gateway/mcp_test.go new file mode 100644 index 00000000..28c21bcb --- /dev/null +++ b/internal/gateway/mcp_test.go @@ -0,0 +1,164 @@ +package gateway + +import ( + "bufio" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/yetone/magpie/internal/mcpauth" + "github.com/yetone/magpie/internal/mcpauth/mcpauthtest" +) + +// /mcp/ is a remote MCP server magpie signed in to (#615), as the +// agents reach it: a POST's JSON answer and its event stream, a GET's +// stream and a DELETE go to the server with magpie's token in place of the +// gateway's, and the session's headers both ways. A token the server +// refuses is renewed once; one it won't renew is said to need signing in +// again, never with a 401 that would set the agent signing in itself. +func TestMCPProxy(t *testing.T) { + fresh(t) + f := mcpauthtest.New(t) + f.SignIn(t, "neon") + gw := httptest.NewServer(New().Handler()) + defer gw.Close() + call := func(method, body string, hdr ...string) *http.Response { + t.Helper() + req, _ := http.NewRequest(method, gw.URL+"/mcp/neon", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Authorization", "Bearer "+Token) + for i := 0; i+1 < len(hdr); i += 2 { + req.Header.Set(hdr[i], hdr[i+1]) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return resp + } + token, _ := mcpauth.Token(t.Context(), "neon") + + resp := call("POST", `{"jsonrpc":"2.0","id":1,"method":"initialize"}`, "X-Team", "acme", "MCP-Protocol-Version", "2025-06-18") + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 || !strings.Contains(string(b), `"method":"initialize"`) || resp.Header.Get("Mcp-Session-Id") != "sess-1" { + t.Fatalf("initialize: %d %s %v", resp.StatusCode, b, resp.Header) + } + if got := f.Seen.Get("Authorization"); got != "Bearer "+token { + t.Fatalf("the server got Authorization %q, not magpie's token", got) + } + if f.Seen.Get("X-Team") != "acme" || f.Seen.Get("Mcp-Protocol-Version") != "2025-06-18" { + t.Fatalf("headers not passed on: %v", f.Seen) + } + + // a call answered as an event stream comes through as one + resp = call("POST", `{"jsonrpc":"2.0","id":2,"method":"tools/call"}`, "Mcp-Session-Id", "sess-1") + b, _ = io.ReadAll(resp.Body) + resp.Body.Close() + if !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") || !strings.Contains(string(b), "notifications/progress") || !strings.Contains(string(b), `"id":2,"result"`) { + t.Fatalf("tools/call: %v %s", resp.Header, b) + } + + // a GET's stream is passed on as it comes, an event at a time + resp = call("GET", "", "Mcp-Session-Id", "sess-1") + rd := bufio.NewReader(resp.Body) + first, err := rd.ReadString('\n') + if err != nil || first != "id: 1\n" { + t.Fatalf("stream: %q %v", first, err) + } + rest, _ := io.ReadAll(rd) + resp.Body.Close() + if !strings.Contains(string(rest), `"n":2`) { + t.Fatalf("stream ended early: %s", rest) + } + + resp = call("DELETE", "", "Mcp-Session-Id", "sess-1") + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("DELETE: %d", resp.StatusCode) + } + if want := "DELETE sess-1"; f.Calls[len(f.Calls)-1] != want { + t.Fatalf("calls %q", f.Calls) + } + + // the server ends the token early: renewed once, and the call goes through + f.Revoke() + resp = call("POST", `{"jsonrpc":"2.0","id":3,"method":"tools/list"}`) + b, _ = io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 || !strings.Contains(string(b), "tools/list") || f.Refreshed != 1 { + t.Fatalf("after the server refused the token: %d %s, renewed %d", resp.StatusCode, b, f.Refreshed) + } + + // it won't renew it: sign in again, and no 401 for the agent to act on + f.Revoke() + f.ForgetRefresh() + resp = call("POST", `{"jsonrpc":"2.0","id":4,"method":"tools/list"}`) + b, _ = io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusBadGateway || resp.Header.Get("WWW-Authenticate") != "" || !strings.Contains(string(b), "sign in to it again") { + t.Fatalf("a sign-in run out: %d %v %s", resp.StatusCode, resp.Header, b) + } + + // a server magpie isn't signed in to + req, _ := http.NewRequest("POST", gw.URL+"/mcp/notion", strings.NewReader("{}")) + r2, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + r2.Body.Close() + if r2.StatusCode != http.StatusNotFound { + t.Fatalf("unknown server: %d", r2.StatusCode) + } +} + +// Another machine is lent magpie's sign-ins only through the gateway shared +// on the local network, with its key. +func TestMCPProxyStaysHere(t *testing.T) { + fresh(t) + t.Setenv("MAGPIE_ADDR", "0.0.0.0:3425") + f := mcpauthtest.New(t) + f.SignIn(t, "neon") + h := lanGuard(New().Handler()) + r := httptest.NewRequest("POST", "/mcp/neon", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`)) + r.RemoteAddr = "192.168.1.9:5000" + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusForbidden || len(f.Calls) != 0 { + t.Fatalf("another machine got %d, calls %v", w.Code, f.Calls) + } +} + +// A web page can't borrow magpie's sign-ins through the browser: a request +// with an Origin, or one to a hostname of the page's own pointed at +// 127.0.0.1, is refused before it reaches the server. +func TestMCPProxyNotForWebPages(t *testing.T) { + fresh(t) + f := mcpauthtest.New(t) + f.SignIn(t, "neon") + h := New().Handler() + for _, c := range []struct{ host, origin string }{ + {"127.0.0.1:3425", "https://evil.example"}, + {"evil.example:3425", ""}, + } { + r := httptest.NewRequest("POST", "/mcp/neon", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/call"}`)) + r.RemoteAddr = "127.0.0.1:5000" + r.Host = c.host + if c.origin != "" { + r.Header.Set("Origin", c.origin) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusForbidden || len(f.Calls) != 0 { + t.Fatalf("host %s origin %q: got %d, calls %v", c.host, c.origin, w.Code, f.Calls) + } + } + for _, host := range []string{"localhost:3425", "[::1]:3425", "127.0.0.1"} { + if !loopbackHost(host) { + t.Fatalf("%s is this computer", host) + } + } +} diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 804109dd..77c5b4d5 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -859,6 +859,16 @@ const I18N = { "What is left on the account: the vendor tells only this, so Used / Left leaves it as it is": "账户里剩下的金额:供应商只提供这个数,所以「已用 / 剩余」不会改变它", "What is left on the account besides its windows": "账户里除周期额度外的其他余额或积分", "Headers": "请求头", + "Sign-in": "登录", + "For a server that asks you to sign in (OAuth): magpie signs in once, and every agent given it uses that sign-in": "适用于要求登录(OAuth)的服务器:在 magpie 里登录一次,分配到它的每个 agent 都用这次登录", + "Opening the sign-in…": "正在打开登录…", + "Finish signing in in your browser…": "请在浏览器中完成登录…", + "The agents given it use magpie's sign-in": "分配到它的 agent 都用 magpie 的登录", + "Sign-in ran out": "登录已失效", + "Sign in again": "重新登录", + "Open it to sign in again": "打开它重新登录", + "Signed in to {name} — the agents given it use magpie's sign-in": "已登录 {name}——分配到它的 agent 都用 magpie 的登录", + "Signed out of {name} — the agents are given the server's own address again": "已退出 {name}——agent 重新使用服务器自己的地址", "Extra HTTP headers sent to the vendor, applied after auth. For gateways that need a private scheme.": "发送给供应商的额外 HTTP 请求头,在鉴权之后应用。适用于需要私有鉴权方案的网关。", "Header-Name": "请求头名称", "value": "值", diff --git a/internal/gui/assets/library.css b/internal/gui/assets/library.css index 3cb4da14..1694db88 100644 --- a/internal/gui/assets/library.css +++ b/internal/gui/assets/library.css @@ -109,6 +109,13 @@ body.window .lib-head { top: -2px; margin: -2px -16px 0; padding: 8px 16px 4px; .lib-tag.lib-new.link { cursor: pointer; } .lib-tag.lib-new.link:hover { background: color-mix(in srgb, var(--accent) 22%, var(--accent-soft)); } .lib-tag.lib-unchecked { color: var(--faint); font-weight: 400; } +.lib-tag.lib-signed { color: var(--green); background: var(--green-soft); } +.lib-tag.lib-signed::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; } + +/* magpie's sign-in to a remote server, in its editor (#615) */ +.lib-signin-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-height: 28px; } +.lib-signin-line .note { font-size: 12px; color: var(--fg-2); } +.lib-signin-err { margin-top: 6px; font-size: 12px; color: var(--red); line-height: 1.45; overflow-wrap: anywhere; } /* the switch */ .lib-switch { flex: none; position: relative; width: 32px; height: 19px; border-radius: 10px; background: var(--line); transition: background .2s var(--ease); } diff --git a/internal/gui/assets/library.js b/internal/gui/assets/library.js index feaf565b..45e5af45 100644 --- a/internal/gui/assets/library.js +++ b/internal/gui/assets/library.js @@ -1202,7 +1202,10 @@ function serverRow(s, all) { const row = el("div", "row lib-row click"); const who = el("div", "who"); - who.append(el("div", "name mono", s.name)); + const nm = el("div", "name mono", s.name); + if (s.signIn?.dead) nm.append(tag(t("Sign-in ran out"), "warn", t("Open it to sign in again"))); + else if (s.signIn?.signedIn) nm.append(tag(t("Signed in"), "lib-signed", t("The agents given it use magpie's sign-in"))); + who.append(nm); const sub = el("div", "sub mono", serverLine(s)); sub.title = serverLine(s); who.append(sub); @@ -1213,6 +1216,76 @@ return row; } + // magpie's sign-in to a remote server (#615): signed in once here, and + // every agent given the server reaches it through magpie, with it + let mcpSigning = null; // { name, id, state, error } while one is under way + function signInBox(s) { + const box = el("div", "lib-signin"); + const draw = () => { + box.replaceChildren(); + const cur = lib.servers.find((x) => x.name === s.name)?.signIn || {}; + const sg = mcpSigning?.name === s.name ? mcpSigning : null; + const line = el("div", "lib-signin-line"); + if (sg && (sg.state === "starting" || sg.state === "waiting")) { + line.append(el("span", "note", sg.state === "starting" ? t("Opening the sign-in…") : t("Finish signing in in your browser…")), + button(t("Cancel"), "", async () => { + if (sg.id) await api("library/mcp-signin/" + sg.id + "/cancel", {}).catch(() => {}); + mcpSigning = null; + draw(); + })); + } else if (cur.signedIn && !cur.dead) { + line.append(tag(t("Signed in"), "lib-signed"), el("span", "note", t("The agents given it use magpie's sign-in")), el("span", "grow"), button(t("Sign out"), "", signOut)); + } else { + if (cur.dead) line.append(tag(t("Sign-in ran out"), "warn")); + line.append(button(cur.dead ? t("Sign in again") : t("Sign in"), "action", start)); + } + box.append(line); + if (sg?.state === "failed") box.append(el("div", "lib-signin-err", sg.error)); + }; + async function start() { + mcpSigning = { name: s.name, state: "starting" }; + draw(); + try { + const st = await api("library/mcp-signin", { name: s.name }); + if (web && st.url) api("open", { url: st.url }).catch(() => {}); + mcpSigning = { ...st, name: s.name }; + draw(); + follow(st.id); + } catch (e) { + mcpSigning = { name: s.name, state: "failed", error: e.message }; + draw(); + } + } + // followed with the dialog closed too, for the row to say it's signed in + async function follow(id) { + while (mcpSigning?.id === id) { + await new Promise((r) => setTimeout(r, 800)); + let st; + try { st = await api("library/mcp-signin/" + id); } catch { continue; } + if (mcpSigning?.id !== id) return; + if (st.state === "waiting") continue; + if (st.state === "done") { + mcpSigning = null; + await api("library").then(take, () => {}); + status(t("Signed in to {name} — the agents given it use magpie's sign-in", { name: s.name }), "ok"); + render(); + } else mcpSigning = st.state === "canceled" ? null : { ...st, name: s.name }; + draw(); + return; + } + } + async function signOut() { + try { + take(await api("library/mcp-signout", { name: s.name })); + status(t("Signed out of {name} — the agents are given the server's own address again", { name: s.name }), "ok"); + render(); + draw(); + } catch (e) { status(e.message, "err", 6000); } + } + draw(); + return box; + } + function foundServerRow(f) { const s = f.server; const row = el("div", "row lib-row"); @@ -1359,6 +1432,9 @@ url.classList.add("mono"); g.append(...field("URL", url)); g.append(...field(t("Headers"), pairs(d.headers, "Authorization", "Bearer …", (v) => { d.headers = v; }))); + // the server as saved: one being added or turned into another is + // signed in to once it is saved + if (s?.transport === "http" && d.transport === "http") g.append(...field(t("Sign-in"), signInBox(s), t("For a server that asks you to sign in (OAuth): magpie signs in once, and every agent given it uses that sign-in"))); } slot.append(g); // its own icon while it runs as it did; another way of running is another server diff --git a/internal/gui/library.go b/internal/gui/library.go index c2a96fa3..dc0c753b 100644 --- a/internal/gui/library.go +++ b/internal/gui/library.go @@ -10,6 +10,7 @@ import ( "time" "github.com/yetone/magpie/internal/library" + "github.com/yetone/magpie/internal/mcpauth" ) // The problems of the last change to the library stay on the page until the @@ -258,6 +259,50 @@ func libraryRoutes(mux *http.ServeMux, w Windows) { } rw.WriteHeader(http.StatusNoContent) }) + // magpie signs in to a remote server once, for every agent given it (#615) + mux.HandleFunc("POST /api/library/mcp-signin", func(rw http.ResponseWriter, r *http.Request) { + var in struct{ Name string } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Name == "" { + http.Error(rw, "no server", http.StatusBadRequest) + return + } + st, err := library.SignInServer(r.Context(), in.Name) + if err != nil { + fail(rw, err) + return + } + w.OpenURL(st.URL) + writeJSON(rw, st) + }) + mux.HandleFunc("GET /api/library/mcp-signin/{id}", func(rw http.ResponseWriter, r *http.Request) { + st, ok := mcpauth.Progress(r.PathValue("id")) + if !ok { + http.NotFound(rw, r) + return + } + writeJSON(rw, st) + }) + mux.HandleFunc("POST /api/library/mcp-signin/{id}/cancel", func(rw http.ResponseWriter, r *http.Request) { + mcpauth.Cancel(r.PathValue("id")) + rw.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("POST /api/library/mcp-signout", func(rw http.ResponseWriter, r *http.Request) { + var in struct{ Name string } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Name == "" { + http.Error(rw, "no server", http.StatusBadRequest) + return + } + if err := library.SignOutServer(in.Name); err != nil { + fail(rw, err) + return + } + v, err := libraryView(nil) + if err != nil { + fail(rw, err) + return + } + writeJSON(rw, v) + }) // every change answers with the page as it is after it, and what it did mux.HandleFunc("POST /api/library/{what}/{action}", func(rw http.ResponseWriter, r *http.Request) { var in struct { diff --git a/internal/gui/tests/mcp-signin.test.cjs b/internal/gui/tests/mcp-signin.test.cjs new file mode 100644 index 00000000..7a911720 --- /dev/null +++ b/internal/gui/tests/mcp-signin.test.cjs @@ -0,0 +1,123 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// #615: a remote MCP server is signed in to once, in magpie. Its editor has +// a Sign in that follows the sign-in to the end, the row then says Signed +// in, and Sign out puts it back; a server run as a command has none. In +// English and Chinese, and no click moves the page. No backend: the API is +// faked here. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const HOME = "/Users/aimer"; +const agent = (id, name) => ({ id, name, icon: "", skills: `${HOME}/.${id}/skills`, mcp: `${HOME}/.${id}/mcp.json` }); +const base = () => ({ + dir: `${HOME}/.magpie/library`, backups: `${HOME}/.magpie/backups`, home: HOME, + agents: [agent("codex", "Codex"), agent("claude", "Claude Code")], + instructions: { agents: [], sets: [] }, foundServers: [], projects: [], foundSkills: [], skills: [], + servers: [ + { name: "fs", transport: "stdio", command: "npx", args: ["fs-mcp"], agents: ["codex"] }, + { name: "neon", transport: "http", url: "https://mcp.neon.tech/mcp", agents: ["codex", "claude"], signIn: { signedIn: false } }, + ], +}); + +function server(lang, calls) { + const lib = base(); + let polls = 0; + return async (route) => { + const req = route.request(), url = new URL(req.url()); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return route.fulfill({ json: { agents: [], profiles: [], settings: { lang, theme: "light" } } }); + if (url.pathname === "/api/plugins") return route.fulfill({ json: { plugins: [] } }); + if (url.pathname === "/api/library") return route.fulfill({ json: lib }); + if (url.pathname === "/api/library/mcp-signin") { + calls.push("start " + req.postDataJSON().name); + // no url: the test's browser opens no window + return route.fulfill({ json: { id: "s1", server: "neon", state: "waiting" } }); + } + if (url.pathname === "/api/library/mcp-signin/s1") { + if (++polls < 2) return route.fulfill({ json: { id: "s1", server: "neon", state: "waiting" } }); + lib.servers[1].signIn = { signedIn: true, at: Date.now() }; + return route.fulfill({ json: { id: "s1", server: "neon", state: "done" } }); + } + if (url.pathname === "/api/library/mcp-signout") { + calls.push("out " + req.postDataJSON().name); + lib.servers[1].signIn = { signedIn: false }; + return route.fulfill({ json: lib }); + } + if (url.pathname === "/api/usage/quotas") return route.fulfill({ json: [] }); + if (url.pathname === "/api/groups") return route.fulfill({ json: { groups: [] } }); + if (url.pathname === "/api/providers") return route.fulfill({ json: { providers: [], gateway: { running: true } } }); + if (url.pathname.startsWith("/api/")) return route.fulfill({ json: {} }); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + await route.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +const words = { + en: { signIn: "Sign in", signOut: "Sign out", signed: "Signed in", label: "Sign-in", done: "Signed in to neon — the agents given it use magpie's sign-in", out: "Signed out of neon — the agents are given the server's own address again" }, + zh: { signIn: "登录", signOut: "退出登录", signed: "已登录", label: "登录", done: "已登录 neon——分配到它的 agent 都用 magpie 的登录", out: "已退出 neon——agent 重新使用服务器自己的地址" }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(engine + ": an MCP server is signed in to from its editor", async (t) => { + assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit"); + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const errors = []; + t.after(async () => { + if (errors.length) console.log(errors); + await browser.close(); + }); + for (const lang of ["en", "zh"]) { + await t.test(lang, async () => { + const w = words[lang]; + const calls = []; + const ctx = await browser.newContext({ viewport: { width: 980, height: 800 } }); + await ctx.addInitScript(() => { try { localStorage.setItem("magpie.libTab", "mcp"); } catch {} }); + const page = await ctx.newPage(); + page.setDefaultTimeout(5000); + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", server(lang, calls)); + await page.goto("http://magpie.test/"); + await page.locator('button[data-view="library"]').click(); + const row = (name) => page.locator("#view-library .lib-row", { has: page.locator(".name", { hasText: new RegExp("^" + name) }) }); + await row("neon").waitFor(); + assert.equal(await row("neon").locator(".lib-tag").count(), 0, "not signed in: no tag"); + const scrolled = () => page.evaluate(() => [window.scrollY, ...[...document.querySelectorAll("#view-library, #view-library *")].filter((e) => e.scrollTop).map((e) => e.scrollTop)].join(",")); + const before = await scrolled(); + + // a command has nothing to sign in to + await row("fs").click(); + await page.locator("#modal .lib-editor").waitFor(); + assert.equal(await page.locator("#modal .lib-signin").count(), 0); + await page.keyboard.press("Escape"); + await page.locator("#modal .lib-editor").waitFor({ state: "detached" }); + + await row("neon").click(); + const box = page.locator("#modal .lib-signin"); + await box.waitFor(); + assert.equal(await page.locator("#modal label", { hasText: new RegExp("^" + w.label + "$") }).count(), 1); + await box.getByRole("button", { name: w.signIn, exact: true }).click(); + await page.waitForFunction((want) => document.querySelector("#status").textContent === want, w.done); + assert.deepEqual(calls, ["start neon"]); + await box.locator(".lib-tag", { hasText: w.signed }).waitFor(); + await row("neon").locator(".lib-tag", { hasText: w.signed }).waitFor(); + + await box.getByRole("button", { name: w.signOut, exact: true }).click(); + await page.waitForFunction((want) => document.querySelector("#status").textContent === want, w.out); + await box.getByRole("button", { name: w.signIn, exact: true }).waitFor(); + assert.deepEqual(calls, ["start neon", "out neon"]); + await page.keyboard.press("Escape"); + await page.locator("#modal .lib-editor").waitFor({ state: "detached" }); + assert.equal(await row("neon").locator(".lib-tag").count(), 0, "signed out: no tag"); + assert.equal(await scrolled(), before, "no click moved the page"); + await ctx.close(); + }); + } + assert.deepEqual(errors, []); + }); +} diff --git a/internal/library/mcp_signin.go b/internal/library/mcp_signin.go new file mode 100644 index 00000000..f778d728 --- /dev/null +++ b/internal/library/mcp_signin.go @@ -0,0 +1,78 @@ +package library + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + + "github.com/yetone/magpie/internal/gateway" + "github.com/yetone/magpie/internal/mcpauth" +) + +// A remote server that asks for an OAuth sign-in is signed in to once, in +// magpie, and every agent given it reaches it through the gateway's +// /mcp/, which adds magpie's token (#615): the agents are given that +// address in place of the server's. A server magpie isn't signed in to is +// given as it is, and the agent signs in itself as before. + +func init() { + mcpauth.Changed = func(string) { _, _ = Sync() } +} + +// through is the server as an agent that reaches the gateway at base is +// given it. Only a streamable HTTP server is relayed: an SSE one's stream +// names the address to post to, which would have to be rewritten. +func through(s *Server, base string) *Server { + if s.Transport != "http" || !mcpauth.SignedIn(s.Name, s.URL) { + return s + } + c := *s + c.URL = strings.TrimRight(base, "/") + "/mcp/" + url.PathEscape(s.Name) + c.Headers = nil + for k, v := range s.Headers { + // magpie's token goes in its place + if strings.EqualFold(k, "Authorization") { + continue + } + if c.Headers == nil { + c.Headers = map[string]string{} + } + c.Headers[k] = v + } + return &c +} + +// gatewayOf is the gateway's address as the target's agent reaches it. +func gatewayOf(t *Target) string { + if t.Agent != nil && t.Agent.Gateway != nil { + return t.Agent.Gateway() + } + return gateway.URL() +} + +var errNotHTTP = errors.New("magpie signs in only to a server reached over streamable HTTP") + +// SignInServer starts signing magpie in to the library's server called +// name: the State's URL is the page to open in a browser. +func SignInServer(ctx context.Context, name string) (mcpauth.State, error) { + mu.Lock() + l, err := load() + mu.Unlock() + if err != nil { + return mcpauth.State{}, err + } + s := l.server(name) + if s == nil { + return mcpauth.State{}, fmt.Errorf("no server called %s", name) + } + if s.Transport != "http" { + return mcpauth.State{}, errNotHTTP + } + return mcpauth.Start(ctx, s.Name, s.URL) +} + +// SignOutServer forgets magpie's sign-in to the server called name, and +// gives the agents the server's own address again. +func SignOutServer(name string) error { return mcpauth.SignOut(name) } diff --git a/internal/library/mcp_signin_test.go b/internal/library/mcp_signin_test.go new file mode 100644 index 00000000..686db3e8 --- /dev/null +++ b/internal/library/mcp_signin_test.go @@ -0,0 +1,107 @@ +package library + +import ( + "testing" + + "github.com/yetone/magpie/internal/gateway" + "github.com/yetone/magpie/internal/mcpauth" + "github.com/yetone/magpie/internal/mcpauth/mcpauthtest" +) + +// A remote server magpie signed in to (#615) is given to the agents at the +// gateway's /mcp/, without the Authorization the library has for it, +// and only while magpie is signed in: before, after signing out and for an +// SSE server, the agents are given the server's own address as before. A +// rename keeps the sign-in, and a server taken out of the library loses it. +func TestSignedInServerGoesThroughMagpie(t *testing.T) { + sandbox(t) + f := mcpauthtest.New(t) + agents := []string{"claude", "codex", "cursor"} + neon := Server{Name: "neon", Transport: "http", URL: f.URL, Headers: map[string]string{"Authorization": "Bearer mine", "X-Team": "acme"}, Agents: agents} + ok(t)(SaveServer("", neon)) + sse := Server{Name: "old", Transport: "sse", URL: f.URL, Agents: []string{"claude"}} + ok(t)(SaveServer("", sse)) + given := func(name string) map[string]*Server { + t.Helper() + out := map[string]*Server{} + for _, id := range agents { + got, err := targetByID(id).MCP.read() + if err != nil { + t.Fatal(err) + } + out[id] = got[name] + } + return out + } + for id, s := range given("neon") { + if s == nil || !s.same(&neon) { + t.Fatalf("%s before signing in: %+v", id, s) + } + } + + f.SignIn(t, "neon") // the agents are given magpie's address as it ends + local := gateway.URL() + "/mcp/neon" + for id, s := range given("neon") { + if s == nil || s.URL != local || s.Transport != "http" || s.Headers["Authorization"] != "" || s.Headers["X-Team"] != "acme" { + t.Fatalf("%s signed in: %+v", id, s) + } + } + if s := given("old")["claude"]; s == nil || s.URL != f.URL { + t.Fatalf("an SSE server isn't relayed: %+v", s) + } + v, err := Read(nil) + if err != nil { + t.Fatal(err) + } + for _, sv := range v.Servers { + switch sv.Name { + case "neon": + if sv.SignIn == nil || !sv.SignIn.SignedIn || sv.URL != f.URL { + t.Fatalf("the page's neon: %+v %+v", sv.Server, sv.SignIn) + } + case "old": + if sv.SignIn != nil { + t.Fatal("an SSE server has a sign-in to show") + } + } + } + // a second sync leaves the files as they are + if res := ok(t)(Sync()); len(res.Changed) != 0 { + t.Fatalf("a sync with nothing new changed %v", res.Changed) + } + + renamed := neon + renamed.Name = "neon2" + ok(t)(SaveServer("neon", renamed)) + for id, s := range given("neon2") { + if s == nil || s.URL != gateway.URL()+"/mcp/neon2" { + t.Fatalf("%s renamed: %+v", id, s) + } + } + + // another URL is another server, which magpie isn't signed in to + moved := renamed + moved.URL = f.URL + "?v=2" + ok(t)(SaveServer("neon2", moved)) + for id, s := range given("neon2") { + if s == nil || s.URL != moved.URL || s.Headers["Authorization"] != "Bearer mine" { + t.Fatalf("%s at another URL: %+v", id, s) + } + } + ok(t)(SaveServer("neon2", renamed)) + + if err := SignOutServer("neon2"); err != nil { + t.Fatal(err) + } + for id, s := range given("neon2") { + if s == nil || !s.same(&renamed) { + t.Fatalf("%s signed out: %+v", id, s) + } + } + + f.SignIn(t, "neon2") + ok(t)(RemoveServer("neon2")) + if _, ok := mcpauth.Get("neon2"); ok { + t.Fatal("a server taken out kept its sign-in") + } +} diff --git a/internal/library/project_mcp.go b/internal/library/project_mcp.go index 8047d922..0ce4c408 100644 --- a/internal/library/project_mcp.go +++ b/internal/library/project_mcp.go @@ -12,6 +12,8 @@ import ( toml "github.com/pelletier/go-toml/v2" "github.com/tidwall/jsonc" + + "github.com/yetone/magpie/internal/gateway" ) // projectMCPFiles is the file, in a project, each agent reads the project's @@ -105,8 +107,8 @@ func (l *Library) syncProjectMCP(p *Project, fail func(what string, err error)) fail("mcp:"+name, err) continue } - if !slices.Contains(want[rel], s) { - want[rel] = append(want[rel], s) + if !slices.ContainsFunc(want[rel], func(x *Server) bool { return x.Name == s.Name }) { + want[rel] = append(want[rel], through(s, gateway.URL())) } } } diff --git a/internal/library/sync.go b/internal/library/sync.go index 6dba8603..920b5788 100644 --- a/internal/library/sync.go +++ b/internal/library/sync.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "slices" + + "github.com/yetone/magpie/internal/mcpauth" ) // Result is what a change did to the agents. @@ -121,10 +123,12 @@ func (l *Library) syncMCP(t *Target, b *backups, res *Result) { mine = append(mine, name) } } + base := gatewayOf(t) for _, s := range l.MCP { if !slices.Contains(s.Agents, id) { continue } + s := through(s, base) if err := t.MCP.supports(s); err != nil { res.fail(id, "mcp:"+s.Name, err) continue @@ -172,6 +176,9 @@ type ServerView struct { Icon string `json:"icon,omitempty"` // Problems are the agents it couldn't be given to, and why Problems map[string]string `json:"problems,omitempty"` + // SignIn is magpie's sign-in to a streamable HTTP server, which the + // agents then use (#615) + SignIn *mcpauth.Status `json:"signIn,omitempty"` } // SkillView is a library skill as the page shows it. @@ -250,6 +257,10 @@ func Read(problems []Problem) (*View, error) { s = &c } sv := ServerView{Server: s, Icon: serverIcon(l, s), Problems: of("mcp:" + s.Name)} + if s.Transport == "http" { + st := mcpauth.StatusOf(s.Name, s.URL) + sv.SignIn = &st + } for _, t := range targets { if t.MCP != nil && slices.Contains(s.Agents, t.Agent.ID) { if err := t.MCP.supports(s); err != nil { @@ -313,6 +324,11 @@ func SaveServer(old string, s Server) (*Result, error) { if err := s.check(); err != nil { return nil, err } + // magpie's sign-in goes with a renamed server, before the agents are + // given it under its new name + if old != "" && old != s.Name { + _ = mcpauth.Rename(old, s.Name) + } return change(func(l *Library) error { if s.Name != old && l.server(s.Name) != nil { return fmt.Errorf("the library already has a server called %s", s.Name) @@ -382,7 +398,7 @@ func EveryServerAgents(agents []string, on bool) (*Result, error) { // RemoveServer takes a server out of the library and out of every agent // magpie gave it to. func RemoveServer(name string) (*Result, error) { - return change(func(l *Library) error { + res, err := change(func(l *Library) error { i := slices.IndexFunc(l.MCP, func(x *Server) bool { return x.Name == name }) if i < 0 { return fmt.Errorf("no server called %s", name) @@ -398,6 +414,11 @@ func RemoveServer(name string) (*Result, error) { } return nil }) + if err == nil { + // its sign-in goes with it + _ = mcpauth.Forget(name) + } + return res, err } // ImportServer takes a server the agents have into the library: the agents diff --git a/internal/mcpauth/mcpauth.go b/internal/mcpauth/mcpauth.go new file mode 100644 index 00000000..f9a4ff9a --- /dev/null +++ b/internal/mcpauth/mcpauth.go @@ -0,0 +1,853 @@ +// Package mcpauth signs magpie in to a remote MCP server that asks for an +// OAuth sign-in (Neon, Notion, Supabase…), once for every agent (#615). +// Each agent would otherwise run the sign-in itself and keep its own token, +// in its own place: magpie signs in, keeps the tokens in mcp-signins.json +// beside its other sign-ins, renews them, and the gateway's /mcp/ +// relays the agents' requests to the server with them (gateway/mcp.go). +// The library then gives the agents that address in place of the server's. +// +// The sign-in is the one the MCP spec lays out (2025-06-18, Authorization): +// the server's 401 names its protected resource metadata (RFC 9728), that +// names the authorization server, whose metadata (RFC 8414) says where to +// register a client (RFC 7591), authorize with PKCE (S256) and get tokens. +// A server of the older spec (2025-03-26) with no resource metadata has its +// authorization server at its own origin. +package mcpauth + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "html" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/yetone/magpie/internal/edit" + "github.com/yetone/magpie/internal/netproxy" + "github.com/yetone/magpie/internal/settings" + "github.com/yetone/magpie/internal/steady" +) + +// Client makes the sign-in's requests, through the proxy magpie's others +// take; tests point it elsewhere. +var Client = &http.Client{Timeout: 30 * time.Second, Transport: proxied()} + +func proxied() http.RoundTripper { + t := http.DefaultTransport.(*http.Transport).Clone() + t.Proxy = netproxy.Func + return netproxy.Dispatch(t) +} + +// Changed is called when a server is signed in or out, for the library to +// give the agents its new address. +var Changed func(name string) + +// renewLead is how long before it runs out a token is renewed. +var renewLead = 2 * time.Minute + +// signInTimeout is how long a sign-in waits for the browser. +var signInTimeout = 10 * time.Minute + +// ErrNotSignedIn is a server magpie holds no sign-in for. +var ErrNotSignedIn = errors.New("magpie isn't signed in to this server") + +// ErrExpired is a sign-in the server no longer takes: it has to be done again. +var ErrExpired = errors.New("magpie's sign-in to this server has run out: sign in again in the Library") + +// Record is magpie's sign-in to one server. +type Record struct { + URL string `json:"url"` // the server's, as the library has it + Resource string `json:"resource,omitempty"` // what the tokens are for (RFC 8707) + Issuer string `json:"issuer,omitempty"` + TokenURL string `json:"tokenURL"` + ClientID string `json:"clientID"` + Secret string `json:"clientSecret,omitempty"` + // AuthMethod is how the client authenticates at the token endpoint: + // none, client_secret_post or client_secret_basic + AuthMethod string `json:"authMethod,omitempty"` + Scope string `json:"scope,omitempty"` + Access string `json:"access"` + Refresh string `json:"refresh,omitempty"` + Expires int64 `json:"expires,omitempty"` // unix ms; 0 when the server didn't say + At int64 `json:"at"` // when it was signed in, unix ms + // Dead is a sign-in the server refused to renew: the page asks for it + // again + Dead bool `json:"dead,omitempty"` +} + +var fileMu sync.Mutex + +func path() string { return filepath.Join(settings.Dir(), "mcp-signins.json") } + +func load() map[string]*Record { + m := map[string]*Record{} + if b, err := os.ReadFile(path()); err == nil { + _ = json.Unmarshal(b, &m) + } + return m +} + +func save(m map[string]*Record) error { + b, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return writePrivate(path(), append(b, '\n')) +} + +// writePrivate replaces a file only the user can read, atomically. +func writePrivate(p string, b []byte) error { + p, err := edit.Target(p) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(p), "."+filepath.Base(p)+".*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(b); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return steady.Rename(tmp.Name(), p) +} + +func update(f func(m map[string]*Record) bool) error { + fileMu.Lock() + defer fileMu.Unlock() + m := load() + if !f(m) { + return nil + } + return save(m) +} + +// Get is the sign-in to the server by that name. +func Get(name string) (Record, bool) { + fileMu.Lock() + defer fileMu.Unlock() + r := load()[name] + if r == nil { + return Record{}, false + } + return *r, true +} + +// SignedIn says whether magpie holds a sign-in to the server by that name +// at that URL: one to give the agents magpie's address for. One the server +// refused to renew still is, so the agents get magpie's word to sign in +// again rather than a sign-in of their own. +func SignedIn(name, serverURL string) bool { + r, ok := Get(name) + return ok && r.URL == serverURL +} + +// Status is a server's sign-in as the page shows it. +type Status struct { + SignedIn bool `json:"signedIn"` + Dead bool `json:"dead,omitempty"` + At int64 `json:"at,omitempty"` +} + +// StatusOf is the sign-in to the server by that name at that URL. +func StatusOf(name, serverURL string) Status { + r, ok := Get(name) + if !ok || r.URL != serverURL { + return Status{} + } + return Status{SignedIn: true, Dead: r.Dead, At: r.At} +} + +// SignOut forgets magpie's sign-in to the server. +func SignOut(name string) error { + had := false + err := update(func(m map[string]*Record) bool { + _, had = m[name] + delete(m, name) + return had + }) + if err == nil && had && Changed != nil { + Changed(name) + } + return err +} + +// Forget forgets the sign-in to a server the library no longer has, which +// gives it to no agent: there is nothing to tell. +func Forget(name string) error { + return update(func(m map[string]*Record) bool { + _, had := m[name] + delete(m, name) + return had + }) +} + +// Rename moves a sign-in to the server's new name. +func Rename(old, name string) error { + return update(func(m map[string]*Record) bool { + r := m[old] + if r == nil || old == name { + return false + } + delete(m, old) + m[name] = r + return true + }) +} + +// ---- tokens ---------------------------------------------------------------- + +// renewing is one renewal per server at a time: a vendor that hands out a +// new refresh token with each renewal takes the old one only once. +var renewing sync.Map // name → *sync.Mutex + +func lockOf(name string) *sync.Mutex { + m, _ := renewing.LoadOrStore(name, &sync.Mutex{}) + return m.(*sync.Mutex) +} + +// Token is the access token for the server by that name, renewed first +// when it runs out within renewLead. +func Token(ctx context.Context, name string) (string, error) { + r, ok := Get(name) + if !ok { + return "", ErrNotSignedIn + } + if r.Dead { + return "", ErrExpired + } + if !due(r) { + return r.Access, nil + } + return renew(ctx, name, func(cur Record) bool { return due(cur) }) +} + +// Renew renews the sign-in the server just refused stale, unless another +// request renewed it meanwhile: then the new token is returned. +func Renew(ctx context.Context, name, stale string) (string, error) { + return renew(ctx, name, func(cur Record) bool { return cur.Access == stale }) +} + +func due(r Record) bool { + return r.Expires != 0 && time.Now().Add(renewLead).UnixMilli() >= r.Expires +} + +func renew(ctx context.Context, name string, still func(Record) bool) (string, error) { + mu := lockOf(name) + mu.Lock() + defer mu.Unlock() + r, ok := Get(name) + if !ok { + return "", ErrNotSignedIn + } + if r.Dead { + return "", ErrExpired + } + if !still(r) { + return r.Access, nil + } + if r.Refresh == "" { + markDead(name, r.Access) + return "", ErrExpired + } + form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {r.Refresh}} + if r.Resource != "" { + form.Set("resource", r.Resource) + } + t, err := tokenRequest(ctx, r, form) + if err != nil { + var te *tokenError + if errors.As(err, &te) && (te.Code == "invalid_grant" || te.Code == "invalid_client" || te.Status == http.StatusUnauthorized) { + markDead(name, r.Access) + return "", ErrExpired + } + return "", err + } + err = update(func(m map[string]*Record) bool { + cur := m[name] + if cur == nil || cur.Access != r.Access { + return false // signed in again, or out, meanwhile + } + cur.Access = t.Access + if t.Refresh != "" { + cur.Refresh = t.Refresh + } + cur.Expires = t.expires() + return true + }) + return t.Access, err +} + +func markDead(name, access string) { + _ = update(func(m map[string]*Record) bool { + cur := m[name] + if cur == nil || cur.Access != access { + return false + } + cur.Dead = true + return true + }) +} + +type tokens struct { + Access string `json:"access_token"` + Refresh string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope"` +} + +func (t tokens) expires() int64 { + if t.ExpiresIn <= 0 { + return 0 + } + return time.Now().Add(time.Duration(t.ExpiresIn) * time.Second).UnixMilli() +} + +type tokenError struct { + Status int + Code string `json:"error"` + Description string `json:"error_description"` +} + +func (e *tokenError) Error() string { + s := fmt.Sprintf("the token endpoint answered %d", e.Status) + if e.Code != "" { + s += ": " + e.Code + } + if e.Description != "" { + s += " (" + e.Description + ")" + } + return s +} + +// tokenRequest posts form to the record's token endpoint, as its client. +func tokenRequest(ctx context.Context, r Record, form url.Values) (tokens, error) { + if r.AuthMethod != "client_secret_basic" { + form.Set("client_id", r.ClientID) + } + if r.AuthMethod == "client_secret_post" && r.Secret != "" { + form.Set("client_secret", r.Secret) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.TokenURL, strings.NewReader(form.Encode())) + if err != nil { + return tokens{}, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + if r.AuthMethod == "client_secret_basic" { + req.SetBasicAuth(url.QueryEscape(r.ClientID), url.QueryEscape(r.Secret)) + } + resp, err := Client.Do(req) + if err != nil { + return tokens{}, err + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode/100 != 2 { + te := &tokenError{Status: resp.StatusCode} + _ = json.Unmarshal(b, te) + return tokens{}, te + } + var t tokens + if err := json.Unmarshal(b, &t); err != nil { + // a few answer as a form, as GitHub's does + if v, perr := url.ParseQuery(string(b)); perr == nil && v.Get("access_token") != "" { + t.Access, t.Refresh, t.Scope = v.Get("access_token"), v.Get("refresh_token"), v.Get("scope") + fmt.Sscan(v.Get("expires_in"), &t.ExpiresIn) + return t, nil + } + return tokens{}, fmt.Errorf("the token endpoint's answer isn't JSON: %w", err) + } + if t.Access == "" { + return tokens{}, errors.New("the token endpoint gave no access token") + } + return t, nil +} + +// ---- discovery ------------------------------------------------------------- + +// meta is what discovery found: where the sign-in goes. +type meta struct { + Resource string + Issuer string + AuthorizeURL string + TokenURL string + RegisterURL string + Scope string + AuthMethods []string // token_endpoint_auth_methods_supported +} + +func origin(u *url.URL) string { return u.Scheme + "://" + u.Host } + +// challenge is the server's 401: its resource metadata and the scope it +// asks for, from WWW-Authenticate. +func challenge(h http.Header) (metadata, scope string) { + for _, v := range h.Values("WWW-Authenticate") { + if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(v)), "bearer") { + continue + } + for k, val := range authParams(strings.TrimSpace(v)[len("bearer"):]) { + switch strings.ToLower(k) { + case "resource_metadata": + metadata = val + case "scope": + scope = val + } + } + } + return +} + +// authParams parses a challenge's k=v and k="v" parameters. +func authParams(s string) map[string]string { + out := map[string]string{} + for s = strings.TrimSpace(s); s != ""; { + eq := strings.IndexByte(s, '=') + if eq < 0 { + break + } + k := strings.TrimSpace(strings.TrimLeft(s[:eq], ", ")) + s = strings.TrimSpace(s[eq+1:]) + var v string + if strings.HasPrefix(s, `"`) { + var b strings.Builder + i := 1 + for ; i < len(s) && s[i] != '"'; i++ { + if s[i] == '\\' && i+1 < len(s) { + i++ + } + b.WriteByte(s[i]) + } + v, s = b.String(), s[min(i+1, len(s)):] + } else { + end := strings.IndexByte(s, ',') + if end < 0 { + end = len(s) + } + v, s = strings.TrimSpace(s[:end]), s[end:] + } + out[k] = v + s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), ",")) + } + return out +} + +func getJSON(ctx context.Context, u string, v any) bool { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return false + } + req.Header.Set("Accept", "application/json") + req.Header.Set("MCP-Protocol-Version", protocolVersion) + resp, err := Client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return false + } + return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(v) == nil +} + +const protocolVersion = "2025-06-18" + +// discover finds how to sign in to the server at serverURL. +func discover(ctx context.Context, serverURL string) (meta, error) { + su, err := url.Parse(serverURL) + if err != nil || su.Host == "" { + return meta{}, fmt.Errorf("%q isn't a URL", serverURL) + } + // ask it something, as an agent would: its 401 says where to sign in + body := `{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"` + protocolVersion + `","capabilities":{},"clientInfo":{"name":"magpie","version":"1"}}}` + req, err := http.NewRequestWithContext(ctx, http.MethodPost, serverURL, strings.NewReader(body)) + if err != nil { + return meta{}, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("MCP-Protocol-Version", protocolVersion) + resp, err := Client.Do(req) + if err != nil { + return meta{}, fmt.Errorf("couldn't reach %s: %w", su.Host, err) + } + io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + resp.Body.Close() + if resp.StatusCode/100 == 2 { + return meta{}, errors.New("this server answers without a sign-in: there is nothing to sign in to") + } + metadataURL, scope := challenge(resp.Header) + + var prm struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + Scopes []string `json:"scopes_supported"` + } + var tries []string + if metadataURL != "" { + tries = append(tries, metadataURL) + } + if p := strings.TrimRight(su.Path, "/"); p != "" { + tries = append(tries, origin(su)+"/.well-known/oauth-protected-resource"+p) + } + tries = append(tries, origin(su)+"/.well-known/oauth-protected-resource") + found := false + for _, u := range tries { + if getJSON(ctx, u, &prm) && len(prm.AuthorizationServers) > 0 { + found = true + break + } + } + m := meta{Resource: serverURL, Scope: scope} + issuer := origin(su) + if found { + issuer = strings.TrimRight(prm.AuthorizationServers[0], "/") + if prm.Resource != "" { + m.Resource = prm.Resource + } + if m.Scope == "" && len(prm.Scopes) > 0 { + m.Scope = strings.Join(prm.Scopes, " ") + } + } + var as struct { + Issuer string `json:"issuer"` + Authorize string `json:"authorization_endpoint"` + Token string `json:"token_endpoint"` + Register string `json:"registration_endpoint"` + Challenges []string `json:"code_challenge_methods_supported"` + AuthMethods []string `json:"token_endpoint_auth_methods_supported"` + ScopesSupport []string `json:"scopes_supported"` + } + iu, err := url.Parse(issuer) + if err != nil || iu.Host == "" { + return meta{}, fmt.Errorf("the server names an authorization server that isn't a URL (%q)", issuer) + } + var asTries []string + if p := strings.TrimRight(iu.Path, "/"); p != "" { + asTries = []string{origin(iu) + "/.well-known/oauth-authorization-server" + p, + origin(iu) + "/.well-known/openid-configuration" + p, issuer + "/.well-known/openid-configuration"} + } else { + asTries = []string{issuer + "/.well-known/oauth-authorization-server", issuer + "/.well-known/openid-configuration"} + } + got := false + for _, u := range asTries { + if getJSON(ctx, u, &as) && as.Authorize != "" && as.Token != "" { + got = true + break + } + } + switch { + case got: + if len(as.Challenges) > 0 && !contains(as.Challenges, "S256") { + return meta{}, errors.New("the server's sign-in doesn't offer PKCE (S256), which magpie needs") + } + m.Issuer, m.AuthorizeURL, m.TokenURL, m.RegisterURL, m.AuthMethods = as.Issuer, as.Authorize, as.Token, as.Register, as.AuthMethods + case !found: + // the older spec: the server's origin is its authorization server, + // at these paths when it has no metadata + o := origin(su) + m.Issuer, m.AuthorizeURL, m.TokenURL, m.RegisterURL = o, o+"/authorize", o+"/token", o+"/register" + default: + return meta{}, fmt.Errorf("couldn't read the metadata of %s, the server's authorization server", issuer) + } + if resp.StatusCode != http.StatusUnauthorized && !found && !got { + return meta{}, fmt.Errorf("the server answered %d and says nothing of a sign-in", resp.StatusCode) + } + return m, nil +} + +func contains(l []string, s string) bool { + for _, x := range l { + if x == s { + return true + } + } + return false +} + +// register makes magpie a client of the authorization server, sent back to +// redirect. +func register(ctx context.Context, m meta, redirect string) (id, secret, method string, err error) { + if m.RegisterURL == "" { + return "", "", "", errors.New("the server's sign-in doesn't let an app register itself (no dynamic client registration), so magpie can't sign in to it") + } + method = "none" + if len(m.AuthMethods) > 0 && !contains(m.AuthMethods, "none") { + method = m.AuthMethods[0] + if !contains(m.AuthMethods, "client_secret_post") && !contains(m.AuthMethods, "client_secret_basic") { + return "", "", "", fmt.Errorf("the server's token endpoint takes only %s, which magpie can't do", strings.Join(m.AuthMethods, ", ")) + } + if contains(m.AuthMethods, "client_secret_post") { + method = "client_secret_post" + } + } + reg := map[string]any{ + "client_name": "Magpie", + "client_uri": "https://usemagpie.ai", + "redirect_uris": []string{redirect}, + "grant_types": []string{"authorization_code", "refresh_token"}, + "response_types": []string{"code"}, + "token_endpoint_auth_method": method, + } + if m.Scope != "" { + reg["scope"] = m.Scope + } + b, _ := json.Marshal(reg) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.RegisterURL, bytes.NewReader(b)) + if err != nil { + return "", "", "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := Client.Do(req) + if err != nil { + return "", "", "", err + } + defer resp.Body.Close() + rb, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode/100 != 2 { + return "", "", "", fmt.Errorf("registering magpie with the server's sign-in failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(rb))) + } + var out struct { + ID string `json:"client_id"` + Secret string `json:"client_secret"` + Method string `json:"token_endpoint_auth_method"` + } + if err := json.Unmarshal(rb, &out); err != nil || out.ID == "" { + return "", "", "", errors.New("the server's sign-in registered magpie but gave it no client id") + } + if out.Method != "" { + method = out.Method + } + if out.Secret == "" { + method = "none" + } else if method == "none" { + method = "client_secret_post" + } + return out.ID, out.Secret, method, nil +} + +// ---- the sign-in ------------------------------------------------------------- + +// State is where a sign-in stands, for the page to show. +type State struct { + ID string `json:"id"` + Server string `json:"server"` + URL string `json:"url,omitempty"` // the sign-in page, to open + State string `json:"state"` // waiting, done, failed or canceled + Error string `json:"error,omitempty"` +} + +type flow struct { + mu sync.Mutex + st State + srv *http.Server + done chan struct{} + claimed bool + finished bool +} + +var flows = struct { + sync.Mutex + m map[string]*flow +}{m: map[string]*flow{}} + +func randomToken(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +// Start begins signing magpie in to the server called name at serverURL: +// open the State's URL in a browser and the rest happens on its own; +// StatusOf follows it. +func Start(ctx context.Context, name, serverURL string) (State, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + m, err := discover(ctx, serverURL) + if err != nil { + return State{}, err + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return State{}, err + } + redirect := fmt.Sprintf("http://127.0.0.1:%d/callback", ln.Addr().(*net.TCPAddr).Port) + id, secret, method, err := register(ctx, m, redirect) + if err != nil { + ln.Close() + return State{}, err + } + verifier, state := randomToken(48), randomToken(24) + sum := sha256.Sum256([]byte(verifier)) + q := url.Values{ + "response_type": {"code"}, + "client_id": {id}, + "redirect_uri": {redirect}, + "code_challenge": {base64.RawURLEncoding.EncodeToString(sum[:])}, + "code_challenge_method": {"S256"}, + "state": {state}, + "resource": {m.Resource}, + } + if m.Scope != "" { + q.Set("scope", m.Scope) + } + au, err := url.Parse(m.AuthorizeURL) + if err != nil { + ln.Close() + return State{}, fmt.Errorf("the authorization endpoint isn't a URL: %w", err) + } + aq := au.Query() + for k, v := range q { + aq[k] = v + } + au.RawQuery = aq.Encode() + + f := &flow{done: make(chan struct{}), st: State{ID: randomToken(9), Server: name, URL: au.String(), State: "waiting"}} + rec := Record{URL: serverURL, Resource: m.Resource, Issuer: m.Issuer, TokenURL: m.TokenURL, ClientID: id, Secret: secret, AuthMethod: method, Scope: m.Scope} + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + v := r.URL.Query() + if v.Get("state") != state { + page(w, http.StatusBadRequest, "This sign-in link is out of date. Start the sign-in again from Magpie.") + return + } + f.mu.Lock() + if f.claimed || f.finished { + f.mu.Unlock() + page(w, http.StatusOK, "Magpie has this sign-in already. You can close this tab.") + return + } + f.claimed = true + f.mu.Unlock() + if e := v.Get("error"); e != "" { + msg := e + if d := v.Get("error_description"); d != "" { + msg += ": " + d + } + f.finish(State{State: "failed", Error: "the sign-in was refused (" + msg + ")"}) + page(w, http.StatusOK, "The sign-in was refused: "+msg) + return + } + cctx, ccancel := context.WithTimeout(context.Background(), 30*time.Second) + defer ccancel() + form := url.Values{"grant_type": {"authorization_code"}, "code": {v.Get("code")}, "redirect_uri": {redirect}, "code_verifier": {verifier}} + if rec.Resource != "" { + form.Set("resource", rec.Resource) + } + t, err := tokenRequest(cctx, rec, form) + if err != nil { + f.finish(State{State: "failed", Error: err.Error()}) + page(w, http.StatusOK, "Magpie couldn't finish the sign-in: "+err.Error()) + return + } + r2 := rec + r2.Access, r2.Refresh, r2.Expires, r2.At = t.Access, t.Refresh, t.expires(), time.Now().UnixMilli() + if t.Scope != "" { + r2.Scope = t.Scope + } + if err := update(func(m map[string]*Record) bool { m[name] = &r2; return true }); err != nil { + f.finish(State{State: "failed", Error: err.Error()}) + page(w, http.StatusOK, "Magpie couldn't keep the sign-in: "+err.Error()) + return + } + // the agents are given magpie's address before the sign-in is said done + if Changed != nil { + Changed(name) + } + f.finish(State{State: "done"}) + page(w, http.StatusOK, "Magpie is signed in to "+name+". Every agent given it in the Library uses this sign-in. You can close this tab.") + }) + f.srv = &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} + go f.srv.Serve(ln) + flows.Lock() + flows.m[f.st.ID] = f + flows.Unlock() + go func() { + select { + case <-f.done: + case <-time.After(signInTimeout): + f.finish(State{State: "failed", Error: "the sign-in timed out; start it again"}) + } + }() + return f.status(), nil +} + +func (f *flow) status() State { + f.mu.Lock() + defer f.mu.Unlock() + return f.st +} + +func (f *flow) finish(st State) { + f.mu.Lock() + if f.finished { + f.mu.Unlock() + return + } + f.finished = true + f.st.State, f.st.Error = st.State, st.Error + f.mu.Unlock() + close(f.done) + // the browser still has the page to load + go func() { + time.Sleep(2 * time.Second) + f.srv.Close() + }() + go func() { + time.Sleep(10 * time.Minute) + flows.Lock() + delete(flows.m, f.st.ID) + flows.Unlock() + }() +} + +// Progress is a sign-in Start began. +func Progress(id string) (State, bool) { + flows.Lock() + f := flows.m[id] + flows.Unlock() + if f == nil { + return State{}, false + } + return f.status(), true +} + +// Cancel ends a sign-in Start began. +func Cancel(id string) { + flows.Lock() + f := flows.m[id] + flows.Unlock() + if f != nil { + f.finish(State{State: "canceled"}) + } +} + +func page(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + fmt.Fprintf(w, `Magpie%s`, html.EscapeString(msg)) +} diff --git a/internal/mcpauth/mcpauth_test.go b/internal/mcpauth/mcpauth_test.go new file mode 100644 index 00000000..0259becd --- /dev/null +++ b/internal/mcpauth/mcpauth_test.go @@ -0,0 +1,206 @@ +package mcpauth_test + +import ( + "errors" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + + "github.com/yetone/magpie/internal/mcpauth" + "github.com/yetone/magpie/internal/mcpauth/mcpauthtest" + "github.com/yetone/magpie/internal/settings" +) + +func home(t *testing.T) { + h := t.TempDir() + t.Setenv("HOME", h) + t.Setenv("USERPROFILE", h) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config")) +} + +// Signing in finds the authorization server from the server's 401 (its +// resource metadata, then the server's metadata), registers magpie as a +// client sent back to a loopback address, and trades the code for tokens +// with the PKCE verifier; the tokens are kept where only the user can read +// them. +func TestSignIn(t *testing.T) { + home(t) + f := mcpauthtest.New(t) + if mcpauth.SignedIn("neon", f.URL) { + t.Fatal("signed in before signing in") + } + f.SignIn(t, "neon") + if len(f.Registered) != 1 || f.Exchanged != 1 { + t.Fatalf("registered %d, exchanged %d", len(f.Registered), f.Exchanged) + } + reg := f.Registered[0] + redirect, _ := reg["redirect_uris"].([]any) + if len(redirect) != 1 || !strings.HasPrefix(redirect[0].(string), "http://127.0.0.1:") || !strings.HasSuffix(redirect[0].(string), "/callback") { + t.Errorf("redirect_uris = %v", reg["redirect_uris"]) + } + if reg["token_endpoint_auth_method"] != "none" || reg["client_name"] != "Magpie" { + t.Errorf("registration = %v", reg) + } + r, ok := mcpauth.Get("neon") + if !ok || r.Access == "" || r.Refresh == "" || r.Resource != f.URL || r.ClientID != "client-1" || r.Scope != "read write" || r.Expires == 0 { + t.Fatalf("record = %+v", r) + } + if !mcpauth.SignedIn("neon", f.URL) || mcpauth.SignedIn("neon", f.URL+"/other") || mcpauth.SignedIn("notion", f.URL) { + t.Error("SignedIn is the name and the URL signed in to") + } + if runtime.GOOS != "windows" { + fi, err := os.Stat(filepath.Join(settings.Dir(), "mcp-signins.json")) + if err != nil || fi.Mode().Perm() != 0o600 { + t.Errorf("mcp-signins.json: %v %v", fi.Mode(), err) + } + } + // a rename takes the sign-in along; signing out forgets it + if err := mcpauth.Rename("neon", "neon2"); err != nil || !mcpauth.SignedIn("neon2", f.URL) || mcpauth.SignedIn("neon", f.URL) { + t.Fatal("rename") + } + if err := mcpauth.SignOut("neon2"); err != nil || mcpauth.SignedIn("neon2", f.URL) { + t.Fatal("sign out") + } +} + +// A server of the older spec, with no resource metadata, has its +// authorization server at its own origin, at /authorize, /token and +// /register. +func TestSignInWithoutMetadata(t *testing.T) { + home(t) + f := mcpauthtest.New(t) + f.NoMetadata = true + f.SignIn(t, "old") + if f.Exchanged != 1 { + t.Fatal("no code exchange") + } + r, _ := mcpauth.Get("old") + if r.TokenURL != f.Server.URL+"/token" { + t.Errorf("token endpoint %s", r.TokenURL) + } +} + +// The sign-in page's URL asks for the code with PKCE (S256), the state, the +// resource and the scope the server's 401 named. +func TestSignInPage(t *testing.T) { + home(t) + f := mcpauthtest.New(t) + st, err := mcpauth.Start(t.Context(), "neon", f.URL) + if err != nil { + t.Fatal(err) + } + defer mcpauth.Cancel(st.ID) + u, _ := url.Parse(st.URL) + q := u.Query() + if u.Path != "/oauth/authorize" || q.Get("code_challenge_method") != "S256" || len(q.Get("code_challenge")) != 43 || + q.Get("resource") != f.URL || q.Get("scope") != "read write" || q.Get("client_id") != "client-1" || q.Get("state") == "" { + t.Errorf("sign-in page %s", st.URL) + } + mcpauth.Cancel(st.ID) + if got, _ := mcpauth.Progress(st.ID); got.State != "canceled" { + t.Errorf("state %s after Cancel", got.State) + } +} + +// A server that answers without a sign-in has nothing to sign in to. +func TestNothingToSignIn(t *testing.T) { + home(t) + f := mcpauthtest.New(t) + if _, err := mcpauth.Start(t.Context(), "open", f.Server.URL+"/.well-known/oauth-protected-resource/mcp"); err == nil { + t.Fatal("signed in to a server that asks for none") + } +} + +// A token that runs out within two minutes is renewed before it is used, +// once however many requests find it due at the same time: a vendor that +// takes a refresh token once would refuse the second. +func TestTokenRenewsBeforeItRunsOut(t *testing.T) { + home(t) + f := mcpauthtest.New(t) + f.ExpiresIn = 60 + f.SignIn(t, "neon") + old, _ := mcpauth.Get("neon") + f.ExpiresIn = 3600 + var wg sync.WaitGroup + got := make([]string, 8) + for i := range got { + wg.Add(1) + go func() { + defer wg.Done() + tok, err := mcpauth.Token(t.Context(), "neon") + if err != nil { + t.Error(err) + } + got[i] = tok + }() + } + wg.Wait() + if f.Refreshed != 1 { + t.Fatalf("renewed %d times", f.Refreshed) + } + for _, g := range got { + if g == old.Access || g != got[0] { + t.Fatalf("tokens %v (old %s)", got, old.Access) + } + } + // one that lasts is used as it is + if tok, _ := mcpauth.Token(t.Context(), "neon"); tok != got[0] || f.Refreshed != 1 { + t.Fatal("renewed a token that lasts") + } +} + +// A token the server refused is renewed once, by whichever request saw it +// refused first; the others get the new one. +func TestRenewRefused(t *testing.T) { + home(t) + f := mcpauthtest.New(t) + f.SignIn(t, "neon") + stale, _ := mcpauth.Token(t.Context(), "neon") + f.Revoke() + var wg sync.WaitGroup + for range 5 { + wg.Add(1) + go func() { + defer wg.Done() + tok, err := mcpauth.Renew(t.Context(), "neon", stale) + if err != nil || tok == stale { + t.Error(tok, err) + } + }() + } + wg.Wait() + if f.Refreshed != 1 { + t.Fatalf("renewed %d times", f.Refreshed) + } +} + +// A sign-in the server won't renew has run out: it is said so, and not +// tried again till it is signed in again. +func TestRenewRefusedForGood(t *testing.T) { + home(t) + f := mcpauthtest.New(t) + f.SignIn(t, "neon") + stale, _ := mcpauth.Token(t.Context(), "neon") + f.Revoke() + f.ForgetRefresh() + if _, err := mcpauth.Renew(t.Context(), "neon", stale); !errors.Is(err, mcpauth.ErrExpired) { + t.Fatalf("renewing a sign-in the server forgot: %v", err) + } + if st := mcpauth.StatusOf("neon", f.URL); !st.SignedIn || !st.Dead { + t.Fatalf("status %+v", st) + } + if _, err := mcpauth.Token(t.Context(), "neon"); !errors.Is(err, mcpauth.ErrExpired) { + t.Fatalf("token of a dead sign-in: %v", err) + } + if f.Refreshed != 0 { + t.Fatal("a dead sign-in was renewed") + } + f.SignIn(t, "neon") + if st := mcpauth.StatusOf("neon", f.URL); !st.SignedIn || st.Dead { + t.Fatalf("signed in again: %+v", st) + } +} diff --git a/internal/mcpauth/mcpauthtest/fake.go b/internal/mcpauth/mcpauthtest/fake.go new file mode 100644 index 00000000..4bdcee9f --- /dev/null +++ b/internal/mcpauth/mcpauthtest/fake.go @@ -0,0 +1,243 @@ +// Package mcpauthtest is a remote MCP server behind an OAuth sign-in, as +// Neon's or Notion's is, for the tests of magpie signing in to one (#615). +package mcpauthtest + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/yetone/magpie/internal/mcpauth" +) + +// Fake is the server: its MCP endpoint is URL, which takes only a token it +// gave out and hasn't revoked. +type Fake struct { + *httptest.Server + URL string // the MCP endpoint, /mcp + + mu sync.Mutex + // Registered are the clients registration made, as they asked + Registered []map[string]any + // Exchanged are the code exchanges, Refreshed the renewals asked + Exchanged, Refreshed int + // Calls are the MCP requests that got through: method and JSON-RPC + // method, with the session they named + Calls []string + // Seen are the headers of the last MCP request that got through + Seen http.Header + ExpiresIn int // what a token is said to last, in seconds + // NoMetadata leaves out the resource metadata: the server of the 2025-03 + // spec, whose authorization server is its own origin + NoMetadata bool + + codes map[string]codeGrant + valid map[string]bool // access tokens + refresh map[string]bool + n int +} + +type codeGrant struct{ challenge, client, redirect, resource string } + +// New starts the server. +func New(t *testing.T) *Fake { + f := &Fake{codes: map[string]codeGrant{}, valid: map[string]bool{}, refresh: map[string]bool{}, ExpiresIn: 3600} + mux := http.NewServeMux() + f.Server = httptest.NewServer(mux) + t.Cleanup(f.Server.Close) + f.URL = f.Server.URL + "/mcp" + issuer := f.Server.URL + "/oauth" + mux.HandleFunc("/.well-known/oauth-protected-resource/mcp", func(w http.ResponseWriter, r *http.Request) { + if f.NoMetadata { + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(map[string]any{"resource": f.URL, "authorization_servers": []string{issuer}, "scopes_supported": []string{"read", "write"}}) + }) + mux.HandleFunc("/.well-known/oauth-authorization-server/oauth", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "issuer": issuer, "authorization_endpoint": issuer + "/authorize", "token_endpoint": issuer + "/token", + "registration_endpoint": issuer + "/register", "code_challenge_methods_supported": []string{"S256"}, + "token_endpoint_auth_methods_supported": []string{"none"}, + }) + }) + register := func(w http.ResponseWriter, r *http.Request) { + var reg map[string]any + json.NewDecoder(r.Body).Decode(®) + f.mu.Lock() + f.Registered = append(f.Registered, reg) + id := fmt.Sprintf("client-%d", len(f.Registered)) + f.mu.Unlock() + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"client_id": id, "redirect_uris": reg["redirect_uris"]}) + } + authorize := func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("response_type") != "code" || q.Get("code_challenge_method") != "S256" || q.Get("code_challenge") == "" || q.Get("state") == "" { + http.Error(w, "bad authorize request: "+r.URL.RawQuery, http.StatusBadRequest) + return + } + f.mu.Lock() + f.n++ + code := fmt.Sprintf("code-%d", f.n) + f.codes[code] = codeGrant{q.Get("code_challenge"), q.Get("client_id"), q.Get("redirect_uri"), q.Get("resource")} + f.mu.Unlock() + back, _ := url.Parse(q.Get("redirect_uri")) + bq := back.Query() + bq.Set("code", code) + bq.Set("state", q.Get("state")) + back.RawQuery = bq.Encode() + http.Redirect(w, r, back.String(), http.StatusFound) + } + token := func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + f.mu.Lock() + defer f.mu.Unlock() + fail := func(code string) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": code}) + } + switch r.PostForm.Get("grant_type") { + case "authorization_code": + g, ok := f.codes[r.PostForm.Get("code")] + delete(f.codes, r.PostForm.Get("code")) + sum := sha256.Sum256([]byte(r.PostForm.Get("code_verifier"))) + if !ok || base64.RawURLEncoding.EncodeToString(sum[:]) != g.challenge || r.PostForm.Get("client_id") != g.client || + r.PostForm.Get("redirect_uri") != g.redirect || r.PostForm.Get("resource") != g.resource { + fail("invalid_grant") + return + } + f.Exchanged++ + case "refresh_token": + if !f.refresh[r.PostForm.Get("refresh_token")] { + fail("invalid_grant") + return + } + delete(f.refresh, r.PostForm.Get("refresh_token")) // spent once + f.Refreshed++ + default: + fail("unsupported_grant_type") + return + } + f.n++ + a, rt := fmt.Sprintf("access-%d", f.n), fmt.Sprintf("refresh-%d", f.n) + f.valid[a], f.refresh[rt] = true, true + json.NewEncoder(w).Encode(map[string]any{"access_token": a, "refresh_token": rt, "token_type": "Bearer", "expires_in": f.ExpiresIn}) + } + mux.HandleFunc("/oauth/register", register) + mux.HandleFunc("/oauth/authorize", authorize) + mux.HandleFunc("/oauth/token", token) + // the 2025-03 spec's paths at the origin + mux.HandleFunc("/register", register) + mux.HandleFunc("/authorize", authorize) + mux.HandleFunc("/token", token) + mux.HandleFunc("/mcp", f.mcp) + return f +} + +func (f *Fake) mcp(w http.ResponseWriter, r *http.Request) { + tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + f.mu.Lock() + ok := f.valid[tok] + f.mu.Unlock() + if !ok { + meta := f.Server.URL + "/.well-known/oauth-protected-resource/mcp" + w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token", resource_metadata="`+meta+`", scope="read write"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var msg struct { + ID any `json:"id"` + Method string `json:"method"` + } + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &msg) + f.mu.Lock() + f.Calls = append(f.Calls, r.Method+" "+msg.Method+" "+r.Header.Get("Mcp-Session-Id")) + f.Seen = r.Header.Clone() + f.mu.Unlock() + switch r.Method { + case http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + case http.MethodGet: + w.Header().Set("Content-Type", "text/event-stream") + fl := w.(http.Flusher) + for i := 1; i <= 2; i++ { + fmt.Fprintf(w, "id: %d\nevent: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/ping\",\"params\":{\"n\":%d}}\n\n", i, i) + fl.Flush() + time.Sleep(20 * time.Millisecond) + } + case http.MethodPost: + w.Header().Set("Mcp-Session-Id", "sess-1") + id, _ := json.Marshal(msg.ID) + if msg.Method == "tools/call" { + // answered as a stream: a progress note, then the result + w.Header().Set("Content-Type", "text/event-stream") + fl := w.(http.Flusher) + fmt.Fprintf(w, "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\"}\n\n") + fl.Flush() + fmt.Fprintf(w, "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}}\n\n", id) + fl.Flush() + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"result":{"method":%q}}`, id, msg.Method) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +// Revoke makes every access token given out so far unusable, as a server +// that ended them early would. +func (f *Fake) Revoke() { + f.mu.Lock() + defer f.mu.Unlock() + f.valid = map[string]bool{} +} + +// SignIn signs magpie in to the server as name, standing in for the +// browser: it opens the sign-in page, which sends it back to magpie. +func (f *Fake) SignIn(t *testing.T, name string) mcpauth.State { + t.Helper() + return SignIn(t, name, f.URL) +} + +// SignIn signs magpie in to the server at u as name, standing in for the +// browser. +func SignIn(t *testing.T, name, u string) mcpauth.State { + t.Helper() + st, err := mcpauth.Start(t.Context(), name, u) + if err != nil { + t.Fatal(err) + } + resp, err := http.Get(st.URL) + if err != nil { + t.Fatal(err) + } + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("the sign-in page answered %d: %s", resp.StatusCode, b) + } + got, _ := mcpauth.Progress(st.ID) + if got.State != "done" { + t.Fatalf("sign-in %s: %s (%s)", got.State, got.Error, b) + } + return got +} + +// ForgetRefresh makes every refresh token given out so far unusable. +func (f *Fake) ForgetRefresh() { + f.mu.Lock() + defer f.mu.Unlock() + f.refresh = map[string]bool{} +}