", "..."],
"bearer_methods_supported": ["header"]
}
@@ -659,7 +691,7 @@ Auto-generated from configuration:
Served at the well-known path derived from the MCP endpoint.
-### 9.5 WWW-Authenticate Header Generation
+### 9.6 WWW-Authenticate Header Generation
On `401 Unauthorized`:
```
@@ -680,7 +712,7 @@ WWW-Authenticate: Bearer error="insufficient_scope",
error_description="Required scope 'tools:execute' not present in token"
```
-### 9.6 stdio Transport — No Authentication
+### 9.7 stdio Transport — No Authentication
Per MCP spec §1.2: "Implementations using an STDIO transport SHOULD NOT follow this specification, and instead retrieve credentials from the environment."
@@ -714,9 +746,9 @@ Static authentication (bearer, apikey, basic, digest) MUST continue to use `Mess
### 10.4 HTTPS Enforcement
-The MCP spec requires "All authorization server endpoints MUST be served over HTTPS." While Naftiko does not enforce HTTPS on the MCP server itself (that is typically handled by a reverse proxy), the `authorizationServerUrl` MUST use the `https` scheme.
+The MCP spec requires "All authorization server endpoints MUST be served over HTTPS." While Naftiko does not enforce HTTPS on the MCP server itself (that is typically handled by a reverse proxy), the `authorizationServerUri` MUST use the `https` scheme.
-Validation rule: `authorizationServerUrl` must start with `https://`.
+Validation rule: `authorizationServerUri` must start with `https://`.
### 10.5 JWKS Key Caching
@@ -740,7 +772,7 @@ These constraints are enforced by the schema itself:
| Rule | Enforcement |
|------|-------------|
| `authentication` is optional on `ExposesMcp` | Not in `required` array |
-| `AuthOAuth2.authorizationServerUrl` is required | In `required` array |
+| `AuthOAuth2.authorizationServerUri` is required | In `required` array |
| `AuthOAuth2.resource` is required | In `required` array |
| `AuthOAuth2.tokenValidation` defaults to `jwks` | `default: "jwks"` |
| `AuthOAuth2.type` must be `"oauth2"` | `const: "oauth2"` |
@@ -749,30 +781,30 @@ These constraints are enforced by the schema itself:
Additional cross-field validations:
-| Rule Name | Severity | Description |
-|-----------|----------|-------------|
-| `naftiko-mcp-oauth2-https-authserver` | error | `authorizationServerUrl` must use `https://` scheme |
-| `naftiko-mcp-oauth2-resource-https` | warn | `resource` should use `https://` scheme for production |
-| `naftiko-mcp-auth-stdio-conflict` | warn | `authentication` should not be set when `transport: "stdio"` |
-| `naftiko-mcp-oauth2-scopes-defined` | warn | `scopes` array should be defined for OAuth2 auth (enables WWW-Authenticate scope challenges) |
+| Rule Name | Severity | Scope | Description |
+|-----------|----------|-------|-------------|
+| `naftiko-oauth2-https-authserver` | error | All adapters | `authorizationServerUri` must use `https://` scheme |
+| `naftiko-oauth2-resource-https` | warn | All adapters | `resource` should use `https://` scheme for production |
+| `naftiko-oauth2-scopes-defined` | warn | All adapters | `scopes` array should be defined for OAuth2 auth (enables scope challenges) |
+| `naftiko-mcp-auth-stdio-conflict` | warn | MCP only | `authentication` should not be set when `transport: "stdio"` |
---
## 12. Design Decisions & Rationale
-### D1: Separate `McpAuthentication` union vs. extending shared `Authentication`
+### D1: Shared `Authentication` union with `AuthOAuth2`
-**Decision**: Create `McpAuthentication` as a new union that includes all four existing auth types plus `AuthOAuth2`.
+**Decision**: Add `AuthOAuth2` directly to the shared `Authentication` union used by all three adapters.
-**Rationale**: Adding `AuthOAuth2` to the shared `Authentication` union would expose OAuth2 as a valid option on `ExposesRest` and `ExposesSkill`, which those adapters don't support. A separate union keeps the schema honest while avoiding duplication of the four shared types (they remain `$ref`'d from the same definitions).
+**Rationale**: The core OAuth 2.1 resource server logic — JWT/JWKS validation, audience/expiry/scope checks, token introspection — is not adapter-specific. Any HTTP server protecting endpoints with bearer tokens needs the same validation. A shared union avoids a parallel `McpAuthentication` type and ensures REST and Skill adapters get OAuth 2.1 support without additional schema work.
-**Alternative considered**: A single `Authentication` union with all five types, and adapter-level validation rules rejecting `oauth2` on REST/Skill. Rejected because schema-level enforcement is stronger than rule-level enforcement, and because OAuth2 may eventually need different configuration for REST (e.g., token relay) vs. MCP (resource server).
+**Alternative considered**: A separate `McpAuthentication` union containing the four existing types plus `AuthOAuth2`, keeping REST/Skill unaffected. Rejected because the `AuthOAuth2` configuration shape is generic, and restricting it to one adapter would force duplication when the others inevitably need it.
-### D2: Jetty Handler chain vs. Servlet Filter
+### D2: Shared `OAuth2AuthenticationRestlet` with MCP extension
-**Decision**: Implement authentication as a Jetty `Handler.Wrapper`.
+**Decision**: Implement a shared `OAuth2AuthenticationRestlet` in `io.naftiko.engine.exposes` for core JWT validation, and extend it in `McpOAuth2Restlet` for MCP protocol-specific concerns.
-**Rationale**: The MCP adapter already uses Jetty's Handler model (not Servlets). `JettyStreamableHandler` extends `Handler.Abstract`. Wrapping it with another handler is the natural Jetty pattern and avoids introducing a Servlet context just for authentication.
+**Rationale**: The MCP adapter requires Protected Resource Metadata (RFC 9728) and `resource_metadata` in `WWW-Authenticate` headers — neither of which applies to REST or Skill. The two-class design cleanly separates shared validation from protocol overlay. REST and Skill adapters use `OAuth2AuthenticationRestlet` directly; the MCP adapter uses its subclass.
### D3: No per-tool scope mapping
@@ -792,7 +824,7 @@ Additional cross-field validations:
**Rationale**: The MCP spec requires this endpoint for authorization server discovery (RFC 9728). Requiring capability authors to manually create and serve this metadata would be a poor UX. By generating it from the `oauth2` authentication configuration, the YAML remains the single source of truth.
-### D6: `authorizationServerUrl` vs. inline AS metadata
+### D6: `authorizationServerUri` vs. inline AS metadata
**Decision**: Require only the AS issuer URL, not inline metadata fields.
@@ -807,35 +839,45 @@ Additional cross-field validations:
**Bring MCP authentication to parity with REST adapter for the simplest cases.**
1. Add `authentication` property to `ExposesMcp` in JSON schema (use existing `Authentication` union first; `McpAuthentication` union comes in Phase 2)
-2. Create `McpAuthenticationHandler` (Jetty `Handler.Wrapper`) — port logic from `ServerAuthenticationRestlet` to Jetty handler model
-3. Wire into `McpServerAdapter.initHttpTransport()` — insert handler before `JettyStreamableHandler`
+2. Add `buildServerChain()` to `McpServerAdapter` — reuse `ServerAuthenticationRestlet` and `ChallengeAuthenticator` from the REST adapter, wrapping the `Router` before `McpServerResource`
+3. Wire into `McpServerAdapter.initHttpTransport()` — insert authentication restlet before the `Router`
4. Add Spectral rule `naftiko-mcp-auth-stdio-conflict`
-5. Tests: unit tests for handler, integration test with bearer-protected MCP server
+5. Tests: unit tests for chain wiring, integration test with bearer-protected MCP server
-### Phase 2: OAuth 2.1 Resource Server
+### Phase 2: OAuth 2.1 Resource Server (shared)
-**Full MCP 2025-11-25 authorization compliance.**
+**Core OAuth 2.1 validation, available to all adapters.**
-1. Add `AuthOAuth2` definition to JSON schema
-2. Replace `Authentication` ref on `ExposesMcp` with `McpAuthentication` union
-3. Create `McpOAuth2Handler`:
+1. Add `AuthOAuth2` definition to JSON schema, in the shared `Authentication` union
+2. Create `OAuth2AuthenticationRestlet` in `io.naftiko.engine.exposes`:
- AS metadata discovery (RFC 8414)
- JWKS fetching and caching
- JWT validation (signature, expiry, issuer, audience)
+ - Scope checking and `403` challenge
+ - `WWW-Authenticate: Bearer` header generation
+3. Wire `OAuth2AuthenticationRestlet` into `RestServerAdapter.buildServerChain()` and `SkillServerAdapter`
+4. Add Spectral rules: `naftiko-oauth2-https-authserver`, `naftiko-oauth2-resource-https`, `naftiko-oauth2-scopes-defined` (adapter-agnostic)
+5. Tests: unit tests for shared JWT validation; integration tests for REST and Skill adapters with OAuth2
+
+### Phase 3: MCP Protocol Overlay
+
+**MCP 2025-11-25-specific authorization behavior.**
+
+1. Create `McpOAuth2Restlet` extending `OAuth2AuthenticationRestlet`:
- Protected Resource Metadata endpoint (RFC 9728)
- - `WWW-Authenticate` header generation
-4. Add Spectral rules: `naftiko-mcp-oauth2-https-authserver`, `naftiko-mcp-oauth2-resource-https`, `naftiko-mcp-oauth2-scopes-defined`
-5. Tests: unit tests for JWT validation, integration test with mock AS
+ - `resource_metadata` URL in `WWW-Authenticate` headers
+2. Wire into `McpServerAdapter.buildServerChain()` for `oauth2` type
+3. Add MCP-specific Spectral rule: `naftiko-mcp-oauth2-resource-defined` (warn if `resource` missing)
+4. Tests: integration test with mock AS, Protected Resource Metadata endpoint test
-### Phase 3: Token Introspection and Scope Challenges
+### Phase 4: Token Introspection and Extended Features
-**Extended OAuth features.**
+**Extended OAuth features in the shared layer.**
-1. Add introspection support to `McpOAuth2Handler` (RFC 7662)
-2. Implement `403` scope challenge responses
-3. Origin header validation for DNS rebinding prevention
-4. JWKS cache key rotation support (unknown-kid refresh)
-5. Tests: introspection flow, scope challenge flow, Origin validation
+1. Add introspection support to `OAuth2AuthenticationRestlet` (RFC 7662)
+2. JWKS cache key rotation support (unknown-kid refresh)
+3. Origin header validation for DNS rebinding prevention (MCP adapter)
+4. Tests: introspection flow, scope challenge flow, Origin validation
---
@@ -844,13 +886,14 @@ Additional cross-field validations:
### Schema
- `authentication` on `ExposesMcp` is optional (not in `required`) — existing capabilities without it continue to work unchanged
+- `AuthOAuth2` is added to the shared `Authentication` union — existing REST and Skill capabilities without it continue to work unchanged
- No properties removed or renamed
-- New `McpAuthentication` and `AuthOAuth2` definitions are purely additive
+- New `AuthOAuth2` definition is purely additive
### Engine
-- `McpServerAdapter` with no `authentication` configured behaves exactly as it does today — no handler inserted, no overhead
-- Existing `JettyStreamableHandler` is not modified — authentication handlers wrap it
+- `McpServerAdapter` with no `authentication` configured behaves exactly as it does today — the `Router` is passed directly to `initServer()`, no overhead
+- Existing `McpServerResource` and `ProtocolDispatcher` are not modified — authentication restlets wrap the `Router`
### Wire Protocol
diff --git a/src/main/resources/rules/naftiko-rules.yml b/src/main/resources/rules/naftiko-rules.yml
index de87e723..3f18f529 100644
--- a/src/main/resources/rules/naftiko-rules.yml
+++ b/src/main/resources/rules/naftiko-rules.yml
@@ -139,6 +139,60 @@ rules:
then:
function: aggregate-semantics-consistency
+ naftiko-mcp-auth-stdio-conflict:
+ message: "MCP `authentication` should not be set when `transport` is `stdio`."
+ description: >
+ Per MCP specification §1.2, stdio transport should not follow the HTTP
+ authorization flow — credentials are retrieved from the environment instead.
+ Authentication is only meaningful for the HTTP transport.
+ severity: warn
+ recommended: true
+ given: "$.capability.exposes[?(@.type == 'mcp' && @.transport == 'stdio')]"
+ then:
+ field: "authentication"
+ function: falsy
+
+ naftiko-oauth2-https-authserver:
+ message: "OAuth2 `authorizationServerUri` must use the `https://` scheme."
+ description: >
+ The OAuth 2.1 specification requires all authorization server endpoints to be
+ served over HTTPS. A non-HTTPS authorization server URI is a security risk.
+ severity: error
+ recommended: true
+ given: "$.capability.exposes[*].authentication[?(@.type == 'oauth2')]"
+ then:
+ field: "authorizationServerUri"
+ function: pattern
+ functionOptions:
+ match: "^https://"
+
+ naftiko-oauth2-resource-https:
+ message: "OAuth2 `resource` should use the `https://` scheme for production."
+ description: >
+ The resource URI identifies this server in Protected Resource Metadata and
+ audience validation. Using HTTPS ensures proper security in production.
+ severity: warn
+ recommended: true
+ given: "$.capability.exposes[*].authentication[?(@.type == 'oauth2')]"
+ then:
+ field: "resource"
+ function: pattern
+ functionOptions:
+ match: "^https://"
+
+ naftiko-oauth2-scopes-defined:
+ message: "OAuth2 authentication should define `scopes` for scope challenge support."
+ description: >
+ Defining scopes enables the server to include scope information in
+ WWW-Authenticate challenges and Protected Resource Metadata, improving
+ client interoperability.
+ severity: warn
+ recommended: true
+ given: "$.capability.exposes[*].authentication[?(@.type == 'oauth2')]"
+ then:
+ field: "scopes"
+ function: truthy
+
# ────────────────────────────────────────────────────────────────
# 2. QUALITY & DISCOVERABILITY
# ────────────────────────────────────────────────────────────────
diff --git a/src/main/resources/schemas/naftiko-schema.json b/src/main/resources/schemas/naftiko-schema.json
index 64bce31e..d7cd2de9 100644
--- a/src/main/resources/schemas/naftiko-schema.json
+++ b/src/main/resources/schemas/naftiko-schema.json
@@ -982,6 +982,9 @@
"type": "string",
"description": "A meaningful description of this MCP server's purpose. Used as the server instructions sent during MCP initialization."
},
+ "authentication": {
+ "$ref": "#/$defs/Authentication"
+ },
"tools": {
"type": "array",
"description": "List of MCP tools exposed by this server",
@@ -2061,6 +2064,9 @@
},
{
"$ref": "#/$defs/AuthDigest"
+ },
+ {
+ "$ref": "#/$defs/AuthOAuth2"
}
]
},
@@ -2156,6 +2162,52 @@
],
"additionalProperties": false
},
+ "AuthOAuth2": {
+ "type": "object",
+ "description": "OAuth 2.1 resource server authentication. The server validates bearer tokens issued by an external authorization server.",
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "oauth2"
+ },
+ "authorizationServerUri": {
+ "type": "string",
+ "format": "uri",
+ "description": "Issuer URI of the OAuth 2.1 authorization server. The engine derives metadata endpoints (.well-known/oauth-authorization-server) from this URI."
+ },
+ "resource": {
+ "type": "string",
+ "format": "uri",
+ "description": "Canonical URI of this server (RFC 8707). Used for audience validation and Protected Resource Metadata."
+ },
+ "scopes": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Scopes this resource server recognizes."
+ },
+ "audience": {
+ "type": "string",
+ "description": "Expected 'aud' claim in the JWT. Defaults to the 'resource' URI if not set."
+ },
+ "tokenValidation": {
+ "type": "string",
+ "enum": [
+ "jwks",
+ "introspection"
+ ],
+ "default": "jwks",
+ "description": "How to validate incoming access tokens. 'jwks' (default): fetch the AS public keys and validate JWT signatures locally. 'introspection': call the AS token introspection endpoint (RFC 7662) for each request."
+ }
+ },
+ "required": [
+ "type",
+ "authorizationServerUri",
+ "resource"
+ ],
+ "additionalProperties": false
+ },
"ExposesSkill": {
"type": "object",
"description": "Skill server adapter — metadata and catalog layer. Skills declare tools derived from sibling api or mcp adapters or defined as local file instructions. Does not execute tools.",
diff --git a/src/main/resources/wiki/Installation.md b/src/main/resources/wiki/Installation.md
index 5e7f9d57..62d1a4de 100644
--- a/src/main/resources/wiki/Installation.md
+++ b/src/main/resources/wiki/Installation.md
@@ -1,6 +1,6 @@
-To use Naftiko Framework, you must install and then run its engine.
+To use Naftiko Framework, you need to install and then run the Naftiko Engine, passing a Naftiko YAML file to it. A command-line interface is also provided.
-## Docker usage
+## Naftiko Engine
### Prerequisites
* You need Docker or, if you are on macOS or Windows their Docker Desktop version. To do so, follow the official documentation:
* [For Mac](https://docs.docker.com/desktop/setup/install/mac-install/)
@@ -53,8 +53,8 @@ To use Naftiko Framework, you must install and then run its engine.
```
Then you should be able to request your capability at http://localhost:8081
-## CLI tool
-The Naftiko framework provides a CLI tool.\
+## Naftiko CLI
+The Naftiko Framework also includes a CLI tool.\
The goal of this CLI is to simplify configuration and validation. While everything can be done manually, the CLI provides helper commands.
## Installation
diff --git a/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java b/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java
new file mode 100644
index 00000000..7851a5b2
--- /dev/null
+++ b/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java
@@ -0,0 +1,492 @@
+/**
+ * Copyright 2025-2026 Naftiko
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.naftiko.engine.exposes;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Date;
+import java.util.List;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.Restlet;
+import org.restlet.data.Method;
+import org.restlet.data.Status;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import io.naftiko.spec.consumes.OAuth2AuthenticationSpec;
+
+/**
+ * Unit tests for the shared {@link OAuth2AuthenticationRestlet}. Validates JWT signature
+ * verification, claims validation, and WWW-Authenticate header generation.
+ */
+class OAuth2AuthenticationRestletTest {
+
+ private static RSAKey rsaJWK;
+ private static RSAKey rsaPublicJWK;
+ private static JWKSet jwkSet;
+
+ @BeforeAll
+ static void generateKeys() throws Exception {
+ rsaJWK = new RSAKeyGenerator(2048).keyID("test-key-1").generate();
+ rsaPublicJWK = rsaJWK.toPublicJWK();
+ jwkSet = new JWKSet(rsaPublicJWK);
+ }
+
+ @Test
+ void handleShouldRejectRequestWithoutBearerToken() {
+ OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec());
+
+ Request request = new Request(Method.POST, "http://localhost/mcp");
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus());
+ assertFalse(response.getChallengeRequests().isEmpty(),
+ "Should include a Bearer challenge");
+ assertEquals("Bearer",
+ response.getChallengeRequests().get(0).getScheme().getTechnicalName());
+ }
+
+ @Test
+ void handleShouldAcceptValidJwt() throws Exception {
+ TrackingRestlet tracker = new TrackingRestlet();
+ OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec(), tracker);
+
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("https://auth.example.com")
+ .audience("https://mcp.example.com/mcp")
+ .expirationTime(futureDate())
+ .build());
+
+ Request request = bearerRequest(token);
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertTrue(tracker.wasCalled(), "Valid JWT should delegate to next restlet");
+ }
+
+ @Test
+ void handleShouldRejectExpiredJwt() throws Exception {
+ OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec());
+
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("https://auth.example.com")
+ .audience("https://mcp.example.com/mcp")
+ .expirationTime(new Date(System.currentTimeMillis() - 60_000))
+ .build());
+
+ Request request = bearerRequest(token);
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus());
+ String rawValue = response.getChallengeRequests().get(0).getRawValue();
+ assertTrue(rawValue.contains("invalid_token"));
+ assertTrue(rawValue.contains("Token expired"));
+ }
+
+ @Test
+ void handleShouldRejectInvalidIssuer() throws Exception {
+ OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec());
+
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("https://wrong-issuer.example.com")
+ .audience("https://mcp.example.com/mcp")
+ .expirationTime(futureDate())
+ .build());
+
+ Request request = bearerRequest(token);
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus());
+ String rawValue = response.getChallengeRequests().get(0).getRawValue();
+ assertTrue(rawValue.contains("Invalid issuer"));
+ }
+
+ @Test
+ void handleShouldRejectInvalidAudience() throws Exception {
+ OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec());
+
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("https://auth.example.com")
+ .audience("https://wrong-audience.example.com")
+ .expirationTime(futureDate())
+ .build());
+
+ Request request = bearerRequest(token);
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus());
+ String rawValue = response.getChallengeRequests().get(0).getRawValue();
+ assertTrue(rawValue.contains("Invalid audience"));
+ }
+
+ @Test
+ void handleShouldReturnForbiddenForInsufficientScope() throws Exception {
+ OAuth2AuthenticationSpec spec = minimalSpec();
+ spec.setScopes(List.of("tools:read", "tools:execute"));
+ OAuth2AuthenticationRestlet restlet = buildRestlet(spec);
+
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("https://auth.example.com")
+ .audience("https://mcp.example.com/mcp")
+ .expirationTime(futureDate())
+ .claim("scope", "tools:read")
+ .build());
+
+ Request request = bearerRequest(token);
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertEquals(Status.CLIENT_ERROR_FORBIDDEN, response.getStatus());
+ String rawValue = response.getChallengeRequests().get(0).getRawValue();
+ assertTrue(rawValue.contains("insufficient_scope"));
+ assertTrue(rawValue.contains("tools:execute"));
+ }
+
+ @Test
+ void handleShouldAcceptJwtWithAllRequiredScopes() throws Exception {
+ OAuth2AuthenticationSpec spec = minimalSpec();
+ spec.setScopes(List.of("tools:read", "tools:execute"));
+ TrackingRestlet tracker = new TrackingRestlet();
+ OAuth2AuthenticationRestlet restlet = buildRestlet(spec, tracker);
+
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("https://auth.example.com")
+ .audience("https://mcp.example.com/mcp")
+ .expirationTime(futureDate())
+ .claim("scope", "tools:read tools:execute admin")
+ .build());
+
+ Request request = bearerRequest(token);
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertTrue(tracker.wasCalled(), "JWT with all required scopes should pass");
+ }
+
+ @Test
+ void handleShouldRejectJwtWithInvalidSignature() throws Exception {
+ OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec());
+
+ RSAKey otherKey = new RSAKeyGenerator(2048).keyID("other-key").generate();
+ SignedJWT jwt = new SignedJWT(
+ new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("other-key").build(),
+ new JWTClaimsSet.Builder()
+ .issuer("https://auth.example.com")
+ .audience("https://mcp.example.com/mcp")
+ .expirationTime(futureDate())
+ .build());
+ jwt.sign(new RSASSASigner(otherKey));
+ String token = jwt.serialize();
+
+ Request request = bearerRequest(token);
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus());
+ }
+
+ @Test
+ void handleShouldUseAudienceFieldWhenConfigured() throws Exception {
+ OAuth2AuthenticationSpec spec = minimalSpec();
+ spec.setAudience("custom-audience");
+ TrackingRestlet tracker = new TrackingRestlet();
+ OAuth2AuthenticationRestlet restlet = buildRestlet(spec, tracker);
+
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("https://auth.example.com")
+ .audience("custom-audience")
+ .expirationTime(futureDate())
+ .build());
+
+ Request request = bearerRequest(token);
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertTrue(tracker.wasCalled(),
+ "JWT with matching custom audience should be accepted");
+ }
+
+ @Test
+ void handleShouldRejectMalformedJwt() {
+ OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec());
+
+ Request request = bearerRequest("not.a.valid.jwt");
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus());
+ }
+
+ @Test
+ void validateClaimsShouldPassWhenNoScopesConfigured() throws Exception {
+ OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec());
+
+ JWTClaimsSet claims = new JWTClaimsSet.Builder()
+ .issuer("https://auth.example.com")
+ .audience("https://mcp.example.com/mcp")
+ .expirationTime(futureDate())
+ .build();
+
+ assertNull(restlet.validateClaims(claims),
+ "No scopes configured should not trigger scope validation");
+ }
+
+ @Test
+ void findKeyShouldReturnFirstKeyWhenNoKid() {
+ JWKSet keys = new JWKSet(rsaPublicJWK);
+ assertNotNull(OAuth2AuthenticationRestlet.findKey(keys, null));
+ }
+
+ @Test
+ void findKeyShouldReturnNullForUnknownKid() {
+ JWKSet keys = new JWKSet(rsaPublicJWK);
+ assertNull(OAuth2AuthenticationRestlet.findKey(keys, "unknown-kid"));
+ }
+
+ @Test
+ void buildBearerChallengeParamsShouldReturnEmptyWhenNoParams() {
+ OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec());
+ assertEquals("", restlet.buildBearerChallengeParams(null, null, null));
+ }
+
+ @Test
+ void buildBearerChallengeParamsShouldIncludeErrorAndDescription() {
+ OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec());
+ String params = restlet.buildBearerChallengeParams("invalid_token", "Token expired", null);
+ assertTrue(params.contains("error=\"invalid_token\""));
+ assertTrue(params.contains("error_description=\"Token expired\""));
+ }
+
+ @Test
+ void extractBearerTokenShouldReturnNullWithoutAuthorizationHeader() {
+ OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec());
+ Request request = new Request(Method.POST, "http://localhost/mcp");
+ assertNull(restlet.extractBearerToken(request));
+ }
+
+ @Test
+ void extractBearerTokenShouldExtractFromAuthorizationHeader() {
+ OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec());
+ Request request = bearerRequest("my-token-123");
+ assertEquals("my-token-123", restlet.extractBearerToken(request));
+ }
+
+ @Test
+ void handleShouldReturnForbiddenWhenTokenHasNoScopeClaimButScopesRequired() throws Exception {
+ OAuth2AuthenticationSpec spec = minimalSpec();
+ spec.setScopes(List.of("tools:read"));
+ OAuth2AuthenticationRestlet restlet = buildRestlet(spec);
+
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("https://auth.example.com")
+ .audience("https://mcp.example.com/mcp")
+ .expirationTime(futureDate())
+ .build());
+
+ Request request = bearerRequest(token);
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertEquals(Status.CLIENT_ERROR_FORBIDDEN, response.getStatus());
+ }
+
+ @Test
+ void handleShouldRejectJwtWithNotBeforeInFuture() throws Exception {
+ OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec());
+
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("https://auth.example.com")
+ .audience("https://mcp.example.com/mcp")
+ .expirationTime(futureDate())
+ .notBeforeTime(new Date(System.currentTimeMillis() + 300_000))
+ .build());
+
+ Request request = bearerRequest(token);
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus());
+ String rawValue = response.getChallengeRequests().get(0).getRawValue();
+ assertTrue(rawValue.contains("invalid_token"));
+ assertTrue(rawValue.contains("Token not yet valid"));
+ }
+
+ @Test
+ void handleShouldAcceptJwtWithNotBeforeInPast() throws Exception {
+ TrackingRestlet tracker = new TrackingRestlet();
+ OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec(), tracker);
+
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("https://auth.example.com")
+ .audience("https://mcp.example.com/mcp")
+ .expirationTime(futureDate())
+ .notBeforeTime(new Date(System.currentTimeMillis() - 60_000))
+ .build());
+
+ Request request = bearerRequest(token);
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertTrue(tracker.wasCalled(), "JWT with nbf in the past should be accepted");
+ }
+
+ @Test
+ void handleShouldRejectJwtWithMissingIssuerClaim() throws Exception {
+ OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec());
+
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .audience("https://mcp.example.com/mcp")
+ .expirationTime(futureDate())
+ .build());
+
+ Request request = bearerRequest(token);
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus());
+ String rawValue = response.getChallengeRequests().get(0).getRawValue();
+ assertTrue(rawValue.contains("Invalid issuer"));
+ }
+
+ @Test
+ void handleShouldAcceptIssuerWithTrailingSlash() throws Exception {
+ TrackingRestlet tracker = new TrackingRestlet();
+ OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec(), tracker);
+
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("https://auth.example.com/")
+ .audience("https://mcp.example.com/mcp")
+ .expirationTime(futureDate())
+ .build());
+
+ Request request = bearerRequest(token);
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertTrue(tracker.wasCalled(),
+ "Issuer with trailing slash should match configured issuer without trailing slash");
+ }
+
+ @Test
+ void handleShouldRejectIntrospectionMode() throws Exception {
+ OAuth2AuthenticationSpec spec = minimalSpec();
+ spec.setTokenValidation("introspection");
+ OAuth2AuthenticationRestlet restlet = buildRestlet(spec);
+
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("https://auth.example.com")
+ .expirationTime(futureDate())
+ .build());
+
+ Request request = bearerRequest(token);
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus());
+ String rawValue = response.getChallengeRequests().get(0).getRawValue();
+ assertTrue(rawValue.contains("introspection is not yet supported"));
+ }
+
+ // ─── Helpers ────────────────────────────────────────────────────────────────
+
+ private static OAuth2AuthenticationSpec minimalSpec() {
+ OAuth2AuthenticationSpec spec = new OAuth2AuthenticationSpec();
+ spec.setAuthorizationServerUri("https://auth.example.com");
+ spec.setResource("https://mcp.example.com/mcp");
+ return spec;
+ }
+
+ private OAuth2AuthenticationRestlet buildRestlet(OAuth2AuthenticationSpec spec) {
+ return new OAuth2AuthenticationRestlet(spec, new NoOpRestlet(), jwkSet);
+ }
+
+ private OAuth2AuthenticationRestlet buildRestlet(OAuth2AuthenticationSpec spec,
+ Restlet next) {
+ return new OAuth2AuthenticationRestlet(spec, next, jwkSet);
+ }
+
+ private static String signedJwt(JWTClaimsSet claims) throws Exception {
+ SignedJWT jwt = new SignedJWT(
+ new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaJWK.getKeyID()).build(),
+ claims);
+ jwt.sign(new RSASSASigner(rsaJWK));
+ return jwt.serialize();
+ }
+
+ private static Request bearerRequest(String token) {
+ Request request = new Request(Method.POST, "http://localhost/mcp");
+ request.getHeaders().set("Authorization", "Bearer " + token);
+ return request;
+ }
+
+ private static Date futureDate() {
+ return new Date(System.currentTimeMillis() + 300_000);
+ }
+
+ private static class NoOpRestlet extends Restlet {
+
+ @Override
+ public void handle(Request request, Response response) {
+ response.setStatus(Status.SUCCESS_OK);
+ }
+ }
+
+ private static class TrackingRestlet extends Restlet {
+
+ private boolean called;
+
+ @Override
+ public void handle(Request request, Response response) {
+ called = true;
+ response.setStatus(Status.SUCCESS_OK);
+ }
+
+ boolean wasCalled() {
+ return called;
+ }
+ }
+
+}
diff --git a/src/test/java/io/naftiko/engine/exposes/ServerAdapterAuthenticationTest.java b/src/test/java/io/naftiko/engine/exposes/ServerAdapterAuthenticationTest.java
new file mode 100644
index 00000000..90a21ada
--- /dev/null
+++ b/src/test/java/io/naftiko/engine/exposes/ServerAdapterAuthenticationTest.java
@@ -0,0 +1,243 @@
+/**
+ * Copyright 2025-2026 Naftiko
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.naftiko.engine.exposes;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.restlet.Restlet;
+import org.restlet.routing.Router;
+import org.restlet.security.ChallengeAuthenticator;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import io.naftiko.Capability;
+import io.naftiko.engine.exposes.mcp.McpOAuth2Restlet;
+import io.naftiko.engine.exposes.rest.ServerAuthenticationRestlet;
+import io.naftiko.spec.NaftikoSpec;
+import io.naftiko.util.VersionHelper;
+
+/**
+ * Unit tests for the shared authentication chain wiring in {@link ServerAdapter#buildServerChain}.
+ *
+ * Uses MCP adapter YAML for generic auth tests (the logic is in ServerAdapter, not
+ * adapter-specific). The MCP-specific OAuth 2.1 test verifies the {@code createOAuth2Restlet}
+ * override produces {@link McpOAuth2Restlet}.
+ */
+class ServerAdapterAuthenticationTest {
+
+ private String schemaVersion;
+
+ @BeforeEach
+ void setUp() {
+ schemaVersion = VersionHelper.getSchemaVersion();
+ }
+
+ @Test
+ void buildServerChainShouldReturnRouterWhenNoAuthentication() throws Exception {
+ ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, ""));
+
+ Router router = new Router();
+ Restlet chain = adapter.buildServerChain(router);
+
+ assertSame(router, chain, "Without authentication, chain should be the router itself");
+ }
+
+ @Test
+ void buildServerChainShouldReturnServerAuthenticationRestletForBearer() throws Exception {
+ String authBlock = """
+ authentication:
+ type: "bearer"
+ token: "secret-token"
+ """;
+ ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, authBlock));
+
+ Router router = new Router();
+ Restlet chain = adapter.buildServerChain(router);
+
+ assertInstanceOf(ServerAuthenticationRestlet.class, chain,
+ "Bearer auth should produce ServerAuthenticationRestlet");
+ }
+
+ @Test
+ void buildServerChainShouldReturnServerAuthenticationRestletForApiKey() throws Exception {
+ String authBlock = """
+ authentication:
+ type: "apikey"
+ key: "X-API-Key"
+ value: "abc123"
+ placement: "header"
+ """;
+ ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, authBlock));
+
+ Router router = new Router();
+ Restlet chain = adapter.buildServerChain(router);
+
+ assertInstanceOf(ServerAuthenticationRestlet.class, chain,
+ "API key auth should produce ServerAuthenticationRestlet");
+ }
+
+ @Test
+ void buildServerChainShouldReturnChallengeAuthenticatorForBasic() throws Exception {
+ String authBlock = """
+ authentication:
+ type: "basic"
+ username: "admin"
+ password: "pass"
+ """;
+ ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, authBlock));
+
+ Router router = new Router();
+ Restlet chain = adapter.buildServerChain(router);
+
+ assertInstanceOf(ChallengeAuthenticator.class, chain,
+ "Basic auth should produce ChallengeAuthenticator");
+ }
+
+ @Test
+ void buildServerChainShouldReturnChallengeAuthenticatorForDigest() throws Exception {
+ String authBlock = """
+ authentication:
+ type: "digest"
+ username: "admin"
+ password: "pass"
+ """;
+ ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, authBlock));
+
+ Router router = new Router();
+ Restlet chain = adapter.buildServerChain(router);
+
+ assertInstanceOf(ChallengeAuthenticator.class, chain,
+ "Digest auth should produce ChallengeAuthenticator");
+ }
+
+ @Test
+ void buildServerChainShouldReturnOAuth2RestletForOAuth2() throws Exception {
+ String authBlock = """
+ authentication:
+ type: "oauth2"
+ authorizationServerUri: "https://auth.example.com"
+ resource: "https://api.example.com"
+ scopes:
+ - "read"
+ """;
+ ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, authBlock));
+
+ Router router = new Router();
+ Restlet chain = adapter.buildServerChain(router);
+
+ assertInstanceOf(OAuth2AuthenticationRestlet.class, chain,
+ "OAuth2 auth should produce an OAuth2AuthenticationRestlet subtype");
+ }
+
+ @Test
+ void mcpAdapterShouldReturnMcpOAuth2RestletForOAuth2() throws Exception {
+ String authBlock = """
+ authentication:
+ type: "oauth2"
+ authorizationServerUri: "https://auth.example.com"
+ resource: "https://mcp.example.com/mcp"
+ """;
+ ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, authBlock));
+
+ Router router = new Router();
+ Restlet chain = adapter.buildServerChain(router);
+
+ assertInstanceOf(McpOAuth2Restlet.class, chain,
+ "MCP adapter OAuth2 should produce McpOAuth2Restlet");
+ }
+
+ @Test
+ void skillAdapterShouldReturnGenericOAuth2RestletForOAuth2() throws Exception {
+ String authBlock = """
+ authentication:
+ type: "oauth2"
+ authorizationServerUri: "https://auth.example.com"
+ resource: "https://skills.example.com"
+ scopes:
+ - "read"
+ """;
+ ServerAdapter adapter = adapterFromYaml(SKILL_YAML.formatted(schemaVersion, authBlock));
+
+ Router router = new Router();
+ Restlet chain = adapter.buildServerChain(router);
+
+ assertInstanceOf(OAuth2AuthenticationRestlet.class, chain,
+ "Skill adapter OAuth2 should produce OAuth2AuthenticationRestlet");
+ }
+
+ @Test
+ void serverSpecShouldDeserializeAuthentication() throws Exception {
+ String authBlock = """
+ authentication:
+ type: "bearer"
+ token: "my-token"
+ """;
+ ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, authBlock));
+
+ assertNotNull(adapter.getSpec().getAuthentication(),
+ "Authentication spec should be deserialized");
+ assertNotNull(adapter.getSpec().getAuthentication().getType(),
+ "Auth type should be set");
+ }
+
+ /** MCP adapter YAML template. First %s = schema version, second %s = authentication block. */
+ private static final String MCP_YAML = """
+ naftiko: "%s"
+ capability:
+ exposes:
+ - type: "mcp"
+ address: "localhost"
+ port: 0
+ namespace: "test-mcp"
+ %s tools:
+ - name: "my-tool"
+ description: "A test tool"
+ outputParameters:
+ - type: "string"
+ value: "ok"
+ consumes: []
+ """;
+
+ /** Skill adapter YAML template. First %s = schema version, second %s = authentication block. */
+ private static final String SKILL_YAML = """
+ naftiko: "%s"
+ capability:
+ exposes:
+ - type: "skill"
+ address: "localhost"
+ port: 0
+ namespace: "test-skills"
+ %s skills:
+ - name: "test-skill"
+ description: "A test skill"
+ location: "file:///tmp/test-skill"
+ tools:
+ - name: "test-tool"
+ description: "A test tool"
+ instruction: "guide.md"
+ consumes: []
+ """;
+
+ private static ServerAdapter adapterFromYaml(String yaml) throws Exception {
+ ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
+ mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ NaftikoSpec spec = mapper.readValue(yaml, NaftikoSpec.class);
+ Capability capability = new Capability(spec);
+ return (ServerAdapter) capability.getServerAdapters().get(0);
+ }
+}
diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/McpAuthenticationIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/McpAuthenticationIntegrationTest.java
new file mode 100644
index 00000000..680b3f0a
--- /dev/null
+++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpAuthenticationIntegrationTest.java
@@ -0,0 +1,317 @@
+/**
+ * Copyright 2025-2026 Naftiko
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.naftiko.engine.exposes.mcp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.net.ServerSocket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import io.naftiko.Capability;
+import io.naftiko.spec.NaftikoSpec;
+import io.naftiko.util.VersionHelper;
+
+/**
+ * Integration tests for MCP server authentication. Validates end-to-end HTTP behavior with bearer
+ * and API key authentication through actual HTTP calls against a running MCP server.
+ */
+class McpAuthenticationIntegrationTest {
+
+ private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory())
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ private static final ObjectMapper JSON = new ObjectMapper();
+
+ @Test
+ void bearerAuthShouldRejectRequestWithoutToken() throws Exception {
+ McpServerAdapter adapter = startBearerProtectedServer();
+ HttpClient client = HttpClient.newHttpClient();
+ String baseUrl = baseUrlFor(adapter);
+
+ try {
+ HttpResponse response = client.send(
+ HttpRequest.newBuilder(URI.create(baseUrl))
+ .POST(HttpRequest.BodyPublishers.ofString(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"))
+ .header("Content-Type", "application/json")
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ assertEquals(401, response.statusCode(),
+ "Request without bearer token should be rejected");
+ } finally {
+ adapter.stop();
+ }
+ }
+
+ @Test
+ void bearerAuthShouldRejectRequestWithWrongToken() throws Exception {
+ McpServerAdapter adapter = startBearerProtectedServer();
+ HttpClient client = HttpClient.newHttpClient();
+ String baseUrl = baseUrlFor(adapter);
+
+ try {
+ HttpResponse response = client.send(
+ HttpRequest.newBuilder(URI.create(baseUrl))
+ .POST(HttpRequest.BodyPublishers.ofString(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"))
+ .header("Content-Type", "application/json")
+ .header("Authorization", "Bearer wrong-token")
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ assertEquals(401, response.statusCode(),
+ "Request with wrong bearer token should be rejected");
+ } finally {
+ adapter.stop();
+ }
+ }
+
+ @Test
+ void bearerAuthShouldAcceptRequestWithCorrectToken() throws Exception {
+ McpServerAdapter adapter = startBearerProtectedServer();
+ HttpClient client = HttpClient.newHttpClient();
+ String baseUrl = baseUrlFor(adapter);
+
+ try {
+ HttpResponse response = client.send(
+ HttpRequest.newBuilder(URI.create(baseUrl))
+ .POST(HttpRequest.BodyPublishers.ofString(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"))
+ .header("Content-Type", "application/json")
+ .header("Authorization", "Bearer mcp-secret-token-123")
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ assertEquals(200, response.statusCode(),
+ "Request with correct bearer token should be accepted");
+
+ JsonNode body = JSON.readTree(response.body());
+ assertNotNull(body.path("result").path("protocolVersion").asText(),
+ "Initialize response should contain protocolVersion");
+ } finally {
+ adapter.stop();
+ }
+ }
+
+ @Test
+ void apiKeyAuthShouldRejectRequestWithoutKey() throws Exception {
+ McpServerAdapter adapter = startApiKeyProtectedServer();
+ HttpClient client = HttpClient.newHttpClient();
+ String baseUrl = baseUrlFor(adapter);
+
+ try {
+ HttpResponse response = client.send(
+ HttpRequest.newBuilder(URI.create(baseUrl))
+ .POST(HttpRequest.BodyPublishers.ofString(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"))
+ .header("Content-Type", "application/json")
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ assertEquals(401, response.statusCode(),
+ "Request without API key should be rejected");
+ } finally {
+ adapter.stop();
+ }
+ }
+
+ @Test
+ void apiKeyAuthShouldAcceptRequestWithCorrectKey() throws Exception {
+ McpServerAdapter adapter = startApiKeyProtectedServer();
+ HttpClient client = HttpClient.newHttpClient();
+ String baseUrl = baseUrlFor(adapter);
+
+ try {
+ HttpResponse response = client.send(
+ HttpRequest.newBuilder(URI.create(baseUrl))
+ .POST(HttpRequest.BodyPublishers.ofString(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"))
+ .header("Content-Type", "application/json")
+ .header("X-MCP-Key", "abc-key-456")
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ assertEquals(200, response.statusCode(),
+ "Request with correct API key should be accepted");
+
+ JsonNode body = JSON.readTree(response.body());
+ assertNotNull(body.path("result").path("protocolVersion").asText(),
+ "Initialize response should contain protocolVersion");
+ } finally {
+ adapter.stop();
+ }
+ }
+
+ @Test
+ void bearerAuthShouldProtectAllMethods() throws Exception {
+ McpServerAdapter adapter = startBearerProtectedServer();
+ HttpClient client = HttpClient.newHttpClient();
+ String baseUrl = baseUrlFor(adapter);
+
+ try {
+ // GET without token should be rejected
+ HttpResponse getResponse = client.send(
+ HttpRequest.newBuilder(URI.create(baseUrl)).GET().build(),
+ HttpResponse.BodyHandlers.ofString());
+ assertEquals(401, getResponse.statusCode(),
+ "GET without token should be rejected");
+
+ // DELETE without token should be rejected
+ HttpResponse deleteResponse = client.send(
+ HttpRequest.newBuilder(URI.create(baseUrl))
+ .method("DELETE", HttpRequest.BodyPublishers.noBody())
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+ assertEquals(401, deleteResponse.statusCode(),
+ "DELETE without token should be rejected");
+ } finally {
+ adapter.stop();
+ }
+ }
+
+ @Test
+ void noAuthShouldAllowAllRequests() throws Exception {
+ McpServerAdapter adapter = startUnprotectedServer();
+ HttpClient client = HttpClient.newHttpClient();
+ String baseUrl = baseUrlFor(adapter);
+
+ try {
+ HttpResponse response = client.send(
+ HttpRequest.newBuilder(URI.create(baseUrl))
+ .POST(HttpRequest.BodyPublishers.ofString(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"))
+ .header("Content-Type", "application/json")
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ assertEquals(200, response.statusCode(),
+ "Unprotected server should accept all requests");
+ } finally {
+ adapter.stop();
+ }
+ }
+
+ private McpServerAdapter startBearerProtectedServer() throws Exception {
+ String schemaVersion = VersionHelper.getSchemaVersion();
+ String yaml = """
+ naftiko: "%s"
+ info:
+ label: "MCP Auth Test"
+ description: "Bearer auth integration test"
+ capability:
+ exposes:
+ - type: "mcp"
+ address: "127.0.0.1"
+ port: %d
+ namespace: "auth-test-mcp"
+ description: "Test MCP server with bearer auth"
+ authentication:
+ type: "bearer"
+ token: "mcp-secret-token-123"
+ tools:
+ - name: "test-tool"
+ description: "A test tool"
+ outputParameters:
+ - type: "string"
+ value: "ok"
+ consumes: []
+ """.formatted(schemaVersion, findFreePort());
+
+ return startAdapter(yaml);
+ }
+
+ private McpServerAdapter startApiKeyProtectedServer() throws Exception {
+ String schemaVersion = VersionHelper.getSchemaVersion();
+ String yaml = """
+ naftiko: "%s"
+ info:
+ label: "MCP Auth Test"
+ description: "API key auth integration test"
+ capability:
+ exposes:
+ - type: "mcp"
+ address: "127.0.0.1"
+ port: %d
+ namespace: "auth-test-mcp"
+ description: "Test MCP server with API key auth"
+ authentication:
+ type: "apikey"
+ key: "X-MCP-Key"
+ value: "abc-key-456"
+ placement: "header"
+ tools:
+ - name: "test-tool"
+ description: "A test tool"
+ outputParameters:
+ - type: "string"
+ value: "ok"
+ consumes: []
+ """.formatted(schemaVersion, findFreePort());
+
+ return startAdapter(yaml);
+ }
+
+ private McpServerAdapter startUnprotectedServer() throws Exception {
+ String schemaVersion = VersionHelper.getSchemaVersion();
+ String yaml = """
+ naftiko: "%s"
+ info:
+ label: "MCP Auth Test"
+ description: "No auth integration test"
+ capability:
+ exposes:
+ - type: "mcp"
+ address: "127.0.0.1"
+ port: %d
+ namespace: "auth-test-mcp"
+ description: "Test MCP server without auth"
+ tools:
+ - name: "test-tool"
+ description: "A test tool"
+ outputParameters:
+ - type: "string"
+ value: "ok"
+ consumes: []
+ """.formatted(schemaVersion, findFreePort());
+
+ return startAdapter(yaml);
+ }
+
+ private McpServerAdapter startAdapter(String yaml) throws Exception {
+ NaftikoSpec spec = YAML_MAPPER.readValue(yaml, NaftikoSpec.class);
+ Capability capability = new Capability(spec);
+ McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0);
+ adapter.start();
+ return adapter;
+ }
+
+ private static String baseUrlFor(McpServerAdapter adapter) {
+ return "http://" + adapter.getMcpServerSpec().getAddress() + ":"
+ + adapter.getMcpServerSpec().getPort() + "/";
+ }
+
+ private static int findFreePort() throws Exception {
+ try (ServerSocket socket = new ServerSocket(0)) {
+ return socket.getLocalPort();
+ }
+ }
+}
diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java
new file mode 100644
index 00000000..0dd79fcc
--- /dev/null
+++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java
@@ -0,0 +1,368 @@
+/**
+ * Copyright 2025-2026 Naftiko
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.naftiko.engine.exposes.mcp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.ServerSocket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.Date;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.Restlet;
+import org.restlet.Server;
+import org.restlet.data.MediaType;
+import org.restlet.data.Protocol;
+import org.restlet.data.Status;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import io.naftiko.Capability;
+import io.naftiko.spec.NaftikoSpec;
+import io.naftiko.util.VersionHelper;
+
+/**
+ * Integration tests for MCP OAuth 2.1 authentication. Starts a mock authorization server
+ * (serving AS metadata and JWKS) and an OAuth2-protected MCP server, then exercises the
+ * full authentication flow with real HTTP calls.
+ */
+class McpOAuth2IntegrationTest {
+
+ private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory())
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ private static final ObjectMapper JSON = new ObjectMapper();
+
+ private static RSAKey rsaJWK;
+ private static int mockAsPort;
+ private static Server mockAsServer;
+ private static String jwksJson;
+
+ @BeforeAll
+ static void startMockAuthorizationServer() throws Exception {
+ rsaJWK = new RSAKeyGenerator(2048).keyID("integration-key").generate();
+ RSAKey publicKey = rsaJWK.toPublicJWK();
+ JWKSet jwkSet = new JWKSet(publicKey);
+ jwksJson = jwkSet.toString();
+
+ mockAsPort = findFreePort();
+
+ // Create AS metadata JSON
+ ObjectNode asMetadata = JSON.createObjectNode();
+ asMetadata.put("issuer", "http://127.0.0.1:" + mockAsPort);
+ asMetadata.put("jwks_uri", "http://127.0.0.1:" + mockAsPort + "/jwks");
+ asMetadata.put("authorization_endpoint",
+ "http://127.0.0.1:" + mockAsPort + "/authorize");
+ asMetadata.put("token_endpoint", "http://127.0.0.1:" + mockAsPort + "/token");
+
+ String asMetadataJson = JSON.writeValueAsString(asMetadata);
+
+ // Use plain Restlet chain for the mock AS (no Router/ServerResource needed)
+ Restlet handler = new Restlet() {
+
+ @Override
+ public void handle(Request request, Response response) {
+ String path = request.getResourceRef().getPath();
+ if ("/.well-known/oauth-authorization-server".equals(path)) {
+ response.setStatus(Status.SUCCESS_OK);
+ response.setEntity(asMetadataJson, MediaType.APPLICATION_JSON);
+ } else if ("/jwks".equals(path)) {
+ response.setStatus(Status.SUCCESS_OK);
+ response.setEntity(jwksJson, MediaType.APPLICATION_JSON);
+ } else {
+ response.setStatus(Status.CLIENT_ERROR_NOT_FOUND);
+ }
+ }
+ };
+
+ mockAsServer = new Server(Protocol.HTTP, "127.0.0.1", mockAsPort);
+ mockAsServer.setNext(handler);
+ mockAsServer.start();
+ }
+
+ @AfterAll
+ static void stopMockAuthorizationServer() throws Exception {
+ if (mockAsServer != null) {
+ mockAsServer.stop();
+ }
+ }
+
+ @Test
+ void oauth2ShouldRejectRequestWithoutToken() throws Exception {
+ McpServerAdapter adapter = startOAuth2Server();
+ HttpClient client = HttpClient.newHttpClient();
+ String baseUrl = baseUrlFor(adapter);
+
+ try {
+ HttpResponse response = client.send(
+ HttpRequest.newBuilder(URI.create(baseUrl))
+ .POST(HttpRequest.BodyPublishers.ofString(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"))
+ .header("Content-Type", "application/json")
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ assertEquals(401, response.statusCode(),
+ "Request without bearer token should return 401");
+
+ String wwwAuth = response.headers().firstValue("WWW-Authenticate").orElse("");
+ assertTrue(wwwAuth.contains("Bearer"), "Should contain Bearer challenge");
+ assertTrue(wwwAuth.contains("resource_metadata"),
+ "Should contain resource_metadata URL");
+ } finally {
+ adapter.stop();
+ }
+ }
+
+ @Test
+ void oauth2ShouldRejectExpiredToken() throws Exception {
+ McpServerAdapter adapter = startOAuth2Server();
+ HttpClient client = HttpClient.newHttpClient();
+ String baseUrl = baseUrlFor(adapter);
+
+ try {
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("http://127.0.0.1:" + mockAsPort)
+ .audience("http://127.0.0.1:" + adapter.getMcpServerSpec().getPort() + "/mcp")
+ .expirationTime(new Date(System.currentTimeMillis() - 60_000))
+ .claim("scope", "tools:read")
+ .build());
+
+ HttpResponse response = client.send(
+ HttpRequest.newBuilder(URI.create(baseUrl))
+ .POST(HttpRequest.BodyPublishers.ofString(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"))
+ .header("Content-Type", "application/json")
+ .header("Authorization", "Bearer " + token)
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ assertEquals(401, response.statusCode(),
+ "Expired token should return 401");
+ } finally {
+ adapter.stop();
+ }
+ }
+
+ @Test
+ void oauth2ShouldAcceptValidToken() throws Exception {
+ McpServerAdapter adapter = startOAuth2Server();
+ HttpClient client = HttpClient.newHttpClient();
+ String baseUrl = baseUrlFor(adapter);
+
+ try {
+ String resourceUri = "http://127.0.0.1:" + adapter.getMcpServerSpec().getPort() + "/mcp";
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("http://127.0.0.1:" + mockAsPort)
+ .audience(resourceUri)
+ .expirationTime(new Date(System.currentTimeMillis() + 300_000))
+ .claim("scope", "tools:read")
+ .build());
+
+ HttpResponse response = client.send(
+ HttpRequest.newBuilder(URI.create(baseUrl))
+ .POST(HttpRequest.BodyPublishers.ofString(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"))
+ .header("Content-Type", "application/json")
+ .header("Authorization", "Bearer " + token)
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ assertEquals(200, response.statusCode(),
+ "Valid token should return 200");
+
+ JsonNode body = JSON.readTree(response.body());
+ assertNotNull(body.path("result").path("protocolVersion").asText(null),
+ "Initialize response should contain protocolVersion");
+ } finally {
+ adapter.stop();
+ }
+ }
+
+ @Test
+ void oauth2ShouldServeProtectedResourceMetadata() throws Exception {
+ McpServerAdapter adapter = startOAuth2Server();
+ HttpClient client = HttpClient.newHttpClient();
+ String baseUrl = "http://127.0.0.1:" + adapter.getMcpServerSpec().getPort();
+
+ try {
+ HttpResponse response = client.send(
+ HttpRequest.newBuilder(
+ URI.create(baseUrl + "/.well-known/oauth-protected-resource/mcp"))
+ .GET()
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ assertEquals(200, response.statusCode(),
+ "Protected Resource Metadata should be served");
+
+ JsonNode metadata = JSON.readTree(response.body());
+ assertNotNull(metadata.get("resource"), "Metadata should contain resource");
+ assertNotNull(metadata.get("authorization_servers"),
+ "Metadata should contain authorization_servers");
+ assertEquals("header",
+ metadata.get("bearer_methods_supported").get(0).asText());
+ } finally {
+ adapter.stop();
+ }
+ }
+
+ @Test
+ void oauth2ShouldReturnForbiddenForInsufficientScope() throws Exception {
+ McpServerAdapter adapter = startOAuth2ServerWithScopes();
+ HttpClient client = HttpClient.newHttpClient();
+ String baseUrl = baseUrlFor(adapter);
+
+ try {
+ String resourceUri = "http://127.0.0.1:" + adapter.getMcpServerSpec().getPort() + "/mcp";
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("http://127.0.0.1:" + mockAsPort)
+ .audience(resourceUri)
+ .expirationTime(new Date(System.currentTimeMillis() + 300_000))
+ .claim("scope", "tools:read")
+ .build());
+
+ HttpResponse response = client.send(
+ HttpRequest.newBuilder(URI.create(baseUrl))
+ .POST(HttpRequest.BodyPublishers.ofString(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"))
+ .header("Content-Type", "application/json")
+ .header("Authorization", "Bearer " + token)
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ assertEquals(403, response.statusCode(),
+ "Token missing required scope should return 403");
+
+ String wwwAuth = response.headers().firstValue("WWW-Authenticate").orElse("");
+ assertTrue(wwwAuth.contains("insufficient_scope"));
+ assertTrue(wwwAuth.contains("resource_metadata"));
+ } finally {
+ adapter.stop();
+ }
+ }
+
+ // ─── Server Helpers ─────────────────────────────────────────────────────────
+
+ private McpServerAdapter startOAuth2Server() throws Exception {
+ int port = findFreePort();
+ String resourceUri = "http://127.0.0.1:" + port + "/mcp";
+ String yaml = """
+ naftiko: "%s"
+ info:
+ label: "OAuth2 MCP Test"
+ description: "OAuth2 integration test"
+ capability:
+ exposes:
+ - type: "mcp"
+ address: "127.0.0.1"
+ port: %d
+ namespace: "oauth2-test-mcp"
+ description: "OAuth2 protected MCP server"
+ authentication:
+ type: "oauth2"
+ authorizationServerUri: "http://127.0.0.1:%d"
+ resource: "%s"
+ scopes:
+ - "tools:read"
+ tools:
+ - name: "test-tool"
+ description: "A test tool"
+ outputParameters:
+ - type: "string"
+ value: "ok"
+ consumes: []
+ """.formatted(VersionHelper.getSchemaVersion(), port, mockAsPort, resourceUri);
+
+ return startAdapter(yaml);
+ }
+
+ private McpServerAdapter startOAuth2ServerWithScopes() throws Exception {
+ int port = findFreePort();
+ String resourceUri = "http://127.0.0.1:" + port + "/mcp";
+ String yaml = """
+ naftiko: "%s"
+ info:
+ label: "OAuth2 MCP Test"
+ description: "OAuth2 scope integration test"
+ capability:
+ exposes:
+ - type: "mcp"
+ address: "127.0.0.1"
+ port: %d
+ namespace: "oauth2-test-mcp"
+ description: "OAuth2 protected MCP server with scopes"
+ authentication:
+ type: "oauth2"
+ authorizationServerUri: "http://127.0.0.1:%d"
+ resource: "%s"
+ scopes:
+ - "tools:read"
+ - "tools:execute"
+ tools:
+ - name: "test-tool"
+ description: "A test tool"
+ outputParameters:
+ - type: "string"
+ value: "ok"
+ consumes: []
+ """.formatted(VersionHelper.getSchemaVersion(), port, mockAsPort, resourceUri);
+
+ return startAdapter(yaml);
+ }
+
+ private McpServerAdapter startAdapter(String yaml) throws Exception {
+ NaftikoSpec spec = YAML_MAPPER.readValue(yaml, NaftikoSpec.class);
+ Capability capability = new Capability(spec);
+ McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0);
+ adapter.start();
+ return adapter;
+ }
+
+ private static String signedJwt(JWTClaimsSet claims) throws Exception {
+ SignedJWT jwt = new SignedJWT(
+ new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaJWK.getKeyID()).build(),
+ claims);
+ jwt.sign(new RSASSASigner(rsaJWK));
+ return jwt.serialize();
+ }
+
+ private static String baseUrlFor(McpServerAdapter adapter) {
+ return "http://127.0.0.1:" + adapter.getMcpServerSpec().getPort() + "/";
+ }
+
+ private static int findFreePort() throws Exception {
+ try (ServerSocket socket = new ServerSocket(0)) {
+ return socket.getLocalPort();
+ }
+ }
+
+}
diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java
new file mode 100644
index 00000000..19aef1ca
--- /dev/null
+++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java
@@ -0,0 +1,247 @@
+/**
+ * Copyright 2025-2026 Naftiko
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.naftiko.engine.exposes.mcp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Date;
+import java.util.List;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.Restlet;
+import org.restlet.data.Method;
+import org.restlet.data.Reference;
+import org.restlet.data.Status;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import io.naftiko.spec.consumes.OAuth2AuthenticationSpec;
+
+/**
+ * Unit tests for {@link McpOAuth2Restlet} — MCP-specific OAuth 2.1 behavior including
+ * Protected Resource Metadata and {@code resource_metadata} in WWW-Authenticate.
+ */
+class McpOAuth2RestletTest {
+
+ private static final ObjectMapper JSON = new ObjectMapper();
+
+ private static RSAKey rsaJWK;
+ private static RSAKey rsaPublicJWK;
+ private static JWKSet jwkSet;
+
+ @BeforeAll
+ static void generateKeys() throws Exception {
+ rsaJWK = new RSAKeyGenerator(2048).keyID("mcp-key-1").generate();
+ rsaPublicJWK = rsaJWK.toPublicJWK();
+ jwkSet = new JWKSet(rsaPublicJWK);
+ }
+
+ @Test
+ void handleShouldServeProtectedResourceMetadataOnGetWellKnown() throws Exception {
+ OAuth2AuthenticationSpec spec = specWithScopes();
+ spec.setResource("https://mcp.example.com/");
+ McpOAuth2Restlet restlet = buildRestlet(spec);
+
+ Request request = new Request(Method.GET,
+ "http://localhost/.well-known/oauth-protected-resource");
+ request.setResourceRef(
+ new Reference("http://localhost/.well-known/oauth-protected-resource"));
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertEquals(Status.SUCCESS_OK, response.getStatus());
+ assertNotNull(response.getEntity());
+
+ String body = response.getEntity().getText();
+ JsonNode metadata = JSON.readTree(body);
+
+ assertEquals("https://mcp.example.com/", metadata.get("resource").asText());
+ assertEquals("https://auth.example.com",
+ metadata.get("authorization_servers").get(0).asText());
+ assertTrue(metadata.has("scopes_supported"));
+ assertEquals(2, metadata.get("scopes_supported").size());
+ assertEquals("header", metadata.get("bearer_methods_supported").get(0).asText());
+ }
+
+ @Test
+ void handleShouldServeMetadataAtPathDerivedFromResource() {
+ OAuth2AuthenticationSpec spec = new OAuth2AuthenticationSpec();
+ spec.setAuthorizationServerUri("https://auth.example.com");
+ spec.setResource("https://mcp.example.com/api/v1");
+
+ McpOAuth2Restlet restlet = new McpOAuth2Restlet(spec, new NoOpRestlet(), jwkSet);
+
+ assertEquals("/.well-known/oauth-protected-resource/api/v1",
+ restlet.getMetadataPath());
+ }
+
+ @Test
+ void handleShouldServeMetadataAtRootPathForRootResource() {
+ OAuth2AuthenticationSpec spec = new OAuth2AuthenticationSpec();
+ spec.setAuthorizationServerUri("https://auth.example.com");
+ spec.setResource("https://mcp.example.com/");
+
+ McpOAuth2Restlet restlet = new McpOAuth2Restlet(spec, new NoOpRestlet(), jwkSet);
+
+ assertEquals("/.well-known/oauth-protected-resource",
+ restlet.getMetadataPath());
+ }
+
+ @Test
+ void unauthorizedShouldIncludeResourceMetadataInWwwAuthenticate() {
+ McpOAuth2Restlet restlet = buildRestlet(specWithScopes());
+
+ Request request = new Request(Method.POST, "http://localhost/mcp");
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus());
+ assertFalse(response.getChallengeRequests().isEmpty());
+ String rawValue = response.getChallengeRequests().get(0).getRawValue();
+ assertNotNull(rawValue);
+ assertTrue(rawValue.contains("resource_metadata="),
+ "WWW-Authenticate should contain resource_metadata");
+ assertTrue(rawValue.contains("/.well-known/oauth-protected-resource"),
+ "resource_metadata should point to well-known path");
+ }
+
+ @Test
+ void forbiddenShouldIncludeResourceMetadataInWwwAuthenticate() throws Exception {
+ OAuth2AuthenticationSpec spec = specWithScopes();
+ McpOAuth2Restlet restlet = buildRestlet(spec);
+
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("https://auth.example.com")
+ .audience("https://mcp.example.com/mcp")
+ .expirationTime(futureDate())
+ .claim("scope", "tools:read")
+ .build());
+
+ Request request = bearerRequest(token);
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertEquals(Status.CLIENT_ERROR_FORBIDDEN, response.getStatus());
+ String rawValue = response.getChallengeRequests().get(0).getRawValue();
+ assertTrue(rawValue.contains("resource_metadata="));
+ assertTrue(rawValue.contains("insufficient_scope"));
+ }
+
+ @Test
+ void handleShouldDelegateToParentForValidJwt() throws Exception {
+ TrackingRestlet tracker = new TrackingRestlet();
+ McpOAuth2Restlet restlet = new McpOAuth2Restlet(specWithScopes(), tracker, jwkSet);
+
+ String token = signedJwt(new JWTClaimsSet.Builder()
+ .issuer("https://auth.example.com")
+ .audience("https://mcp.example.com/mcp")
+ .expirationTime(futureDate())
+ .claim("scope", "tools:read tools:execute")
+ .build());
+
+ Request request = bearerRequest(token);
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertTrue(tracker.wasCalled(), "Valid JWT should pass through to next restlet");
+ }
+
+ @Test
+ void handleShouldNotServeMetadataForNonGetRequests() {
+ McpOAuth2Restlet restlet = buildRestlet(specWithScopes());
+
+ Request request = new Request(Method.POST,
+ "http://localhost/.well-known/oauth-protected-resource");
+ request.setResourceRef(
+ new Reference("http://localhost/.well-known/oauth-protected-resource"));
+ Response response = new Response(request);
+
+ restlet.handle(request, response);
+
+ assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus(),
+ "POST to metadata path without token should be rejected, not serve metadata");
+ }
+
+ // ─── Helpers ────────────────────────────────────────────────────────────────
+
+ private static OAuth2AuthenticationSpec specWithScopes() {
+ OAuth2AuthenticationSpec spec = new OAuth2AuthenticationSpec();
+ spec.setAuthorizationServerUri("https://auth.example.com");
+ spec.setResource("https://mcp.example.com/mcp");
+ spec.setScopes(List.of("tools:read", "tools:execute"));
+ return spec;
+ }
+
+ private McpOAuth2Restlet buildRestlet(OAuth2AuthenticationSpec spec) {
+ return new McpOAuth2Restlet(spec, new NoOpRestlet(), jwkSet);
+ }
+
+ private static String signedJwt(JWTClaimsSet claims) throws Exception {
+ SignedJWT jwt = new SignedJWT(
+ new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaJWK.getKeyID()).build(),
+ claims);
+ jwt.sign(new RSASSASigner(rsaJWK));
+ return jwt.serialize();
+ }
+
+ private static Request bearerRequest(String token) {
+ Request request = new Request(Method.POST, "http://localhost/mcp");
+ request.getHeaders().set("Authorization", "Bearer " + token);
+ return request;
+ }
+
+ private static Date futureDate() {
+ return new Date(System.currentTimeMillis() + 300_000);
+ }
+
+ private static class NoOpRestlet extends Restlet {
+
+ @Override
+ public void handle(Request request, Response response) {
+ response.setStatus(Status.SUCCESS_OK);
+ }
+ }
+
+ private static class TrackingRestlet extends Restlet {
+
+ private boolean called;
+
+ @Override
+ public void handle(Request request, Response response) {
+ called = true;
+ response.setStatus(Status.SUCCESS_OK);
+ }
+
+ boolean wasCalled() {
+ return called;
+ }
+ }
+
+}
diff --git a/src/test/java/io/naftiko/engine/exposes/rest/RestServerAdapterTest.java b/src/test/java/io/naftiko/engine/exposes/rest/RestServerAdapterTest.java
index ce691d00..1961eb52 100644
--- a/src/test/java/io/naftiko/engine/exposes/rest/RestServerAdapterTest.java
+++ b/src/test/java/io/naftiko/engine/exposes/rest/RestServerAdapterTest.java
@@ -25,6 +25,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import io.naftiko.Capability;
+import io.naftiko.engine.exposes.ServerAdapter;
import io.naftiko.spec.NaftikoSpec;
import io.naftiko.util.VersionHelper;
@@ -102,7 +103,7 @@ public void extractAllowedVariablesShouldReturnAllBindingKeys() throws Exception
consumes: []
""".formatted(schemaVersion));
- Set keys = RestServerAdapter.extractAllowedVariables(spec);
+ Set keys = ServerAdapter.extractAllowedVariables(spec);
assertEquals(2, keys.size());
assertTrue(keys.contains("auth_token"));