Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions pkg/authz/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ var MCPMethodToFeatureOperation = map[string]featureOperation{
"features/list": {Feature: "", Operation: authorizers.MCPOperationList}, // Capability discovery
"roots/list": {Feature: "", Operation: ""}, // Root directory discovery

// server/discover is intentionally NOT allow-listed: it default-denies (403) for now.
// Its response enumerates tool/resource descriptors and would bypass
// ResponseFilteringWriter (which only filters tools/list, prompts/list, resources/list,
// and find_tool). When Modern serving is wired up (#5830), add it as allow +
// response-filter, not always-allowed.

// Subscriptions - always allowed for now. This method carries no single resource
// identifier the parser extracts (params are a notification-type filter with an
// optional resourceSubscriptions array), so routing it through Cedar with an empty
// ResourceID would risk matching a broad allow rule. Notification delivery and
// per-resource authorization of resourceSubscriptions URIs are future work.
//
// TODO(#5755): when subscription notification delivery is implemented, replace this
// always-allowed entry with real per-resource authorization of resourceSubscriptions URIs.
"subscriptions/listen": {Feature: "", Operation: ""},

// Logging and client preferences - always allowed
"logging/setLevel": {Feature: "", Operation: ""}, // Client preference for server logging

Expand Down
45 changes: 45 additions & 0 deletions pkg/authz/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,28 @@ func TestMiddleware(t *testing.T) {
expectStatus: http.StatusForbidden,
expectAuthorized: false,
},
{
name: "Server discover default-denies (not allow-listed)",
method: "server/discover",
params: map[string]interface{}{},
claims: jwt.MapClaims{
"sub": "user123",
"name": "John Doe",
},
expectStatus: http.StatusForbidden,
expectAuthorized: false,
},
{
name: "Subscriptions listen is always allowed",
method: "subscriptions/listen",
params: map[string]interface{}{},
claims: jwt.MapClaims{
"sub": "user123",
"name": "John Doe",
},
expectStatus: http.StatusOK,
expectAuthorized: true,
},
{
name: "Sampling createMessage is denied by default (security-sensitive)",
method: "sampling/createMessage",
Expand Down Expand Up @@ -434,6 +456,29 @@ func TestMiddleware(t *testing.T) {
}
}

// TestSubscriptionsListenIsAllowlistedPendingDelivery guards a deliberate, temporary
// exception: subscriptions/listen is always-allowed only because notification delivery
// for it is not yet implemented, so it exposes no data. When delivery lands, this entry
// must become a real Feature/Operation with per-resource authorization of
// resourceSubscriptions URIs (see TODO(#5755) in MCPMethodToFeatureOperation) — this test
// should fail at that point as a reminder to update it deliberately.
func TestSubscriptionsListenIsAllowlistedPendingDelivery(t *testing.T) {
t.Parallel()
require.Equal(t, featureOperation{}, MCPMethodToFeatureOperation["subscriptions/listen"])
}

// TestServerDiscoverIsNotAllowlisted guards a deliberate omission: server/discover must
// stay absent from MCPMethodToFeatureOperation so it default-denies (403) until Modern
// serving is wired up with proper response filtering (#5830). Its response enumerates
// tool/resource descriptors, and re-adding it as always-allowed would let a Cedar-restricted
// client bypass ResponseFilteringWriter and enumerate the full catalog. This test forces a
// conscious decision if someone re-adds the entry.
func TestServerDiscoverIsNotAllowlisted(t *testing.T) {
t.Parallel()
_, ok := MCPMethodToFeatureOperation["server/discover"]
require.False(t, ok, "server/discover must not be allow-listed until Modern serving with response filtering lands (#5830)")
}

// TestMiddlewareWithGETRequest tests that the middleware doesn't panic with GET requests.
func TestMiddlewareWithGETRequest(t *testing.T) {
t.Parallel()
Expand Down
68 changes: 60 additions & 8 deletions pkg/mcp/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ type ParsedMCPRequest struct {
// Meta contains the _meta field from the request params for protocol-level metadata
// such as progress tokens, trace IDs, or custom namespaced metadata
Meta map[string]interface{}
// MCPMethodHeader is the value of the Modern (stateless MCP) "Mcp-Method"
// request header, if present. Mandatory on every Modern POST per the draft spec.
MCPMethodHeader string
// MCPNameHeader is the raw, as-received value of the Modern (stateless MCP)
// "Mcp-Name" request header, if present. Required for tools/call,
// resources/read, prompts/get. Stored undecoded: the spec allows the header
// value to be sentinel-encoded (=?base64?...?=), and a caller comparing it to
// the plain body name/uri during validation must decode the header first.
MCPNameHeader string
// ClientInfo is the client implementation info surfaced via _meta for Modern
// (stateless) requests, sourced from _meta["io.modelcontextprotocol/clientInfo"].
ClientInfo map[string]interface{}
// ProtocolVersion is the per-request protocol version surfaced via _meta for
// Modern (stateless) requests, sourced from _meta["io.modelcontextprotocol/protocolVersion"].
ProtocolVersion string
// IsRequest indicates if this is a JSON-RPC request (vs response or notification)
IsRequest bool
// IsBatch indicates if this is a batch request
Expand Down Expand Up @@ -95,6 +110,8 @@ func ParsingMiddleware(next http.Handler) http.Handler {
// Parse the MCP request and store in context
parsedRequest := parseMCPRequest(bodyBytes)
if parsedRequest != nil {
parsedRequest.MCPMethodHeader = r.Header.Get("Mcp-Method")
parsedRequest.MCPNameHeader = r.Header.Get("Mcp-Name")
ctx := context.WithValue(r.Context(), MCPRequestContextKey, parsedRequest)
r = r.WithContext(ctx)
}
Expand Down Expand Up @@ -158,6 +175,7 @@ func parseMCPRequest(bodyBytes []byte) *ParsedMCPRequest {

// Extract resource ID, arguments, and meta based on the method
resourceID, arguments, meta := extractResourceAndArguments(req.Method, req.Params)
clientInfo, protocolVersion := extractModernMeta(meta)

// Determine the ID - will be nil for notifications
var id interface{}
Expand All @@ -166,14 +184,16 @@ func parseMCPRequest(bodyBytes []byte) *ParsedMCPRequest {
}

return &ParsedMCPRequest{
Method: req.Method,
ID: id,
Params: req.Params,
ResourceID: resourceID,
Arguments: arguments,
Meta: meta,
IsRequest: true,
IsBatch: false, // TODO: Add batch request support if needed
Method: req.Method,
ID: id,
Params: req.Params,
ResourceID: resourceID,
Arguments: arguments,
Meta: meta,
ClientInfo: clientInfo,
ProtocolVersion: protocolVersion,
IsRequest: true,
IsBatch: false, // TODO: Add batch request support if needed
}
}

Expand Down Expand Up @@ -219,6 +239,7 @@ var staticResourceIDs = map[string]string{
"notifications/resources/list_changed": "resources",
"notifications/resources/updated": "resources",
"notifications/tools/list_changed": "tools",
"server/discover": "discover",
}

func extractResourceAndArguments(method string, params json.RawMessage) (string, map[string]interface{}, map[string]interface{}) {
Expand All @@ -243,6 +264,17 @@ func extractResourceAndArguments(method string, params json.RawMessage) (string,
return resourceID, arguments, meta
}

// extractModernMeta surfaces the Modern (stateless MCP) clientInfo and
// protocolVersion fields from a parsed _meta map, if present. It delegates to
// the reserved-key helpers in revision.go so the guarded type assertions live
// in one place; a wrong-shaped value is treated as absent rather than causing
// an error.
func extractModernMeta(meta map[string]interface{}) (clientInfo map[string]interface{}, protocolVersion string) {
clientInfo, _ = objectMetaValue(meta, metaKeyClientInfo)
protocolVersion, _ = stringMetaValue(meta, metaKeyProtocolVersion)
return clientInfo, protocolVersion
}

// getStaticResourceID returns the static resource ID for methods that don't need parameter parsing
func getStaticResourceID(method string) string {
if resourceID, exists := staticResourceIDs[method]; exists {
Expand Down Expand Up @@ -495,3 +527,23 @@ func GetMCPMeta(ctx context.Context) map[string]interface{} {
}
return nil
}

// GetMCPClientInfo is a convenience function to get the Modern (stateless MCP)
// per-request clientInfo from the context.
// Returns nil if no parsed request is available or clientInfo is not present.
func GetMCPClientInfo(ctx context.Context) map[string]interface{} {
if parsed := GetParsedMCPRequest(ctx); parsed != nil {
return parsed.ClientInfo
}
return nil
}

// GetMCPProtocolVersion is a convenience function to get the Modern (stateless
// MCP) per-request protocol version from the context.
// Returns "" if no parsed request is available or protocolVersion is not present.
func GetMCPProtocolVersion(ctx context.Context) string {
if parsed := GetParsedMCPRequest(ctx); parsed != nil {
return parsed.ProtocolVersion
}
return ""
}
Loading
Loading