Skip to content

feat: Resources API: Align with spec and simplify handler ergonomics - #1169

Open
nbottari9 wants to merge 9 commits into
containers:mainfrom
nbottari9:1157-resources-api
Open

feat: Resources API: Align with spec and simplify handler ergonomics#1169
nbottari9 wants to merge 9 commits into
containers:mainfrom
nbottari9:1157-resources-api

Conversation

@nbottari9

@nbottari9 nbottari9 commented May 27, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR aligns the Resources API with the MCP specification and improves handler ergonomics by:

  1. Adding Annotations support (Audience, Priority, LastModified) to resources and resource templates
  2. Adding Title field to resources and resource templates
  3. Introducing ResourceHandlerParams struct to provide a consistent, extensible handler signature

These changes bring the Resources API to feature parity with the Tools API while maintaining backward compatibility (no resources are currently registered in the codebase).


Changes Made

1. Added Annotations to Resource and ResourceTemplate

Files modified:

  • pkg/api/toolsets.go (lines 113-175)
  • pkg/mcp/resources_gosdk.go (lines 22-32, 69-79, 124-147)

What changed:

  • Added Audience []string, Priority *float64, and LastModified *string fields to api.Resource and api.ResourceTemplate
  • Created buildAnnotations() helper function that converts internal annotation fields to MCP SDK mcp.Annotations type
  • Wired annotations through ServerResourceToGoSdkResource() and ServerResourceTemplateToGoSdkResourceTemplate()

Why:
The MCP specification defines three optional annotation fields for resources:

  • audience: Controls visibility to specific roles (e.g., ["user"], ["assistant"], ["user", "assistant"])
  • priority: Float 0.0-1.0 indicating relative importance for ranking/filtering
  • lastModified: ISO 8601 timestamp for cache invalidation

These annotations enable MCP clients to make smarter decisions about which resources to surface to users or LLMs.

Implementation details:

  • Used pointer types (*float64, *string) to distinguish "not set" (nil) from "explicitly zero/empty"
  • buildAnnotations() returns nil when all fields are empty, ensuring proper omitempty behavior in JSON serialization
  • Audience values are converted from []string to []mcp.Role to match SDK types

2. Added Title to Resource and ResourceTemplate

Files modified:

  • pkg/api/toolsets.go (lines 113-175)
  • pkg/mcp/resources_gosdk.go (lines 26, 73)

What changed:

  • Added Title string field to api.Resource and api.ResourceTemplate
  • Wired Title through ServerResourceToGoSdkResource() and ServerResourceTemplateToGoSdkResourceTemplate()

Why:
The MCP specification includes a title field as the human-readable display name, distinct from the programmatic name field. This follows the same pattern already used for tools via ToolAnnotations.Title.

Without Title, MCP clients fall back to displaying the programmatic name in resource pickers, which is often not user-friendly (e.g., "cluster_metrics" instead of "Cluster Performance Metrics").

Implementation details:

  • Title is optional (empty string means not set)
  • When Title is empty, MCP clients automatically fall back to displaying Name (client-side behavior)
  • Simple pass-through conversion - no validation needed

3. Changed ResourceHandler Signatures to Use ResourceHandlerParams

Files modified:

  • pkg/api/toolsets.go (lines 138-145, 169)
  • pkg/mcp/resources_gosdk.go (lines 36, 82)

What changed:

  • Introduced ResourceHandlerParams struct with embedded context.Context, BaseConfig, KubernetesClient, and URI string
  • Changed ResourceHandler signature from func(context.Context) (...) to func(params ResourceHandlerParams) (...)
  • Changed ResourceTemplateHandler signature from func(context.Context, string) (...) to func(params ResourceHandlerParams) (...)
  • Updated all handler call sites to use api.ResourceHandlerParams{Context: ctx} or api.ResourceHandlerParams{Context: ctx, URI: req.Params.URI}

Why:
The previous handler signatures were inflexible - adding new parameters (like BaseConfig or KubernetesClient) would require breaking API changes. By introducing a params struct (matching the pattern used for ToolHandlerParams), we can add new fields in the future without breaking existing handlers.

This also provides a more consistent API surface - tools and resources now follow the same parameter pattern.

Current limitations:

  • Context: ✅ Populated from MCP request
  • URI: ✅ Populated for resource templates (matches the actual URI requested)
  • BaseConfig: ❌ Not yet populated (future enhancement - requires using Server parameter)
  • KubernetesClient: ❌ Fundamentally cannot be populated (MCP protocol has no cluster targeting for resources)

The params struct is defined now to establish the API contract, even though not all fields are populated yet. This allows handlers to be written with the expectation that BaseConfig will be available in a future version without requiring signature changes.


Testing

New Test Suite

File added:

  • pkg/mcp/resources_annotations_test.go (519 lines)

Comprehensive test suite covering:

  • All annotation field combinations (all set, individual fields, none set)
  • Nil vs empty values for pointer fields (Priority, LastModified)
  • Boundary conditions (Priority 0.0 and 1.0, single/multiple audience values)
  • Persistence across configuration reload
  • Proper omitempty behavior (nil annotations not serialized)

