Class SecuredExampleMcpServer

All Implemented Interfaces:
jakarta.servlet.Servlet, jakarta.servlet.ServletConfig, Serializable, BasicUniversalConfig, DefaultConfig, DefaultHtmlConfig, ReadinessStateAware

The OAuth 2.1-secured variant of ExampleMcpServer: identical notes-service surface (tools, prompt, resources, resource template, subscriptions), but every POST / JSON-RPC call now requires a valid bearer token.

This class changes nothing about what the server exposes — it inherits ExampleMcpServer.createMcpConfig() unmodified, so the same publishNote/deleteNote tools, summarize prompt, and note:///... resources are still there. The only override is createMcpOptions(), which layers an McpResourceServerConfig on top of the parent's capabilities. That single override is the entire security story:

  • setEnabled(true) — turns on the RS (resource-server) gate at all. Off by default, so an ordinary ExampleMcpServer is completely unaffected by this class existing.
  • setResource(...) — this server's own canonical URL (RFC 9728 resource / RFC 8707 default audience). A bearer token's aud claim must include this exact URL or it is rejected — the confused-deputy defense: a token minted for some OTHER resource cannot be replayed here even if it is otherwise perfectly valid and signed by a trusted issuer.
  • setTokenValidator(...) — a JwtTokenValidator that checks the token's signature (against OfflineAuthorizationServer.jwkSource(), offline — no JWKS HTTP fetch), iss (must equal OfflineAuthorizationServer.issuerUri()), and aud/exp/ nbf (validated against the resource URL above and wall-clock time).
  • addAuthorizationServer(...) — advertised in the RFC 9728 Protected Resource Metadata (PRM) document a client fetches from the well-known .well-known/oauth-protected-resource path this framework serves automatically once RS auth is enabled. This is how a compliant client discovers WHICH authorization server to go get a token from, without that URL being hardcoded into the client at all.
  • addRequiredScope(...) — the coarse, endpoint-wide baseline scope (READ_SCOPE) every request must carry (on top of a merely valid, correctly-audienced token) before any JSON-RPC method dispatches. A token missing it gets a 403 with a WWW-Authenticate challenge naming the missing scope, not a silent failure.
  • addOperationScope(...) — SEP-2350 per-operation step-up (H3): publishNote and deleteNote additionally require WRITE_SCOPE on top of the baseline. A token carrying only READ_SCOPE can discover the server and read resources, but a mutating tool call gets its own scoped 403 insufficient_scope naming WRITE_SCOPE specifically — the baseline scope alone is never enough to invoke either write tool.

The net effect on the wire: an unauthenticated (or wrongly-audienced, or expired, or insufficiently-scoped) POST / now gets 401/403 plus a WWW-Authenticate: Bearer ... challenge instead of ever reaching ExampleMcpServer's tool/prompt/resource handlers; a request bearing a valid bearer token dispatches exactly as it always did.

The one wrinkle this class works around:

The resource URL is this server's OWN address, which is a problem when SecuredExampleServer boots on an OS-assigned ephemeral port (as the end-to-end test does): naively, that address is not knowable until AFTER Jetty has bound its socket. It is tempting to assume createMcpOptions() (like createMcpConfig()) is called lazily on the first incoming MCP request, late enough to bind the resource URL once the port is known — but it is NOT: the REST framework eagerly walks every @Bean-annotated method declared on the resource (including this inherited getMcpOptions()) while building the servlet's RestContext, i.e. during Jetty's OWN servlet initialization, strictly before Server.start() returns. A constructor-supplied resource URL is therefore required; see SecuredExampleServer.start(int) for how it opens the Jetty connector (and thus learns the real ephemeral port) before this servlet is even constructed.

  • Field Details

    • READ_SCOPE

      public static final String READ_SCOPE
      The baseline OAuth scope every request to this server must carry, on top of a valid, correctly-audienced token. Advertised in the PRM document's scopes_supported and required via McpResourceServerConfig.addRequiredScope(...).
      See Also:
    • WRITE_SCOPE

      public static final String WRITE_SCOPE
      The additional step-up scope publishNote/deleteNote require on top of READ_SCOPE (H3, SEP-2350 per-operation scoping), via McpResourceServerConfig.addOperationScope(...).
      See Also:
  • Constructor Details

    • SecuredExampleMcpServer

      Constructor.
      Parameters:
      authServer - The offline authorization server this instance validates bearer tokens against (its public key and issuer). Must not be null.
      resource - This server's own canonical root URL (e.g. http://localhost:10001/), known and fixed before construction — see the class javadoc's "one wrinkle" section for why this cannot be supplied later. Must not be null.
  • Method Details

    • getResource

      public URI getResource()
      Returns this server's own canonical resource URL, as supplied to the constructor.
      Returns:
      The resource URL. Never null.
    • createMcpOptions

      Description copied from class: ExampleMcpServer
      Advertises capabilities and instructions. Explicit here so the server/discover handshake truthfully reports everything this server supports — notably resources.subscribe, which pairs with the subscriptions/listen demo.

      On revision 2026-07-28, server/discover's instructions field is sourced from this McpOptions, not McpServerConfig.setInstructions(java.lang.String), which only feeds the legacy v1 initialize handshake.

      Overrides:
      createMcpOptions in class ExampleMcpServer
      Returns:
      A non-null options instance.