[Server] Oauth2 based on middleware (#221)
* Add Middleware handlers to StreamableHttpTransport * OAuth Implementation based on middleware * OAuth Implementation based on middleware * Add Middleware handlers to StreamableHttpTransport * OAuth Implementation based on middleware * OAuth Implementation based on middleware Signed-off-by: Volodymyr Panivko <sveneld300@gmail.com> * Address PR review feedback for OAuth middleware * Apply php-cs-fixer to satisfy QA * Use Mcp exception classes in OAuth middleware * Refactor OAuth HTTP stack and add Microsoft example policies * Fix QA PHPStan findings in OAuth examples and auth middleware * Require OIDC discovery in JWKS provider and refine OAuth proxy behavior * Remove local development docker artifacts from VCS * Revert .gitignore changes for local docker files * Fix body retrieval in OAuthRequestMetaMiddleware for proper string conversion Signed-off-by: Volodymyr Panivko <sveneld300@gmail.com> * Address OAuth PR review feedback and align example configs * Fix QA style issues and add authorization docs * Fix PHPStan typing issues in OAuth middleware * Remove duplicate Middleware namespace, consolidate under Http\Middleware Remove old Mcp\Server\Transport\Middleware\ classes that were duplicated in Mcp\Server\Transport\Http\Middleware\ and Http\OAuth\. Update docs to reference the correct namespaces. * Address PR review: replace Throwable catch with specific exceptions, add JSON_THROW_ON_ERROR - JwtTokenValidator: replace catch(\Throwable) with explicit \InvalidArgumentException|\UnexpectedValueException|\DomainException, letting RuntimeException from JwksProvider propagate as infrastructure errors - OAuthProxyMiddleware: add \JSON_THROW_ON_ERROR to json_encode for metadata response - Update tests to expect RuntimeException for JWKS infrastructure failures --------- Signed-off-by: Volodymyr Panivko <sveneld300@gmail.com> Co-authored-by: Volodymyr Panivko <volodymyr.panivko@togethernetworks.com>
This commit is contained in:
parent
61c20f1450
commit
e3f4b88768
7 changed files with 6038 additions and 0 deletions
23
examples/server/oauth-keycloak/Dockerfile
Normal file
23
examples/server/oauth-keycloak/Dockerfile
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
FROM php:8.1-fpm-alpine
|
||||
|
||||
# Install dependencies
|
||||
RUN apk add --no-cache \
|
||||
curl \
|
||||
git \
|
||||
unzip
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-install opcache
|
||||
|
||||
# Configure PHP-FPM to listen on TCP
|
||||
RUN sed -i 's/listen = .*/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf
|
||||
|
||||
EXPOSE 9000
|
||||
|
||||
CMD ["php-fpm"]
|
||||
134
examples/server/oauth-keycloak/McpElements.php
Normal file
134
examples/server/oauth-keycloak/McpElements.php
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Example\Server\OAuthKeycloak;
|
||||
|
||||
use Mcp\Capability\Attribute\McpPrompt;
|
||||
use Mcp\Capability\Attribute\McpResource;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Mcp\Server\RequestContext;
|
||||
|
||||
/**
|
||||
* MCP elements for the OAuth Keycloak example.
|
||||
*
|
||||
* These tools demonstrate a protected MCP server.
|
||||
* All requests must include a valid OAuth bearer token.
|
||||
*/
|
||||
final class McpElements
|
||||
{
|
||||
/**
|
||||
* Confirms the user is authenticated.
|
||||
*
|
||||
* The fact that this tool executes means the request passed OAuth validation.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
#[McpTool(
|
||||
name: 'get_auth_status',
|
||||
description: 'Confirm authentication status - only accessible with valid OAuth token'
|
||||
)]
|
||||
public function getAuthStatus(RequestContext $context): array
|
||||
{
|
||||
$meta = $context->getRequest()->getMeta() ?? [];
|
||||
$oauth = isset($meta['oauth']) && \is_array($meta['oauth']) ? $meta['oauth'] : [];
|
||||
$claims = isset($oauth['oauth.claims']) && \is_array($oauth['oauth.claims']) ? $oauth['oauth.claims'] : [];
|
||||
$scopes = isset($oauth['oauth.scopes']) && \is_array($oauth['oauth.scopes']) ? $oauth['oauth.scopes'] : [];
|
||||
|
||||
return [
|
||||
'authenticated' => true,
|
||||
'provider' => 'Keycloak',
|
||||
'message' => 'You have successfully authenticated with OAuth!',
|
||||
'timestamp' => date('c'),
|
||||
'user' => [
|
||||
'subject' => $oauth['oauth.subject'] ?? ($claims['sub'] ?? null),
|
||||
'username' => $claims['preferred_username'] ?? null,
|
||||
'name' => $claims['name'] ?? null,
|
||||
'email' => $claims['email'] ?? null,
|
||||
'issuer' => $claims['iss'] ?? null,
|
||||
'audience' => $claims['aud'] ?? null,
|
||||
'scopes' => $scopes,
|
||||
'expires_at' => isset($claims['exp']) && is_numeric($claims['exp'])
|
||||
? date('c', (int) $claims['exp'])
|
||||
: null,
|
||||
],
|
||||
'note' => 'This endpoint is protected by JWT validation. If you see this, your token was valid.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates calling a protected external API.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
#[McpTool(
|
||||
name: 'call_protected_api',
|
||||
description: 'Simulate calling a protected external API endpoint'
|
||||
)]
|
||||
public function callProtectedApi(
|
||||
string $endpoint,
|
||||
string $method = 'GET',
|
||||
): array {
|
||||
// In a real implementation, you would:
|
||||
// 1. Use token exchange to get a token for the downstream API
|
||||
// 2. Or use client credentials with the user's context
|
||||
// 3. Make the actual HTTP call to the protected API
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'message' => \sprintf('Simulated %s request to %s', $method, $endpoint),
|
||||
'simulated_response' => [
|
||||
'data' => 'This is simulated data from the protected API',
|
||||
'timestamp' => date('c'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current server time and status.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
#[McpResource(
|
||||
uri: 'server://status',
|
||||
name: 'server_status',
|
||||
description: 'Current server status (protected resource)',
|
||||
mimeType: 'application/json'
|
||||
)]
|
||||
public function getServerStatus(): array
|
||||
{
|
||||
return [
|
||||
'status' => 'healthy',
|
||||
'timestamp' => date('c'),
|
||||
'php_version' => \PHP_VERSION,
|
||||
'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2),
|
||||
'protected' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A greeting prompt.
|
||||
*/
|
||||
#[McpPrompt(
|
||||
name: 'greeting',
|
||||
description: 'Generate a greeting message'
|
||||
)]
|
||||
public function greeting(string $style = 'formal'): string
|
||||
{
|
||||
return match ($style) {
|
||||
'casual' => 'Hey there! Welcome to the protected MCP server!',
|
||||
'formal' => 'Good day. Welcome to the OAuth-protected MCP server.',
|
||||
'friendly' => 'Hello! Great to have you here!',
|
||||
default => 'Welcome to the MCP server!',
|
||||
};
|
||||
}
|
||||
}
|
||||
135
examples/server/oauth-keycloak/README.md
Normal file
135
examples/server/oauth-keycloak/README.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# OAuth Keycloak Example
|
||||
|
||||
This example demonstrates MCP server authorization using Keycloak as the OAuth 2.0 / OpenID Connect provider.
|
||||
|
||||
## Features
|
||||
|
||||
- JWT token validation with automatic JWKS discovery
|
||||
- Protected Resource Metadata (RFC 9728) at `/.well-known/oauth-protected-resource`
|
||||
- MCP tools protected by OAuth authentication
|
||||
- Pre-configured Keycloak realm with test user
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Start the services:**
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
2. **Wait for Keycloak to be ready** (may take 30-60 seconds):
|
||||
|
||||
```bash
|
||||
docker compose logs -f keycloak
|
||||
# Wait until you see "Running the server in development mode"
|
||||
```
|
||||
|
||||
3. **Get an access token:**
|
||||
|
||||
```bash
|
||||
# Using Resource Owner Password Credentials (for testing only)
|
||||
TOKEN=$(curl -s -X POST "http://localhost:8180/realms/mcp/protocol/openid-connect/token" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "client_id=mcp-client" \
|
||||
-d "username=demo" \
|
||||
-d "password=demo123" \
|
||||
-d "grant_type=password" \
|
||||
-d "scope=openid mcp" | jq -r '.access_token')
|
||||
|
||||
echo $TOKEN
|
||||
```
|
||||
|
||||
4. **Test the MCP server:**
|
||||
|
||||
```bash
|
||||
# Get Protected Resource Metadata
|
||||
curl http://localhost:8000/.well-known/oauth-protected-resource
|
||||
|
||||
# Call MCP endpoint without token (should get 401)
|
||||
curl -i http://localhost:8000/mcp
|
||||
|
||||
# Call MCP endpoint with token
|
||||
curl -X POST http://localhost:8000/mcp \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
|
||||
```
|
||||
|
||||
5. **Use with MCP Inspector:**
|
||||
|
||||
MCP Inspector can call this server if you provide a valid Bearer token manually (Authorization header). It does not run the OAuth login flow automatically.
|
||||
|
||||
## Keycloak Configuration
|
||||
|
||||
The realm is pre-configured with:
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Realm | `mcp` |
|
||||
| Client (public) | `mcp-client` |
|
||||
| Client (resource) | `mcp-server` |
|
||||
| Test User | `demo` / `demo123` |
|
||||
| Scopes | `mcp:read`, `mcp:write` |
|
||||
|
||||
### Keycloak Admin Console
|
||||
|
||||
Access at http://localhost:8180/admin with:
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ MCP Client │────▶│ Nginx │────▶│ PHP-FPM │
|
||||
│ │ │ (port 8000) │ │ MCP Server │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
│ Get Token │ Validate JWT
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Keycloak │◀───────────────────────────│ JWKS Fetch │
|
||||
│ (port 8180) │ │ │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `docker-compose.yml` - Docker Compose configuration
|
||||
- `Dockerfile` - PHP-FPM container with dependencies
|
||||
- `nginx/default.conf` - Nginx configuration for MCP endpoint
|
||||
- `keycloak/mcp-realm.json` - Pre-configured Keycloak realm
|
||||
- `server.php` - MCP server with OAuth middleware
|
||||
- `McpElements.php` - MCP tools and resources
|
||||
|
||||
## Configuration
|
||||
|
||||
This example uses hard-coded values in `server.php` for consistency with other examples:
|
||||
- Keycloak external URL: `http://localhost:8180`
|
||||
- Keycloak internal URL: `http://keycloak:8180`
|
||||
- Realm: `mcp`
|
||||
- Audience: `mcp-server`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Token validation fails
|
||||
|
||||
1. Ensure Keycloak is fully started (check health endpoint)
|
||||
2. Verify the token hasn't expired (default: 5 minutes)
|
||||
3. Check that the audience claim matches `mcp-server`
|
||||
|
||||
### Connection refused
|
||||
|
||||
1. Wait for Keycloak health check to pass
|
||||
2. Check Docker network connectivity: `docker compose logs`
|
||||
|
||||
### JWKS fetch fails
|
||||
|
||||
The MCP server needs to reach Keycloak at `http://keycloak:8180` (Docker network).
|
||||
For local development outside Docker, use `http://localhost:8180`.
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
69
examples/server/oauth-keycloak/docker-compose.yml
Normal file
69
examples/server/oauth-keycloak/docker-compose.yml
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
services:
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:24.0
|
||||
container_name: mcp-keycloak
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
volumes:
|
||||
- ./keycloak/mcp-realm.json:/opt/keycloak/data/import/mcp-realm.json:ro
|
||||
command:
|
||||
- start-dev
|
||||
- --import-realm
|
||||
- --http-port=8180
|
||||
ports:
|
||||
- "8180:8180"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8180;echo -e 'GET /health/ready HTTP/1.1\r\nhost: localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 15
|
||||
start_period: 30s
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
php:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: mcp-php
|
||||
volumes:
|
||||
- ../../../:/app
|
||||
working_dir: /app
|
||||
environment:
|
||||
KEYCLOAK_EXTERNAL_URL: http://localhost:8180
|
||||
KEYCLOAK_INTERNAL_URL: http://keycloak:8180
|
||||
KEYCLOAK_REALM: mcp
|
||||
MCP_AUDIENCE: mcp-server
|
||||
depends_on:
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
command: >
|
||||
sh -c "mkdir -p /app/examples/server/oauth-keycloak/sessions;
|
||||
chmod -R 0777 /app/examples/server/oauth-keycloak/sessions;
|
||||
touch /app/examples/server/oauth-keycloak/dev.log;
|
||||
chmod 0666 /app/examples/server/oauth-keycloak/dev.log;
|
||||
touch /app/examples/server/dev.log;
|
||||
chmod 0666 /app/examples/server/dev.log;
|
||||
composer install --no-interaction --quiet 2>/dev/null || true;
|
||||
php-fpm"
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: mcp-nginx
|
||||
ports:
|
||||
- "8000:80"
|
||||
volumes:
|
||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ../../../:/app:ro
|
||||
depends_on:
|
||||
- php
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
networks:
|
||||
mcp-network:
|
||||
driver: bridge
|
||||
128
examples/server/oauth-keycloak/keycloak/mcp-realm.json
Normal file
128
examples/server/oauth-keycloak/keycloak/mcp-realm.json
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
{
|
||||
"realm": "mcp",
|
||||
"enabled": true,
|
||||
"registrationAllowed": false,
|
||||
"loginWithEmailAllowed": true,
|
||||
"duplicateEmailsAllowed": false,
|
||||
"resetPasswordAllowed": true,
|
||||
"editUsernameAllowed": false,
|
||||
"bruteForceProtected": true,
|
||||
"accessTokenLifespan": 300,
|
||||
"ssoSessionIdleTimeout": 1800,
|
||||
"ssoSessionMaxLifespan": 36000,
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "mcp-client",
|
||||
"name": "MCP Client Application",
|
||||
"description": "Public client for MCP client applications",
|
||||
"enabled": true,
|
||||
"publicClient": true,
|
||||
"standardFlowEnabled": true,
|
||||
"directAccessGrantsEnabled": true,
|
||||
"serviceAccountsEnabled": false,
|
||||
"authorizationServicesEnabled": false,
|
||||
"fullScopeAllowed": true,
|
||||
"redirectUris": [
|
||||
"http://localhost:*",
|
||||
"http://127.0.0.1:*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:*",
|
||||
"http://127.0.0.1:*"
|
||||
],
|
||||
"defaultClientScopes": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"mcp"
|
||||
],
|
||||
"optionalClientScopes": [],
|
||||
"attributes": {
|
||||
"pkce.code.challenge.method": "S256"
|
||||
}
|
||||
},
|
||||
{
|
||||
"clientId": "mcp-server",
|
||||
"name": "MCP Server Resource",
|
||||
"description": "Resource server representing the MCP server",
|
||||
"enabled": true,
|
||||
"publicClient": false,
|
||||
"bearerOnly": true,
|
||||
"standardFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"authorizationServicesEnabled": false
|
||||
}
|
||||
],
|
||||
"clientScopes": [
|
||||
{
|
||||
"name": "mcp",
|
||||
"description": "MCP access scope",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Access to MCP server resources"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "mcp-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "mcp-server",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mcp-scopes",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-hardcoded-claim-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"claim.name": "scope",
|
||||
"claim.value": "mcp:read mcp:write",
|
||||
"jsonType.label": "String",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "false"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"username": "demo",
|
||||
"email": "demo@example.com",
|
||||
"emailVerified": true,
|
||||
"enabled": true,
|
||||
"firstName": "Demo",
|
||||
"lastName": "User",
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "demo123",
|
||||
"temporary": false
|
||||
}
|
||||
],
|
||||
"realmRoles": ["default-roles-mcp"]
|
||||
}
|
||||
],
|
||||
"defaultDefaultClientScopes": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email"
|
||||
],
|
||||
"roles": {
|
||||
"realm": [
|
||||
{
|
||||
"name": "default-roles-mcp",
|
||||
"description": "Default roles for MCP realm",
|
||||
"composite": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
25
examples/server/oauth-keycloak/nginx/default.conf
Normal file
25
examples/server/oauth-keycloak/nginx/default.conf
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /app/examples/server/oauth-keycloak;
|
||||
|
||||
# Route all requests through PHP
|
||||
location / {
|
||||
try_files $uri /server.php$is_args$args;
|
||||
}
|
||||
|
||||
# PHP processing
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass php:9000;
|
||||
fastcgi_index server.php;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
|
||||
# Pass all request info
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
fastcgi_param QUERY_STRING $query_string;
|
||||
fastcgi_param REQUEST_METHOD $request_method;
|
||||
fastcgi_param CONTENT_TYPE $content_type;
|
||||
fastcgi_param CONTENT_LENGTH $content_length;
|
||||
}
|
||||
}
|
||||
68
examples/server/oauth-keycloak/server.php
Normal file
68
examples/server/oauth-keycloak/server.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__).'/bootstrap.php';
|
||||
|
||||
use Http\Discovery\Psr17Factory;
|
||||
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\Session\FileSessionStore;
|
||||
use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
|
||||
use Mcp\Server\Transport\Http\Middleware\OAuthRequestMetaMiddleware;
|
||||
use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware;
|
||||
use Mcp\Server\Transport\Http\OAuth\JwksProvider;
|
||||
use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator;
|
||||
use Mcp\Server\Transport\Http\OAuth\OidcDiscovery;
|
||||
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
|
||||
use Mcp\Server\Transport\StreamableHttpTransport;
|
||||
|
||||
$externalIssuer = 'http://localhost:8180/realms/mcp';
|
||||
$internalIssuer = 'http://keycloak:8180/realms/mcp';
|
||||
|
||||
$validator = new JwtTokenValidator(
|
||||
issuer: [$externalIssuer, $internalIssuer],
|
||||
audience: 'mcp-server',
|
||||
jwksProvider: new JwksProvider(new OidcDiscovery()),
|
||||
jwksUri: $internalIssuer.'/protocol/openid-connect/certs',
|
||||
);
|
||||
|
||||
$protectedResourceMetadata = new ProtectedResourceMetadata(
|
||||
authorizationServers: [$externalIssuer],
|
||||
scopesSupported: ['openid'],
|
||||
resource: 'http://localhost:8000/mcp',
|
||||
resourceName: 'OAuth Keycloak Example MCP Server',
|
||||
);
|
||||
|
||||
$metadataMiddleware = new ProtectedResourceMetadataMiddleware($protectedResourceMetadata);
|
||||
|
||||
$authMiddleware = new AuthorizationMiddleware(
|
||||
$validator,
|
||||
$protectedResourceMetadata,
|
||||
);
|
||||
|
||||
$server = Server::builder()
|
||||
->setServerInfo('OAuth Keycloak Example', '1.0.0')
|
||||
->setLogger(logger())
|
||||
->setSession(new FileSessionStore(__DIR__.'/sessions'))
|
||||
->setDiscovery(__DIR__)
|
||||
->build();
|
||||
|
||||
$transport = new StreamableHttpTransport(
|
||||
(new Psr17Factory())->createServerRequestFromGlobals(),
|
||||
logger: logger(),
|
||||
middleware: [$metadataMiddleware, $authMiddleware, new OAuthRequestMetaMiddleware()],
|
||||
);
|
||||
|
||||
$response = $server->run($transport);
|
||||
|
||||
(new SapiEmitter())->emit($response);
|
||||
Loading…
Reference in a new issue