library: a remote MCP server signed in once in magpie is used by every agent without signing in again (#615) — a streamable-HTTP server behind OAuth (Neon, Notion, Supabase) had each agent run its own sign-in. The server's editor now has Sign in: magpie finds the authorization server from the server's 401 (RFC 9728 protected-resource metadata, RFC 8414/OIDC metadata, else the 2025-03 /authorize, /token, /register at its origin), registers itself (RFC 7591, a 127.0.0.1 redirect), opens the browser with PKCE S256 and the resource, and keeps the tokens in mcp-signins.json beside its other account secrets (0600, never the keychain). While signed in, the agents are given the gateway's /mcp/<name> for that server, without the Authorization the library has for it; the gateway passes POST (JSON or event stream), the GET stream and DELETE on with the Mcp-Session-Id and magpie's token, renews it 2 minutes before it runs out and once on a 401, one renewal per server at a time, and a sign-in the server won't renew is answered 502 "sign in to it again" (never a 401 the agent would act on). Signing out, a new URL or an SSE server gives the agents the server's own address as before; a rename keeps the sign-in and removing the server drops it. /mcp/ is local-only or LAN-shared with the key, and refuses a request with an Origin or a Host not naming this computer, so a web page can't borrow the sign-in through the browser (form posts, DNS rebinding), and isn't counted as busy or key-limited. WSL agents are given their distro's gateway address. The row says Signed in or Sign-in ran out; zh strings added. Tests: TestSignIn, TestSignInWithoutMetadata, TestSignInPage, TestNothingToSignIn, TestTokenRenewsBeforeItRunsOut (8 calls, 1 renewal), TestRenewRefused, TestRenewRefusedForGood, TestMCPProxy, TestMCPProxyStaysHere, TestMCPProxyNotForWebPages (fails without the Origin/Host check), TestSignedInServerGoesThroughMagpie against a local fake OAuth+MCP server; mcp-signin.test.cjs (webkit, en and zh). Not tried against a real Neon/Notion/Supabase sign-in. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.
This commit is contained in:
1 parent
05bc2ffb92
commit
bf0f3e3f73
19 files changed
+2128
-6
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/<server>: 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()
|
||||
}
|
||||
@@ -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/<server> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "值",
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
@@ -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/<name>, 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) }
|
||||
@@ -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/<name>, 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")
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<name>
|
||||
// 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, `<!doctype html><meta charset="utf-8"><title>Magpie</title><body style="font:15px -apple-system,system-ui,sans-serif;max-width:32em;margin:4em auto;padding:0 1em;color:#222">%s</body>`, html.EscapeString(msg))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, <server>/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{}
|
||||
}
|
||||
Reference in new issue
Block a user