Test cases:

  1. TestResourceAnnotationsAllFieldsSet - verifies all three annotation fields
  2. TestResourceAnnotationsAudienceOnly - verifies single field (audience)
  3. TestResourceAnnotationsPriorityOnly - verifies single field (priority)
  4. TestResourceAnnotationsLastModifiedOnly - verifies single field (lastModified)
  5. TestResourceAnnotationsNoneSet - verifies nil Annotations when all empty
  6. TestResourceTemplateSameAnnotations - verifies templates work identically to resources
  7. TestResourceAnnotationsAudienceEmptySlice - edge case: empty slice vs nil
  8. TestResourceAnnotationsPriorityZero - edge case: &0.0 vs nil
  9. TestResourceAnnotationsLastModifiedEmptyString - edge case: &"" vs nil
  10. TestResourceAnnotationsPriorityBoundary - boundary: 0.0 and 1.0 are valid
  11. TestResourceAnnotationsMultipleAudiences - multiple audience values
  12. TestResourceAnnotationsSurvivesReload - persistence across config reload

Extended Existing Tests

File modified:

  • pkg/mcp/resources_gosdk_test.go

Extended existing resource conversion tests to verify Title field:

  • Added Title to test resource definitions
  • Added assertions checking Title values in MCP resources
  • Verified Title appears correctly in list responses

Impact

Positive Impacts

  1. MCP Spec Compliance: Resources now support all current MCP annotation fields (audience, priority, lastModified) and title, bringing this implementation into full compliance with the specification.

  2. Better UX: MCP clients can now display user-friendly titles in resource pickers instead of programmatic names.

  3. Smarter Resource Selection: Annotations enable clients to filter and rank resources appropriately:

    • Show only user-facing resources in UI pickers (audience: ["user"])
    • Prioritize important resources (priority: 0.9)
    • Cache resources efficiently (lastModified)
  4. Future-Proof Handler API: The ResourceHandlerParams struct allows adding new parameters (like BaseConfig) without breaking existing handlers.

  5. API Consistency: Resources now follow the same parameter pattern as tools (both use params structs).

Breaking Changes

None. All changes are backward compatible:

Known Limitations

  1. BaseConfig not populated: The params struct includes BaseConfig but it's not yet populated in handler calls. This is a future enhancement (requires changing _ *Server to s *Server in conversion functions).

  2. KubernetesClient fundamentally limited: Unlike tools (which receive cluster targeting in request parameters), MCP resources have no cluster targeting mechanism. The ReadResourceRequest only contains a URI, so there's no way to know which cluster client to derive. This field will remain nil unless the MCP protocol adds cluster targeting support.


Migration Guide

For toolset authors (when adding new resources):

Before:

api.ServerResource{
    Resource: api.Resource{
        URI:         "k8s://cluster/status",
        Name:        "cluster_status",
        Description: "Cluster health status",
        MIMEType:    "application/json",
    },
    Handler: func(ctx context.Context) (*api.ResourceContent, error) {
        // ...
    },
}

After:

api.ServerResource{
    Resource: api.Resource{
        URI:         "k8s://cluster/status",
        Name:        "cluster_status",
        Title:       "Cluster Health Status",  // Human-friendly!
        Description: "Real-time cluster health and status",
        MIMEType:    "application/json",
        Audience:    []string{"user"},  // Show in UI pickers
        Priority:    ptr(0.8),          // High priority
        LastModified: ptr("2026-05-27T10:00:00Z"),
    },
    Handler: func(params api.ResourceHandlerParams) (*api.ResourceContent, error) {
        ctx := params.Context  // Access context from params
        // BaseConfig and KubernetesClient not yet available
        // ...
    },
}

Helper for pointer values:

func ptr[T any](v T) *T { return &v }

Related Issues


Checklist

  • ✅ Code follows project conventions
  • ✅ All tests pass (make test)
  • ✅ Linting passes (make lint)
  • ✅ Comprehensive test coverage (12 test cases for annotations, Title tested in existing suite)
  • ✅ No breaking changes
  • ✅ Documentation updated (godoc comments on new fields)

nbottari9 added 5 commits May 27, 2026 12:01
Signed-off-by: Nick Bottari <nbottari9@gmail.com>
…te to align with spec

Signed-off-by: Nick Bottari <nbottari9@gmail.com>
…modate new Title field

Signed-off-by: Nick Bottari <nbottari9@gmail.com>
Signed-off-by: Nick Bottari <nbottari9@gmail.com>
…ize the new struct for params

Signed-off-by: Nick Bottari <nbottari9@gmail.com>
@nbottari9
nbottari9 force-pushed the 1157-resources-api branch from 4f090b4 to aec8acd Compare May 27, 2026 16:01
@nader-ziada
nader-ziada requested review from Cali0707 and manusa May 28, 2026 19:19

@nader-ziada nader-ziada left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added some small suggestions

Comment thread pkg/mcp/resources_gosdk.go Outdated
Comment thread pkg/api/toolsets.go
Comment thread pkg/api/toolsets.go
Comment thread pkg/api/toolsets.go Outdated
Comment thread pkg/mcp/resources_annotations_test.go
Comment thread pkg/api/toolsets.go
Comment thread pkg/api/toolsets.go
Comment thread pkg/api/toolsets.go Outdated

