diff --git a/controllers/api/mcp_view.go b/controllers/api/mcp_view.go new file mode 100644 index 0000000..4df915d --- /dev/null +++ b/controllers/api/mcp_view.go @@ -0,0 +1,20 @@ +package api + +import ( + "net/http" +) + +// viaMCP reports whether r is an MCP tool call. +func viaMCP(r *http.Request) bool { return false } + +// includeBody reports whether r asks for the material body. +func includeBody(r *http.Request) bool { return false } + +// materialView is the answer of GET /materials/{id} for r. +func materialView(r *http.Request, d materialDetailJSON) any { return d } + +// managedMaterialView is a managed material for r. +func managedMaterialView(r *http.Request, m managedMaterialJSON, write bool) any { return m } + +// managedMaterialDetailView is a managed material with its files for r. +func managedMaterialDetailView(r *http.Request, d managedMaterialDetailJSON, write bool) any { return d } diff --git a/controllers/api/mcp_view_test.go b/controllers/api/mcp_view_test.go new file mode 100644 index 0000000..46872cf --- /dev/null +++ b/controllers/api/mcp_view_test.go @@ -0,0 +1,166 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.golem15.com/golem15/summercms/modules/bouncer" +) + +// mcpRequest is a request as pier dispatches it in-process: the api_token +// marker with a principal on its context. +func mcpRequest(method, target string) *http.Request { + r := httptest.NewRequest(method, target, nil) + return r.WithContext(bouncer.WithDispatch(r.Context(), &bouncer.Dispatch{ + Guards: []string{"api_token"}, + Principal: &bouncer.Principal{ID: 7}, + })) +} + +// keysOf marshals v and returns its top-level JSON object. +func keysOf(t *testing.T, v any) map[string]any { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + return out +} + +func TestViaMCP(t *testing.T) { + plain := httptest.NewRequest(http.MethodGet, "/", nil) + if viaMCP(plain) { + t.Error("a plain request is an MCP call") + } + if !viaMCP(mcpRequest(http.MethodGet, "/")) { + t.Error("a dispatched api_token request is not an MCP call") + } + backend := httptest.NewRequest(http.MethodGet, "/", nil) + backend = backend.WithContext(bouncer.WithDispatch(backend.Context(), &bouncer.Dispatch{ + Guards: []string{"backend"}, + Principal: &bouncer.Principal{ID: 7}, + })) + if viaMCP(backend) { + t.Error("a backend dispatch is an MCP call of jz.bm") + } + for target, want := range map[string]bool{ + "/": false, + "/?include_body=false": false, + "/?include_body=0": false, + "/?include_body=true": true, + "/?include_body=1": true, + } { + if got := includeBody(httptest.NewRequest(http.MethodGet, target, nil)); got != want { + t.Errorf("includeBody(%s) = %v, want %v", target, got, want) + } + } +} + +func TestMCPMaterialView(t *testing.T) { + body := "transcript text" + url := "https://x.test/a" + detail := materialDetailJSON{ + materialListJSON: materialListJSON{ID: 3, Title: "T", Type: "notatka", URL: &url, Tags: []tagJSON{}}, + Body: &body, + Files: []fileJSON{{ID: 9, URL: "/storage/f.pdf", Name: "f.pdf", Size: 10, Mime: "application/pdf"}}, + } + + t.Run("plain request unchanged", func(t *testing.T) { + got := keysOf(t, materialView(httptest.NewRequest(http.MethodGet, "/", nil), detail)) + want := keysOf(t, detail) + gotRaw, _ := json.Marshal(got) + wantRaw, _ := json.Marshal(want) + if string(gotRaw) != string(wantRaw) { + t.Errorf("plain view = %s, want %s", gotRaw, wantRaw) + } + }) + + t.Run("MCP without include_body", func(t *testing.T) { + got := keysOf(t, materialView(mcpRequest(http.MethodGet, "/"), detail)) + if _, ok := got["body"]; ok { + t.Errorf("body present: %v", got) + } + if got["body_omitted"] != true { + t.Errorf("body_omitted = %v, want true", got["body_omitted"]) + } + files, _ := got["files"].([]any) + if len(files) != 1 { + t.Fatalf("files = %v", got["files"]) + } + f := files[0].(map[string]any) + for _, k := range []string{"id", "name", "size", "mime", "url"} { + if _, ok := f[k]; !ok { + t.Errorf("file lacks %s: %v", k, f) + } + } + if got["title"] != "T" || got["url"] != url { + t.Errorf("list fields lost: %v", got) + } + }) + + t.Run("MCP without include_body, empty body", func(t *testing.T) { + empty := detail + empty.Body = nil + got := keysOf(t, materialView(mcpRequest(http.MethodGet, "/"), empty)) + if _, ok := got["body"]; ok { + t.Errorf("body present: %v", got) + } + if _, ok := got["body_omitted"]; ok { + t.Errorf("body_omitted set for an empty body: %v", got) + } + }) + + t.Run("MCP with include_body", func(t *testing.T) { + got := keysOf(t, materialView(mcpRequest(http.MethodGet, "/?include_body=true"), detail)) + if got["body"] != body { + t.Errorf("body = %v, want %q", got["body"], body) + } + if _, ok := got["body_omitted"]; ok { + t.Errorf("body_omitted set with include_body: %v", got) + } + }) + + managed := managedMaterialJSON{ID: 3, Title: "T", Type: "notatka", Body: &body, Tags: []tagJSON{}} + + t.Run("managed plain unchanged", func(t *testing.T) { + got := keysOf(t, managedMaterialView(httptest.NewRequest(http.MethodGet, "/", nil), managed, false)) + if got["body"] != body { + t.Errorf("plain managed body = %v", got["body"]) + } + if _, ok := got["body_omitted"]; ok { + t.Errorf("plain managed has body_omitted: %v", got) + } + }) + + t.Run("managed MCP read", func(t *testing.T) { + got := keysOf(t, managedMaterialView(mcpRequest(http.MethodGet, "/"), managed, false)) + if _, ok := got["body"]; ok || got["body_omitted"] != true { + t.Errorf("managed MCP read = %v, want no body and body_omitted", got) + } + got = keysOf(t, managedMaterialView(mcpRequest(http.MethodGet, "/?include_body=1"), managed, false)) + if got["body"] != body { + t.Errorf("managed MCP read with include_body = %v", got) + } + }) + + t.Run("managed MCP write never echoes the body", func(t *testing.T) { + got := keysOf(t, managedMaterialView(mcpRequest(http.MethodPost, "/?include_body=true"), managed, true)) + if _, ok := got["body"]; ok || got["body_omitted"] != true { + t.Errorf("managed MCP write = %v, want no body and body_omitted", got) + } + d := managedMaterialDetailJSON{managedMaterialJSON: managed, Files: []fileJSON{}} + got = keysOf(t, managedMaterialDetailView(mcpRequest(http.MethodPost, "/?include_body=true"), d, true)) + if _, ok := got["body"]; ok || got["body_omitted"] != true { + t.Errorf("managed MCP write detail = %v, want no body and body_omitted", got) + } + if _, ok := got["files"]; !ok { + t.Errorf("managed MCP write detail lost files: %v", got) + } + }) +} diff --git a/mcp_links.go b/mcp_links.go new file mode 100644 index 0000000..ff52fb3 --- /dev/null +++ b/mcp_links.go @@ -0,0 +1,9 @@ +package bm + +import "net/http" + +// MiddlewareMCPLinks makes the links of an MCP answer absolute. +const MiddlewareMCPLinks = "jz.bm.mcp-links" + +// mcpLinks is not implemented yet. +func (p *Plugin) mcpLinks(next http.Handler) http.Handler { return next } diff --git a/mcp_test.go b/mcp_test.go index fafdba1..28679ec 100644 --- a/mcp_test.go +++ b/mcp_test.go @@ -1,13 +1,19 @@ package bm_test import ( + "bytes" + "context" + "encoding/json" "net/http" + "net/http/httptest" "reflect" "slices" + "strconv" "strings" "testing" "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/pier" "git.golem15.com/golem15/summercms/modules/surf" @@ -47,6 +53,8 @@ var pinnedTools = []string{ "bm_deadlines", "bm_mark_reading_owned", "bm_mark_reading_read", + "bm_material", + "bm_materials", "bm_meetings", "bm_readings", "bm_schedule", @@ -63,6 +71,8 @@ var studentQueryKeys = map[string][]string{ "schedule": {"from", "to"}, "deadlines": {"from", "to"}, "readings": {"course"}, + "materials": {"course", "meeting", "tag", "type", "page", "per_page"}, + "material": {"include_body"}, } // bmTools returns jz.bm's declared tools. @@ -240,3 +250,137 @@ func TestTokenMirrorRoutes(t *testing.T) { } } } + +// dispatched returns r as pier dispatches it in-process: the api_token +// marker with principal id on its context. +func dispatched(r *http.Request, id uint) *http.Request { + return r.WithContext(bouncer.WithDispatch(r.Context(), &bouncer.Dispatch{ + Guards: []string{"api_token"}, + Principal: &bouncer.Principal{ID: id}, + })) +} + +// TestMCPLinks (D-16): under MCP every string under url or *_url that starts +// with a single / becomes absolute; other keys, protocol-relative and +// absolute URLs, error answers and plain HTTP answers are left alone. +func TestMCPLinks(t *testing.T) { + const answer = `{"data":{"url":"/storage/x","files":[{"url":"/storage/y","name":"/n"}],"syllabus_url":"/sylabusy/z.pdf","link":"https://x.test/a","other":"/keep","cdn_url":"//cdn.test/a","abs_url":"https://y.test/b","count":12345678901234567890,"nested":{"items":[{"thumb_url":"/t.png"}]}}}` + mw := (&bm.Plugin{}).Middlewares()[bm.MiddlewareMCPLinks] + if mw == nil { + t.Fatalf("%s is not exported", bm.MiddlewareMCPLinks) + } + handler := func(status int, body string) http.Handler { + return mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + } + + t.Run("plain HTTP untouched", func(t *testing.T) { + rec := httptest.NewRecorder() + handler(http.StatusOK, answer).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Body.String() != answer { + t.Errorf("plain answer = %s, want it unchanged", rec.Body.String()) + } + }) + + t.Run("MCP answer absolute", func(t *testing.T) { + rec := httptest.NewRecorder() + handler(http.StatusOK, answer).ServeHTTP(rec, dispatched(httptest.NewRequest(http.MethodGet, "/", nil), 7)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + if cl := rec.Header().Get("Content-Length"); cl != strconv.Itoa(rec.Body.Len()) { + t.Errorf("Content-Length = %s, body is %d bytes", cl, rec.Body.Len()) + } + dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes())) + dec.UseNumber() + var got struct { + Data map[string]any `json:"data"` + } + if err := dec.Decode(&got); err != nil { + t.Fatalf("decode %s: %v", rec.Body.String(), err) + } + d := got.Data + want := map[string]string{ + "url": "http://example.com/storage/x", + "syllabus_url": "http://example.com/sylabusy/z.pdf", + "link": "https://x.test/a", + "other": "/keep", + "cdn_url": "//cdn.test/a", + "abs_url": "https://y.test/b", + } + for k, v := range want { + if d[k] != v { + t.Errorf("%s = %v, want %s", k, d[k], v) + } + } + file := d["files"].([]any)[0].(map[string]any) + if file["url"] != "http://example.com/storage/y" || file["name"] != "/n" { + t.Errorf("files[0] = %v", file) + } + thumb := d["nested"].(map[string]any)["items"].([]any)[0].(map[string]any) + if thumb["thumb_url"] != "http://example.com/t.png" { + t.Errorf("nested thumb_url = %v", thumb["thumb_url"]) + } + if n, _ := d["count"].(json.Number); n.String() != "12345678901234567890" { + t.Errorf("count = %v, want the number kept exactly", d["count"]) + } + }) + + t.Run("MCP forwarded scheme", func(t *testing.T) { + rec := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Host = "bm.example.test" + r.Header.Set("X-Forwarded-Proto", "https") + handler(http.StatusOK, `{"url":"/a"}`).ServeHTTP(rec, dispatched(r, 7)) + if !strings.Contains(rec.Body.String(), `"https://bm.example.test/a"`) { + t.Errorf("answer = %s", rec.Body.String()) + } + }) + + t.Run("MCP error untouched", func(t *testing.T) { + const notFound = `{"error":"not_found","url":"/x"}` + rec := httptest.NewRecorder() + handler(http.StatusNotFound, notFound).ServeHTTP(rec, dispatched(httptest.NewRequest(http.MethodGet, "/", nil), 7)) + if rec.Code != http.StatusNotFound || rec.Body.String() != notFound { + t.Errorf("error answer = %d %s", rec.Code, rec.Body.String()) + } + }) +} + +// TestManageBucketKey (research Pitfall 6): a dispatched management call is +// throttled per user, a bearer request per token hash, a bare one per IP; +// the token twins are throttled per user. +func TestManageBucketKey(t *testing.T) { + buckets := (&bm.Plugin{}).Buckets() + manage, ok := buckets["jz.bm.manage"] + if !ok { + t.Fatal("jz.bm.manage is not declared") + } + if manage.Max != 120 { + t.Errorf("jz.bm.manage Max = %d, want 120", manage.Max) + } + if got := manage.Key(dispatched(httptest.NewRequest(http.MethodGet, "/", nil), 7)); got != "jz.bm.manage.user|7" { + t.Errorf("dispatched key = %q, want jz.bm.manage.user|7", got) + } + bearer := httptest.NewRequest(http.MethodGet, "/", nil) + bearer.Header.Set("Authorization", "Bearer usr_secret") + if got := manage.Key(bearer); !strings.HasPrefix(got, "jz.bm.manage.token|") || strings.Contains(got, "usr_secret") { + t.Errorf("bearer key = %q, want the token hash", got) + } + if got := manage.Key(httptest.NewRequest(http.MethodGet, "/", nil)); !strings.HasPrefix(got, "jz.bm.manage.ip|") { + t.Errorf("bare key = %q, want the IP", got) + } + token, ok := buckets["jz.bm.token"] + if !ok { + t.Fatal("jz.bm.token is not declared") + } + r := httptest.NewRequest(http.MethodGet, "/", nil) + r = r.WithContext(bouncer.WithUser(context.Background(), &bouncer.Principal{ID: 7})) + if got := token.Key(r); got != "jz.bm.token|u:7" { + t.Errorf("token twin key = %q, want jz.bm.token|u:7", got) + } +} diff --git a/realtime_access.go b/realtime_access.go index d934edf..c74f8bc 100644 --- a/realtime_access.go +++ b/realtime_access.go @@ -20,9 +20,12 @@ const MiddlewareRealtimeAccess = "jz.bm.realtime-access" // here without importing that plugin. const presentationsOwnerGuard = "presentations.realtime-owner" -// Middlewares exports the realtime access guard. +// Middlewares exports the realtime access guard and the MCP link rewriter. func (p *Plugin) Middlewares() map[string]pact.Middleware { - return map[string]pact.Middleware{MiddlewareRealtimeAccess: p.realtimeAccess} + return map[string]pact.Middleware{ + MiddlewareRealtimeAccess: p.realtimeAccess, + MiddlewareMCPLinks: p.mcpLinks, + } } // realtimeAccess lets a chat-group member through, otherwise, when