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:
yetone committed 2026-10-03 09:37:39 +08:00
1 parent 05bc2ffb92
commit bf0f3e3f73
19 files changed
+2128 -6

No files matched your search

+3
View File
@@ -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.
+1
View File
@@ -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)
}
+1 -1
View File
@@ -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
}
+1
View File
@@ -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)
+1 -1
View File
@@ -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
}
+182
View File
@@ -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()
}
+164
View File
@@ -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)
}
}
}
+10
View File
@@ -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": "值",
+7
View File
@@ -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); }
+77 -1
View File
@@ -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
+45
View File
@@ -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 {
+123
View File
@@ -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, []);
});
}
+78
View File
@@ -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) }
+107
View File
@@ -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")
}
}
+4 -2
View File
@@ -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()))
}
}
}
+22 -1
View File
@@ -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
+853
View File
@@ -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))
}
+206
View File
@@ -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)
}
}
+243
View File
@@ -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(&reg)
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{}
}