@manusa manusa left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the annotations/Title wiring and the handler-signature migration are clean, and the empty→nil annotation behavior is well covered. Extending the existing review comments, three things block this for me:

  1. Item 3 of #1157 is only half-done. ResourceHandlerParams ships with BaseConfig/KubernetesClient embedded but never populated — the issue's core motivation. The "fundamentally cannot be populated" rationale doesn't hold (the tools' default-target fallback applies; see inline). Also, the description defers this to "#1157 item 4", but the issue has only 3 items — as written, "Closes #1157" would auto-close the issue with the motivating capability missing; please either wire the fields or change to "Refs #1157".
  2. Contract gaps: static-resource handlers get URI == ""; audience values are unvalidated and one test asserts the spec-invalid role "system" round-trips.
  3. Test/description accuracy: the description's Testing section lists test names that don't exist (9 of 12), including a claimed LastModified: &"" edge case that is genuinely untested; the zero-priority "preservation" assertions pass vacuously (the SDK drops priority: 0 via omitempty); and the new 518-line suite duplicates ResourceSuite scaffolding that a table-driven test in the existing file would collapse.

Details inline.

Comment thread pkg/mcp/resources_gosdk.go Outdated

handler := func(ctx context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
content, err := res.Handler(ctx)
content, err := res.Handler(api.ResourceHandlerParams{Context: ctx})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Separate from the population question above: params.URI is left empty for static resources, while templates get req.Params.URI. The issue says "A single handler type covers both static resources and templates (URI is populated from the read request)". res.Resource.URI is already in scope (it's used in the ReadResourceResult below), so this can be api.ResourceHandlerParams{Context: ctx, URI: res.Resource.URI} — otherwise a handler shared across both kinds sees a populated URI in one path and "" in the other.


resource := result.Resources[0]
s.Require().NotNil(resource.Annotations, "annotations should not be nil even for zero priority")
s.Equal(0.0, resource.Annotations.Priority, "zero priority should be preserved")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion passes vacuously: the SDK's Annotations.Priority is a non-pointer float64 with json:"priority,omitempty", so an explicit 0.0 is dropped on the wire and the 0.0 observed here is just the client-side Go zero value — the test can't distinguish "preserved" from "dropped-and-defaulted". Only the NotNil(resource.Annotations) check verifies real behavior (the server emits "annotations":{}). Same applies to the minPriority half of TestResourceAnnotationsPriorityBoundaries below. Worth reframing these to assert what's actually observable — and the *float64 godoc/description shouldn't claim the not-set-vs-zero distinction survives serialization, because it can't.

Comment thread pkg/mcp/resources_annotations_test.go Outdated
URI: "test://example/multi-audience",
Name: "Multiple Audience",
MIMEType: "text/plain",
Audience: []string{"user", "assistant", "system"},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"system" isn't a valid MCP audience role — the spec (and the field's own comment in api/toolsets.go) defines only user and assistant. This test codifies pass-through of an out-of-spec value as the expected contract, so if buildAnnotations ever starts validating roles, this test will read as a regression. Suggest proving ordering with valid roles instead (e.g. ["assistant", "user"]) — and deciding explicitly whether the conversion should validate/filter roles.

"github.com/stretchr/testify/suite"
)

type ResourceAnnotationsSuite struct {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This suite duplicates ResourceSuite's scaffolding verbatim (SetupTest/TearDownTest/originalToolsets, same mockResourceToolset, same "resource-test" toolset name). There's no need for a second suite: define these tests as methods on ResourceSuite instead, and keep them in this separate file if you prefer — the project already does this elsewhere, e.g. PromptsTestSuite is declared in pkg/prompts/prompts_test.go and has additional test methods in pkg/prompts/prompts_merge_test.go. That removes the duplicated scaffolding and the extra suite.Run entry point entirely.

For the per-test repetition (each of the 12 tests repeats the same toolsets.Clear() / toolsets.Register(...) / s.Cfg.Toolsets = ... / s.InitMcpClient() block around the single api.Resource literal that varies), follow the parameterized suite-helper pattern from ElicitationSuite.registerElicitingToolset(handler) (elicit_test.go:36-59) — e.g. a registerResources(resources ...api.ServerResource) helper on ResourceSuite that both this file's tests and the existing ones can use.

Separately: none of the 12 tests ever reads a resource, so the template handlers written here against params.URI are never invoked.

nbottari9 added 4 commits June 9, 2026 10:04
Signed-off-by: Nick Bottari <nbottari9@gmail.com>
Signed-off-by: Nick Bottari <nbottari9@gmail.com>
…d" fields to be in their own struct, with 'Resource' and 'ResourceTemplate' having a pointer to it.

Signed-off-by: Nick Bottari <nbottari9@gmail.com>
… structure

Signed-off-by: Nick Bottari <nbottari9@gmail.com>
@Cali0707

Copy link
Copy Markdown
Collaborator

@nbottari9 mind looking into the failing tests here? It seems related to the error messages you added/changed...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants