diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 72a4e00..a715f99 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -69,6 +69,43 @@ This mode is intended for public read-only MCP servers. It must not be used in front of a backend that exposes write or administrative tools unless every publicly callable tool is intentionally allowlisted and tested. +## Optional OAuth Scope-to-Tool ACL + +OAuth is optional for public read-only deployments. Anonymous requests can keep +using the public read-only surface, while agents that present a valid bearer +token may be constrained by a scope-to-tool ACL. + +Configure the ACL with `AUTHENTICATED_SCOPE_TOOLS`: + +```text +AUTHENTICATED_SCOPE_TOOLS=mcp:list_pages|get_page|search_pages|get_recent_posts|list_tags|list_categories|get_sitemap|get_feed|get_site_information +``` + +Semantics: + +- if `AUTHENTICATED_SCOPE_TOOLS` is empty, valid bearer tokens retain the legacy + proxy behavior; +- if a `mcp` mapping is present, valid bearer tokens may call only the listed + `tools/call` names; +- authenticated `tools/list` responses are filtered to advertise only the tools + allowed by the scope; +- protocol setup methods (`initialize`, `notifications/initialized`, `ping`) + remain allowed; +- methods outside that narrow MCP surface are rejected before the backend is + reached. + +For `hugo-public-mcp`, the production candidate model is: + +- anonymous read-only remains available; +- OAuth is optional and does not unlock private tools yet; +- bearer tokens are limited to the same public read-only tools as anonymous + clients until a separate design introduces private scopes. + +Refresh tokens and token revocation are intentionally not part of this model yet. +Short-lived access tokens plus SQLite WAL persistence are sufficient for the +current public read-only staging validation. Add revocation or refresh tokens +only if a future private-tool design requires them. + Important guarantees: - redirect URIs must match the registered allowlist @@ -110,6 +147,7 @@ Common runtime controls: - `MANDATORY_PKCE` - `ANONYMOUS_ENABLED` - `ANONYMOUS_PUBLIC_TOOLS` +- `AUTHENTICATED_SCOPE_TOOLS` ## Security Model diff --git a/docs/HUGO_PUBLIC_MCP_STAGING_OAUTH_REPORT.md b/docs/HUGO_PUBLIC_MCP_STAGING_OAUTH_REPORT.md new file mode 100644 index 0000000..2ca25fb --- /dev/null +++ b/docs/HUGO_PUBLIC_MCP_STAGING_OAUTH_REPORT.md @@ -0,0 +1,200 @@ +# hugo-public-mcp OAuth Staging Hardening Report + +Date: 2026-07-02 + +Scope: `staging-mcp.arleo.eu` only. Production `mcp.arleo.eu` was not moved +behind `mcp-runtime-go`. + +## Auth Model + +The production-candidate model is: + +- anonymous MCP access remains enabled for public read-only tools; +- OAuth is optional; +- OAuth does not unlock private tools in the current design; +- no write tools, admin tools, Hugo rebuild, shell, filesystem, or private Hugo + MCP access are exposed; +- bearer tokens are constrained by a scope-to-tool ACL before traffic reaches the + backend. + +Current staging allowlists: + +```text +ANONYMOUS_PUBLIC_TOOLS=list_pages,get_page,search_pages,get_recent_posts,list_tags,list_categories,get_sitemap,get_feed,get_site_information +AUTHENTICATED_SCOPE_TOOLS=mcp:list_pages|get_page|search_pages|get_recent_posts|list_tags|list_categories|get_sitemap|get_feed|get_site_information +``` + +`TRUSTED_AUTHORIZE_CIDRS` no longer uses `0.0.0.0/0,::/0`. Staging now uses: + +```text +82.65.145.189/32,192.168.1.0/24,127.0.0.1/32,::1/128 +``` + +OpenResty staging also has an explicit `/authorize` location with `allow` for +the operator public IP and LAN, then `deny all`. + +## Comparison + +| Area | `mcp.arleo.eu` production | `staging-mcp.arleo.eu` OAuth staging | +| --- | --- | --- | +| Runtime | `hugo-public-mcp` directly | `mcp-runtime-go` proxying to `hugo-public-mcp` | +| Auth requirement | none | anonymous allowed, OAuth optional | +| OAuth discovery | absent on production | present and valid for staging | +| Public tools | read-only Hugo tools | same read-only tools | +| Private tools | none | none | +| Bearer invalid | not applicable | `401` with `WWW-Authenticate` | +| Bearer valid | not applicable | constrained by `AUTHENTICATED_SCOPE_TOOLS` | +| `/authorize` exposure | not present | restricted to operator/LAN at OpenResty and Go CIDR gate | +| Backend port exposure | production app port remains unchanged | staging app binds `127.0.0.1:8092` only | +| IsItAgentReady role | canonical public MCP | OAuth staging endpoint only | + +## Benefits + +- Adds real OAuth Authorization Code + PKCE discovery without making OAuth + mandatory for public read-only content. +- Keeps public anonymous tools available for agents that do not need tokens. +- Prevents bearer tokens from becoming implicit broad backend access. +- Provides a staging path to test client interoperability before any production + cutover. + +## Risks + +- Dynamic Client Registration is still single-tenant: it returns the configured + client identity rather than creating independent durable clients. +- There is no token revocation endpoint. +- There are no refresh tokens. +- The current public read-only use case does not require private scopes; adding + private tools later needs a separate scope design and tests. +- `/authorize` is intentionally operator/LAN restricted. This is safer for + staging, but a fully public OAuth consent model would need a real user-auth or + consent ceremony before production. + +## Revocation and Refresh Token Decision + +Do not add refresh tokens or revocation for the current public read-only staging +candidate. + +Reasoning: + +- anonymous read-only access remains available without OAuth; +- OAuth tokens currently unlock only the same public read-only tools; +- short-lived access tokens plus SQLite WAL persistence are sufficient for + staging validation; +- adding refresh/revocation before private scopes would increase surface and + operational burden without clear benefit. + +Revisit this only if a future design introduces private scopes or longer-lived +authenticated sessions. + +## Validation Evidence + +Local/runtime: + +```text +systemctl is-active mcp-runtime-staging.service -> active +listener -> 127.0.0.1:8092 +GET http://127.0.0.1:8092/healthz -> OK +GET http://127.0.0.1:8092/readyz -> OK +``` + +Public staging endpoints: + +```text +/.well-known/oauth-authorization-server -> 200 application/json +/.well-known/oauth-protected-resource -> 200 application/json +/auth.md -> 200 text/markdown +/healthz -> 200 text/plain +/readyz -> 200 text/plain +``` + +Security behavior: + +```text +Go direct /authorize with X-Forwarded-For: 203.0.113.10 -> 403 +OAuth DCR + Authorization Code PKCE + token exchange -> OK +Authenticated tools/list -> filtered to read-only ACL +Authenticated get_site_information -> OK +Authenticated publish_post -> 403 before backend +Anonymous tools/list -> filtered to read-only tools +``` + +Leak scan over public staging discovery and health endpoints: + +```text +No /home/jm +No 192.168. +No .git +No token or secret patterns +``` + +Project validation: + +```text +go test ./... -> PASS +go test -race ./... -> PASS +go vet ./... -> PASS +gitleaks detect -> PASS +``` + +`golangci-lint run ./...` is not clean because of existing errcheck/staticcheck +debt in unrelated tests and handlers. The new unused symbol found during this +pass was removed. + +IsItAgentReady staging: + +```text +level: 0 Not Ready +OAuth Discovery: PASS +OAuth Protected Resource: PASS +auth.md: PASS +MCP Server Card: PASS +robots.txt/content-signal: PASS +``` + +The remaining staging failures are expected because `staging-mcp.arleo.eu` is +not the full Hugo content site: + +- sitemap +- DNS-AID +- API Catalog +- Agent Skills +- Markdown negotiation +- A2A +- WebMCP + +## Rollback + +Disable staging OAuth runtime: + +```bash +sudo systemctl disable --now mcp-runtime-staging.service +``` + +Remove staging vhost: + +```bash +sudo rm -f /usr/local/openresty/nginx/conf/sites-enabled/staging-mcp.arleo.eu +sudo openresty -t +sudo systemctl reload openresty +``` + +Restore saved staging backups if needed: + +```bash +sudo cp -a /etc/mcp-runtime-go/mcp-runtime-staging.env.bak-authorize-hardening-YYYYMMDD-HHMMSS /etc/mcp-runtime-go/mcp-runtime-staging.env +sudo cp -a /usr/local/bin/mcp-runtime-staging.bak-YYYYMMDD-HHMMSS /usr/local/bin/mcp-runtime-staging +sudo systemctl restart mcp-runtime-staging.service +``` + +Production rollback is not required for this pass because production was not +changed. + +## Verdict + +GO for a future production-candidate PR that documents and reviews this model. + +NO-GO for direct production cutover today. + +Before production, decide explicitly whether `/authorize` should remain +operator/LAN-only or whether a real public consent/user-auth model is required. +Do not expose OAuth broadly without that decision. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index cad32b6..ebe7da0 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -40,6 +40,7 @@ Common production settings: - `ALLOW_TOKEN_STORE_RECOVERY=false` - `ANONYMOUS_ENABLED=false` - `ANONYMOUS_PUBLIC_TOOLS=` +- `AUTHENTICATED_SCOPE_TOOLS=` Legacy `GRAV_*` variables are supported only as compatibility fallback. @@ -66,6 +67,30 @@ Behavior: Do not enable this mode in front of an administrative MCP backend unless every write-capable tool is excluded from the public allowlist and separately tested. +## Optional OAuth for Public Read-Only MCP + +For a public read-only MCP deployment, OAuth can be enabled without making it +mandatory for anonymous users. + +Recommended production-candidate policy for `hugo-public-mcp`: + +```bash +ANONYMOUS_ENABLED=true +ANONYMOUS_PUBLIC_TOOLS=list_pages,get_page,search_pages,get_recent_posts,list_tags,list_categories,get_sitemap,get_feed,get_site_information +AUTHENTICATED_SCOPE_TOOLS=mcp:list_pages|get_page|search_pages|get_recent_posts|list_tags|list_categories|get_sitemap|get_feed|get_site_information +``` + +Behavior: + +- requests without an `Authorization` header use the anonymous public allowlist; +- requests with a valid bearer token use the `mcp` scope tool ACL; +- requests with an invalid bearer token receive `401` with `WWW-Authenticate`; +- no private or write tools are exposed by this policy. + +This keeps OAuth optional and truthful. It advertises a real Authorization Code ++ PKCE flow and Dynamic Client Registration, but it does not invent private +capabilities or make OAuth a prerequisite for public content access. + ## Systemd The service is expected to run as a hardened unit with: @@ -89,6 +114,10 @@ systemctl cat mcp-runtime - Public traffic flows through Cloudflare and OpenResty. - CrowdSec / OpenResty controls may block at the edge before Go sees the request. - `/authorize` is intentionally restricted to trusted operator IPs. +- Do not use `TRUSTED_AUTHORIZE_CIDRS=0.0.0.0/0,::/0` outside short-lived + interoperability tests. A production candidate should restrict `/authorize` + to explicit operator/admin source ranges until a real public consent model is + designed. - If a request is blocked at the edge, check the OpenResty access/error logs and CrowdSec decisions. ## Health and Metrics diff --git a/internal/config/config.go b/internal/config/config.go index e703f82..51fcd87 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,6 +34,7 @@ type OAuthProxyConfig struct { AllowTokenStoreRecovery bool `env:"ALLOW_TOKEN_STORE_RECOVERY" envDefault:"false"` AnonymousEnabled bool `env:"ANONYMOUS_ENABLED" envDefault:"false"` AnonymousPublicTools []string `env:"ANONYMOUS_PUBLIC_TOOLS" envDefault:""` + AuthenticatedScopeTools string `env:"AUTHENTICATED_SCOPE_TOOLS" envDefault:""` } type RuntimeConfig struct { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3b4d343..6b47a37 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -57,6 +57,22 @@ func TestLoad_AnonymousMCPConfig(t *testing.T) { } } +func TestLoad_AuthenticatedScopeToolsConfig(t *testing.T) { + t.Setenv("CLIENT_ID", "test-client") + t.Setenv("CLIENT_SECRET", "test-secret") + t.Setenv("HUGO_TOKEN", "test-token") + t.Setenv("AUTHENTICATED_SCOPE_TOOLS", "mcp:list_pages|get_page|search_pages") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + if cfg.OAuthProxy.AuthenticatedScopeTools != "mcp:list_pages|get_page|search_pages" { + t.Fatalf("authenticated scope tools = %q", cfg.OAuthProxy.AuthenticatedScopeTools) + } +} + func TestValidate(t *testing.T) { tests := []struct { name string diff --git a/internal/oauthproxy/proxy.go b/internal/oauthproxy/proxy.go index 23c04f5..deee385 100644 --- a/internal/oauthproxy/proxy.go +++ b/internal/oauthproxy/proxy.go @@ -17,7 +17,7 @@ import ( const anonymousInspectionLimitBytes = 1 << 20 -type anonymousToolsListContextKey struct{} +type toolsListFilterContextKey struct{} func appendSubPath(ctx context.Context, subPath string) context.Context { return context.WithValue(ctx, subPathContextKey{}, subPath) @@ -81,11 +81,11 @@ func (s *Service) buildReverseProxy() *httputil.ReverseProxy { }, Transport: s.httpClient.Transport, ModifyResponse: func(resp *http.Response) error { - anonymousToolsList, _ := resp.Request.Context().Value(anonymousToolsListContextKey{}).(bool) - if !anonymousToolsList { + allowedTools, _ := resp.Request.Context().Value(toolsListFilterContextKey{}).([]string) + if len(allowedTools) == 0 { return nil } - return s.filterAnonymousToolsListResponse(resp) + return filterToolsListResponse(resp, allowedTools) }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { s.auditLog(r, "proxy_error", map[string]interface{}{"error": err.Error()}) @@ -108,6 +108,13 @@ func (s *Service) HandleProxy(w http.ResponseWriter, r *http.Request) { s.unauthorized(w, "Invalid or expired token") return } + allowed, toolsList := s.allowAuthenticatedMCPRequest(w, r) + if !allowed { + return + } + if toolsList { + r = r.WithContext(context.WithValue(r.Context(), toolsListFilterContextKey{}, s.authenticatedAllowedToolsForScope(mcpScope))) + } } else if !s.cfg.OAuthProxy.AnonymousEnabled { s.unauthorized(w, "Bearer token required") return @@ -117,7 +124,7 @@ func (s *Service) HandleProxy(w http.ResponseWriter, r *http.Request) { return } if toolsList { - r = r.WithContext(context.WithValue(r.Context(), anonymousToolsListContextKey{}, true)) + r = r.WithContext(context.WithValue(r.Context(), toolsListFilterContextKey{}, s.cfg.OAuthProxy.AnonymousPublicTools)) } } @@ -249,8 +256,120 @@ func (s *Service) anonymousMessageAllowed(raw []byte) (allowed bool, toolsList b } } +func (s *Service) allowAuthenticatedMCPRequest(w http.ResponseWriter, r *http.Request) (allowed bool, toolsList bool) { + allowedTools := s.authenticatedAllowedToolsForScope(mcpScope) + if len(allowedTools) == 0 { + return true, false + } + if r.Method != http.MethodPost { + s.auditLog(r, "authenticated_proxy_rejected", map[string]interface{}{"reason": "method_not_allowed", "method": r.Method}) + http.Error(w, "authenticated scoped MCP requires POST", http.StatusMethodNotAllowed) + return false, false + } + + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, anonymousInspectionLimitBytes)) + if err != nil { + s.auditLog(r, "authenticated_proxy_rejected", map[string]interface{}{"reason": "body_too_large_or_unreadable"}) + http.Error(w, "invalid authenticated MCP request", http.StatusBadRequest) + return false, false + } + r.Body = io.NopCloser(bytes.NewReader(body)) + + allowed, toolsList, reason := payloadAllowedForTools(body, allowedTools) + if !allowed { + s.auditLog(r, "authenticated_proxy_rejected", map[string]interface{}{"reason": reason}) + http.Error(w, "authenticated MCP request is outside token scope", http.StatusForbidden) + return false, false + } + + return true, toolsList +} + +func (s *Service) authenticatedAllowedToolsForScope(scope string) []string { + return toolsForScope(s.cfg.OAuthProxy.AuthenticatedScopeTools, scope) +} + +func toolsForScope(raw, scope string) []string { + for _, entry := range strings.Split(raw, ";") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + scopeName, toolsRaw, ok := strings.Cut(entry, ":") + if !ok { + scopeName, toolsRaw, ok = strings.Cut(entry, "=") + } + if !ok || strings.TrimSpace(scopeName) != scope { + continue + } + var tools []string + for _, tool := range strings.FieldsFunc(toolsRaw, func(r rune) bool { + return r == '|' || r == ',' + }) { + tool = strings.TrimSpace(tool) + if tool != "" { + tools = append(tools, tool) + } + } + return tools + } + return nil +} + +func payloadAllowedForTools(body []byte, allowedTools []string) (allowed bool, toolsList bool, reason string) { + var batch []json.RawMessage + if err := json.Unmarshal(body, &batch); err == nil { + if len(batch) == 0 { + return false, false, "empty_batch" + } + containsToolsList := false + for _, raw := range batch { + ok, isToolsList, reason := messageAllowedForTools(raw, allowedTools) + if !ok { + return false, false, reason + } + containsToolsList = containsToolsList || isToolsList + } + return true, containsToolsList, "" + } + + return messageAllowedForTools(body, allowedTools) +} + +func messageAllowedForTools(raw []byte, allowedTools []string) (allowed bool, toolsList bool, reason string) { + var msg jsonRPCRequest + if err := json.Unmarshal(raw, &msg); err != nil { + return false, false, "invalid_json" + } + + switch msg.Method { + case "initialize", "notifications/initialized", "ping": + return true, false, "" + case "tools/list": + return true, true, "" + case "tools/call": + var params toolCallParams + if err := json.Unmarshal(msg.Params, ¶ms); err != nil { + return false, false, "invalid_tool_call_params" + } + if params.Name == "" { + return false, false, "missing_tool_name" + } + if toolAllowed(params.Name, allowedTools) { + return true, false, "" + } + return false, false, "tool_outside_scope" + default: + return false, false, "method_outside_scope" + } +} + func (s *Service) isAnonymousPublicTool(name string) bool { - for _, tool := range s.cfg.OAuthProxy.AnonymousPublicTools { + return toolAllowed(name, s.cfg.OAuthProxy.AnonymousPublicTools) +} + +func toolAllowed(name string, allowedTools []string) bool { + for _, tool := range allowedTools { if tool == name { return true } @@ -258,7 +377,7 @@ func (s *Service) isAnonymousPublicTool(name string) bool { return false } -func (s *Service) filterAnonymousToolsListResponse(resp *http.Response) error { +func filterToolsListResponse(resp *http.Response, allowedTools []string) error { body, err := io.ReadAll(io.LimitReader(resp.Body, anonymousInspectionLimitBytes+1)) if err != nil { return err @@ -267,20 +386,20 @@ func (s *Service) filterAnonymousToolsListResponse(resp *http.Response) error { return err } if len(body) > anonymousInspectionLimitBytes { - return fmt.Errorf("anonymous tools/list response exceeds inspection limit") + return fmt.Errorf("tools/list response exceeds inspection limit") } - filtered := s.filterAnonymousToolsListBody(body) + filtered := filterToolsListBody(body, allowedTools) resp.Body = io.NopCloser(bytes.NewReader(filtered)) resp.ContentLength = int64(len(filtered)) resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(filtered))) return nil } -func (s *Service) filterAnonymousToolsListBody(body []byte) []byte { +func filterToolsListBody(body []byte, allowedTools []string) []byte { var value interface{} if err := json.Unmarshal(body, &value); err == nil { - s.filterAnonymousToolsListJSON(value) + filterToolsListJSON(value, allowedTools) if filtered, err := json.Marshal(value); err == nil { return filtered } @@ -297,7 +416,7 @@ func (s *Service) filterAnonymousToolsListBody(body []byte) []byte { if err := json.Unmarshal(data, &event); err != nil { continue } - s.filterAnonymousToolsListJSON(event) + filterToolsListJSON(event, allowedTools) filtered, err := json.Marshal(event) if err != nil { continue @@ -307,11 +426,11 @@ func (s *Service) filterAnonymousToolsListBody(body []byte) []byte { return bytes.Join(lines, []byte("\n")) } -func (s *Service) filterAnonymousToolsListJSON(value interface{}) { +func filterToolsListJSON(value interface{}, allowedTools []string) { switch typed := value.(type) { case []interface{}: for _, item := range typed { - s.filterAnonymousToolsListJSON(item) + filterToolsListJSON(item, allowedTools) } case map[string]interface{}: result, ok := typed["result"].(map[string]interface{}) @@ -329,7 +448,7 @@ func (s *Service) filterAnonymousToolsListJSON(value interface{}) { continue } name, ok := toolObj["name"].(string) - if ok && s.isAnonymousPublicTool(name) { + if ok && toolAllowed(name, allowedTools) { filtered = append(filtered, tool) } } diff --git a/internal/oauthproxy/proxy_test.go b/internal/oauthproxy/proxy_test.go index 01b02fd..6795ad2 100644 --- a/internal/oauthproxy/proxy_test.go +++ b/internal/oauthproxy/proxy_test.go @@ -371,6 +371,105 @@ func TestHandleProxy_AnonymousToolsListFiltersServerSentEvents(t *testing.T) { } } +func TestHandleProxy_AuthenticatedScopeToolsRestrictToolCalls(t *testing.T) { + var backendHits int + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + backendHits++ + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"ok":true}}`)) + })) + defer backend.Close() + + tmpDir := t.TempDir() + cfg := &config.Config{ + OAuthProxy: config.OAuthProxyConfig{ + HugoMCPURL: backend.URL, + HugoToken: "test-token", + ClientID: "hugo-mcp", + AuthenticatedScopeTools: "mcp:search_posts|read_page", + }, + } + audit := observability.NewAuditLogger(filepath.Join(tmpDir, "audit.log")) + store := storage.NewTokenStore(filepath.Join(tmpDir, "tokens.json"), false) + s, _ := NewService(cfg, store, audit, nil) + s.AddAccessToken("valid-token", time.Now().Add(1*time.Hour)) + + t.Run("allowed scope tool is proxied with valid bearer token", func(t *testing.T) { + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_posts","arguments":{"q":"hugo"}}}`) + req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer valid-token") + rr := httptest.NewRecorder() + + s.HandleProxy(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + if backendHits != 1 { + t.Fatalf("expected backend to be hit once, got %d", backendHits) + } + }) + + t.Run("disallowed scope tool is rejected before backend", func(t *testing.T) { + before := backendHits + body := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"publish_post","arguments":{}}}`) + req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer valid-token") + rr := httptest.NewRecorder() + + s.HandleProxy(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) + } + if backendHits != before { + t.Fatalf("backend was hit for forbidden authenticated tool") + } + }) +} + +func TestHandleProxy_AuthenticatedScopeToolsFilterToolsList(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"search_posts"},{"name":"publish_post"}]}}`)) + })) + defer backend.Close() + + tmpDir := t.TempDir() + cfg := &config.Config{ + OAuthProxy: config.OAuthProxyConfig{ + HugoMCPURL: backend.URL, + HugoToken: "test-token", + ClientID: "hugo-mcp", + AuthenticatedScopeTools: "mcp:search_posts", + }, + } + audit := observability.NewAuditLogger(filepath.Join(tmpDir, "audit.log")) + store := storage.NewTokenStore(filepath.Join(tmpDir, "tokens.json"), false) + s, _ := NewService(cfg, store, audit, nil) + s.AddAccessToken("valid-token", time.Now().Add(1*time.Hour)) + + req := httptest.NewRequest("POST", "/mcp", bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer valid-token") + rr := httptest.NewRecorder() + + s.HandleProxy(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), `"search_posts"`) { + t.Fatalf("allowed scope tool missing from response: %s", rr.Body.String()) + } + if strings.Contains(rr.Body.String(), "publish_post") { + t.Fatalf("tool outside scope leaked in authenticated tools/list: %s", rr.Body.String()) + } +} + func TestHandleProxy_BackendMissing(t *testing.T) { tmpDir := t.TempDir() cfg := &config.Config{