[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:
Volodymyr Panivko 2026-03-10 17:10:07 +01:00 • committed by GitHub
commit e3f4b88768
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 6038 additions and 0 deletions

View file

@ -26,6 +26,7 @@
"psr/clock": "^1.0",
"psr/container": "^1.0 || ^2.0",
"psr/event-dispatcher": "^1.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.1",
"psr/http-message": "^1.1 || ^2.0",
"psr/http-server-handler": "^1.0",
@ -35,6 +36,8 @@
"symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0"
},
"require-dev": {
"ext-openssl": "*",
"firebase/php-jwt": "^6.10 || ^7.0",
"laminas/laminas-httphandlerrunner": "^2.12",
"nyholm/psr7": "^1.8",
"nyholm/psr7-server": "^1.1",
@ -46,6 +49,7 @@
"psr/simple-cache": "^2.0 || ^3.0",
"symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/http-client": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0"
},
"autoload": {
@ -68,6 +72,8 @@
"Mcp\\Example\\Server\\DiscoveryUserProfile\\": "examples/server/discovery-userprofile/",
"Mcp\\Example\\Server\\EnvVariables\\": "examples/server/env-variables/",
"Mcp\\Example\\Server\\ExplicitRegistration\\": "examples/server/explicit-registration/",
"Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/",
"Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/",
"Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/",
"Mcp\\Tests\\": "tests/"
}

430
docs/authorization.md Normal file
View file

@ -0,0 +1,430 @@
# Authorization
The PHP MCP SDK provides OAuth 2.1 authorization support for HTTP transports, implementing the
[MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
## Table of Contents
- [Overview](#overview)
- [Quick Start](#quick-start)
- [Components](#components)
- [JWT Token Validation](#jwt-token-validation)
- [Protected Resource Metadata](#protected-resource-metadata)
- [Custom Token Validators](#custom-token-validators)
- [Scope-Based Access Control](#scope-based-access-control)
- [Examples](#examples)
## Overview
Authorization in MCP is implemented at the transport level using PSR-15 middleware. The SDK provides:
- **AuthorizationMiddleware** - PSR-15 middleware that enforces bearer token authentication
- **ProtectedResourceMetadataMiddleware** - Serves RFC 9728 metadata at well-known endpoints
- **OAuthProxyMiddleware** - Proxies OAuth flows to upstream authorization servers
- **OAuthRequestMetaMiddleware** - Bridges HTTP OAuth attributes to JSON-RPC request meta
- **JwtTokenValidator** - Validates JWT tokens using JWKS from OAuth 2.0 / OIDC providers
- **OidcDiscovery** - Discovers authorization server metadata from well-known endpoints
```
┌─────────────┐ ┌────────────────────┐ ┌─────────────────┐
│ MCP Client │────▶│ AuthorizationMiddleware │────▶│ MCP Handlers │
└─────────────┘ └────────────────────┘ └─────────────────┘
│ │
│ │ Validate JWT
▼ ▼
┌─────────────┐ ┌─────────────────┐
│ Auth Server │◀────│ JwtTokenValidator│
│ (Keycloak, │ │ + JWKS │
│ Entra ID) │ └─────────────────┘
└─────────────┘
```
## Quick Start
```php
use Mcp\Server;
use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
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;
// 1. Set up OIDC discovery and JWKS provider
$discovery = new OidcDiscovery();
$jwksProvider = new JwksProvider($discovery);
// 2. Create JWT validator for your OAuth provider
$validator = new JwtTokenValidator(
issuer: 'https://auth.example.com/realms/mcp',
audience: 'mcp-server',
jwksProvider: $jwksProvider,
);
// 3. Create Protected Resource Metadata (RFC 9728)
$metadata = new ProtectedResourceMetadata(
authorizationServers: ['https://auth.example.com/realms/mcp'],
scopesSupported: ['mcp:read', 'mcp:write'],
);
// 4. Create middleware stack
$authMiddleware = new AuthorizationMiddleware(
validator: $validator,
resourceMetadata: $metadata,
);
$metadataMiddleware = new ProtectedResourceMetadataMiddleware(
metadata: $metadata,
);
// 5. Create transport with middleware
$transport = new StreamableHttpTransport(
$request,
middlewares: [$metadataMiddleware, $authMiddleware],
);
// 6. Run server
$server = Server::builder()
->setServerInfo('Protected MCP Server', '1.0.0')
->setDiscovery(__DIR__)
->build();
$response = $server->run($transport);
```
## Components
### AuthorizationMiddleware
The main middleware that enforces authentication:
```php
$middleware = new AuthorizationMiddleware(
validator: $validator, // AuthorizationTokenValidatorInterface
resourceMetadata: $metadata, // ProtectedResourceMetadata instance
responseFactory: null, // PSR-17 (auto-discovered)
);
```
**Behavior:**
| Request | Response |
|---------|----------|
| Missing Authorization header | 401 with `WWW-Authenticate: Bearer resource_metadata="..."` |
| Invalid/expired token | 401 with error details |
| Valid token | Passes to next handler with OAuth attributes on request |
### ProtectedResourceMetadataMiddleware
Serves Protected Resource Metadata at configured well-known paths:
```php
$metadataMiddleware = new ProtectedResourceMetadataMiddleware(
metadata: $metadata, // ProtectedResourceMetadata instance
responseFactory: null, // PSR-17 (auto-discovered)
streamFactory: null, // PSR-17 (auto-discovered)
);
```
### JwtTokenValidator
Validates JWT access tokens:
```php
$validator = new JwtTokenValidator(
issuer: 'https://auth.example.com', // Expected issuer claim
audience: 'mcp-server', // Expected audience (string or array)
jwksProvider: $jwksProvider, // JwksProviderInterface
jwksUri: null, // Explicit JWKS URI (auto-discovered)
algorithms: ['RS256', 'RS384'], // Allowed algorithms
scopeClaim: 'scope', // Claim name for scopes
);
```
**Request Attributes:**
After successful validation, these attributes are added to the request:
| Attribute | Description |
|-----------|-------------|
| `oauth.claims` | All JWT claims as array |
| `oauth.scopes` | Extracted scopes as array |
| `oauth.subject` | The `sub` claim |
| `oauth.client_id` | The `client_id` claim (if present) |
| `oauth.authorized_party` | The `azp` claim (if present) |
### ProtectedResourceMetadata
Represents RFC 9728 Protected Resource Metadata:
```php
$metadata = new ProtectedResourceMetadata(
authorizationServers: [ // Required: authorization server URLs
'https://auth.example.com',
],
scopesSupported: [ // Optional: supported scopes
'mcp:read',
'mcp:write',
],
resource: 'https://mcp.example.com', // Optional: resource identifier
resourceName: 'My MCP Server', // Optional: human-readable name
metadataPaths: [ // Paths to serve metadata (default: /.well-known/oauth-protected-resource)
'/.well-known/oauth-protected-resource',
],
extra: [ // Optional: additional fields
'custom_field' => 'value',
],
);
```
### OidcDiscovery
Discovers OAuth/OIDC server metadata:
```php
$discovery = new OidcDiscovery(
httpClient: null, // PSR-18 (auto-discovered)
requestFactory: null, // PSR-17 (auto-discovered)
cache: $cache, // PSR-16 cache (optional)
cacheTtl: 3600, // Cache TTL
);
// Discover metadata
$metadata = $discovery->discover('https://auth.example.com/realms/mcp');
// Get specific endpoints
$jwksUri = $discovery->getJwksUri($issuer);
$tokenEndpoint = $discovery->getTokenEndpoint($issuer);
$authEndpoint = $discovery->getAuthorizationEndpoint($issuer);
```
### JwksProvider
Fetches and caches JWKS key sets:
```php
$jwksProvider = new JwksProvider(
discovery: $discovery, // OidcDiscoveryInterface
httpClient: null, // PSR-18 (auto-discovered)
requestFactory: null, // PSR-17 (auto-discovered)
cache: $cache, // PSR-16 cache (optional)
cacheTtl: 3600, // JWKS cache TTL
);
```
## JWT Token Validation
### Keycloak
```php
$validator = new JwtTokenValidator(
issuer: 'https://keycloak.example.com/realms/mcp',
audience: 'mcp-server',
jwksProvider: $jwksProvider,
);
```
### Microsoft Entra ID (Azure AD)
```php
$tenantId = 'your-tenant-id';
$clientId = 'your-client-id';
$validator = new JwtTokenValidator(
issuer: "https://login.microsoftonline.com/{$tenantId}/v2.0",
audience: $clientId,
jwksProvider: $jwksProvider,
);
```
### Auth0
```php
$validator = new JwtTokenValidator(
issuer: 'https://your-tenant.auth0.com/',
audience: 'https://api.example.com',
jwksProvider: $jwksProvider,
);
```
### Okta
```php
$validator = new JwtTokenValidator(
issuer: 'https://your-org.okta.com/oauth2/default',
audience: 'api://default',
jwksProvider: $jwksProvider,
);
```
## Protected Resource Metadata
The `ProtectedResourceMetadataMiddleware` serves Protected Resource Metadata at configured paths, enabling clients to discover the authorization server:
```json
{
"authorization_servers": ["https://auth.example.com/realms/mcp"],
"scopes_supported": ["mcp:read", "mcp:write"],
"resource": "https://mcp.example.com/mcp"
}
```
Clients request this from `/.well-known/oauth-protected-resource` before authenticating.
### WWW-Authenticate Header
On 401 responses, the middleware includes:
```
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="mcp:read mcp:write"
```
## Custom Token Validators
Implement `AuthorizationTokenValidatorInterface` for custom validation:
```php
use Mcp\Server\Transport\Http\OAuth\AuthorizationTokenValidatorInterface;
use Mcp\Server\Transport\Http\OAuth\AuthorizationResult;
final class ApiKeyValidator implements AuthorizationTokenValidatorInterface
{
public function __construct(
private array $validKeys,
) {}
public function validate(string $accessToken): AuthorizationResult
{
if (!isset($this->validKeys[$accessToken])) {
return AuthorizationResult::unauthorized(
'invalid_token',
'Unknown API key'
);
}
$keyInfo = $this->validKeys[$accessToken];
return AuthorizationResult::allow([
'api_key.name' => $keyInfo['name'],
'api_key.scopes' => $keyInfo['scopes'],
]);
}
}
// Usage
$validator = new ApiKeyValidator([
'sk_live_abc123' => ['name' => 'Production', 'scopes' => ['read', 'write']],
]);
```
### AuthorizationResult
Factory methods for different outcomes:
```php
// Allow access with attributes
AuthorizationResult::allow(['user_id' => '123']);
// Deny - missing/invalid token (401)
AuthorizationResult::unauthorized('invalid_token', 'Token expired');
// Deny - valid token but insufficient permissions (403)
AuthorizationResult::forbidden('insufficient_scope', 'Requires admin scope', ['admin']);
// Deny - malformed request (400)
AuthorizationResult::badRequest('invalid_request', 'Malformed header');
```
## Scope-Based Access Control
### Checking Scopes in Handlers
```php
#[McpTool(name: 'admin_action')]
public function adminAction(RequestContext $context): array
{
$scopes = $context->getRequest()?->getAttribute('oauth.scopes') ?? [];
if (!in_array('mcp:admin', $scopes, true)) {
throw new \RuntimeException('Admin scope required');
}
// Perform admin action
return ['status' => 'success'];
}
```
### Using JwtTokenValidator::requireScopes
```php
// In a custom middleware or handler
$result = $validator->validate($token);
if ($result->isAllowed()) {
// Check for specific scopes
$result = $validator->requireScopes($result, ['mcp:write']);
}
if (!$result->isAllowed()) {
// Handle insufficient scope (returns 403)
}
```
## Examples
Complete working examples are available in the `examples/server/` directory:
### Keycloak Example
```bash
cd examples/server/oauth-keycloak
docker-compose up -d
# Test credentials: demo / demo123
```
See [oauth-keycloak/README.md](../examples/server/oauth-keycloak/README.md)
### Microsoft Entra ID Example
```bash
cd examples/server/oauth-microsoft
cp env.example .env
# Edit .env with your Azure credentials
docker-compose up -d
```
See [oauth-microsoft/README.md](../examples/server/oauth-microsoft/README.md)
## Security Considerations
1. **Always use HTTPS** in production for token transmission
2. **Validate audience claims** to prevent token confusion attacks
3. **Use short-lived tokens** and implement token refresh
4. **Cache JWKS** to reduce latency but allow for key rotation
5. **Never log tokens** - log only non-sensitive claims like subject
6. **Validate scopes** before performing sensitive operations
## Troubleshooting
### "Invalid issuer" error
The `iss` claim in the token must exactly match the configured issuer URL, including trailing slashes.
### "Invalid audience" error
Check the `aud` claim matches your configured audience. Some providers use the client ID, others use a custom URI.
### JWKS fetch timeout
- Ensure network connectivity to the authorization server
- Consider using a cache to reduce dependency on the auth server
- Check firewall rules allow outbound HTTPS
### Token expired
- Check clock synchronization between servers
- Tokens typically have a 5-minute clock skew tolerance
- Ensure clients refresh tokens before expiration

View 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"]

View 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!',
};
}
}

View 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
```

View 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

View 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
}
]
}
}

View 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;
}
}

View 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);

View file

@ -0,0 +1,3 @@
AZURE_TENANT_ID=
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=

View 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"]

View file

@ -0,0 +1,154 @@
<?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\OAuthMicrosoft;
use Mcp\Capability\Attribute\McpPrompt;
use Mcp\Capability\Attribute\McpResource;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Server\RequestContext;
/**
* MCP elements for the OAuth Microsoft example.
*
* These tools demonstrate a protected MCP server using Microsoft Entra ID.
* All requests must include a valid Microsoft-issued OAuth bearer token.
*/
final class McpElements
{
/**
* Confirms the user is authenticated with Microsoft.
*
* @return array<string, mixed>
*/
#[McpTool(
name: 'get_auth_status',
description: 'Confirm Microsoft Entra ID authentication status'
)]
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' => 'Microsoft Entra ID',
'message' => 'You have successfully authenticated with Microsoft!',
'timestamp' => date('c'),
'user' => [
'subject' => $oauth['oauth.subject'] ?? ($claims['sub'] ?? null),
'object_id' => $oauth['oauth.object_id'] ?? ($claims['oid'] ?? null),
'username' => $claims['preferred_username'] ?? ($claims['upn'] ?? null),
'name' => $oauth['oauth.name'] ?? ($claims['name'] ?? null),
'email' => $claims['email'] ?? null,
'issuer' => $claims['iss'] ?? null,
'audience' => $claims['aud'] ?? null,
'tenant_id' => $claims['tid'] ?? null,
'scopes' => $scopes,
'expires_at' => isset($claims['exp']) && is_numeric($claims['exp'])
? date('c', (int) $claims['exp'])
: null,
],
];
}
/**
* Simulates calling Microsoft Graph API.
*
* @return array<string, mixed>
*/
#[McpTool(
name: 'call_graph_api',
description: 'Simulate calling Microsoft Graph API'
)]
public function callGraphApi(
string $endpoint = '/me',
): array {
// In a real implementation, you would:
// 1. Use the On-Behalf-Of flow to exchange tokens
// 2. Call Microsoft Graph with the new token
return [
'status' => 'simulated',
'endpoint' => "https://graph.microsoft.com/v1.0{$endpoint}",
'message' => 'Configure AZURE_CLIENT_SECRET for actual Graph API calls',
'simulated_response' => [
'displayName' => 'Demo User',
'mail' => 'demo@example.com',
],
];
}
/**
* Lists simulated emails.
*
* @return array<string, mixed>
*/
#[McpTool(
name: 'list_emails',
description: 'List recent emails (simulated)'
)]
public function listEmails(int $count = 5): array
{
return [
'note' => 'Simulated data. Implement Graph API call with Mail.Read scope for real emails.',
'emails' => array_map(static fn ($i) => [
'id' => 'msg_'.uniqid(),
'subject' => "Sample Email #{$i}",
'from' => "sender{$i}@example.com",
'receivedDateTime' => date('c', strtotime("-{$i} hours")),
], range(1, $count)),
];
}
/**
* Returns the current server status.
*
* @return array<string, mixed>
*/
#[McpResource(
uri: 'server://status',
name: 'server_status',
description: 'Current server status with Microsoft auth info',
mimeType: 'application/json'
)]
public function getServerStatus(): array
{
return [
'status' => 'healthy',
'timestamp' => date('c'),
'auth_provider' => 'Microsoft Entra ID',
'php_version' => \PHP_VERSION,
'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2),
];
}
/**
* A Microsoft Teams-style message prompt.
*/
#[McpPrompt(
name: 'teams_message',
description: 'Generate a Microsoft Teams-style message'
)]
public function teamsMessage(string $messageType = 'announcement'): string
{
return match ($messageType) {
'announcement' => "📢 **Announcement**\n\nPlease add your announcement content here.",
'question' => "❓ **Question**\n\nType your question here.",
'update' => "📋 **Status Update**\n\n**Progress:**\n- Item 1\n- Item 2",
default => "💬 **Message**\n\nYour message content here.",
};
}
}

View file

@ -0,0 +1,187 @@
<?php
/*
* 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\OAuthMicrosoft;
use Mcp\Server\Transport\Http\OAuth\AuthorizationResult;
use Mcp\Server\Transport\Http\OAuth\AuthorizationTokenValidatorInterface;
use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator;
/**
* Validates Microsoft Entra ID access tokens.
*
* This validator supports two Microsoft token modes:
* - Standard JWT validation via JWKS (delegated/client credentials tokens)
* - Claim-based Microsoft Graph token validation for tokens with a "nonce" header
*
* Security notice:
* Tokens treated as Graph tokens (header contains "nonce") are validated by
* claims only in this example and are NOT signature-verified.
* This mode is intended for local/demo interoperability only and should not be
* used as-is in production deployments.
*
* @author Volodymyr Panivko <sveneld300@gmail.com>
*/
class MicrosoftJwtTokenValidator implements AuthorizationTokenValidatorInterface
{
/**
* @param JwtTokenValidator $jwtTokenValidator Base JWT validator used for non-Graph tokens
* @param string $scopeClaim Claim name for scopes in Graph tokens
* @param list<string> $trustedGraphIssuers Allowed Graph issuer host markers
* @param int $notBeforeLeewaySeconds Allowed clock skew for "nbf" claim
*/
public function __construct(
private readonly JwtTokenValidator $jwtTokenValidator,
private readonly string $scopeClaim = 'scp',
private readonly array $trustedGraphIssuers = ['sts.windows.net', 'login.microsoftonline.com'],
private readonly int $notBeforeLeewaySeconds = 60,
) {
}
public function validate(string $accessToken): AuthorizationResult
{
$parts = explode('.', $accessToken);
if (!$this->isGraphToken($parts)) {
return $this->jwtTokenValidator->validate($accessToken);
}
return $this->validateGraphToken($parts);
}
/**
* Validates a token has the required scopes.
*
* Use this after validation to check specific scope requirements.
*
* @param AuthorizationResult $result The result from validate()
* @param list<string> $requiredScopes Scopes required for this operation
*
* @return AuthorizationResult The original result if scopes are sufficient, forbidden otherwise
*/
public function requireScopes(AuthorizationResult $result, array $requiredScopes): AuthorizationResult
{
return $this->jwtTokenValidator->requireScopes($result, $requiredScopes);
}
/**
* @param array<string> $parts
*/
private function isGraphToken(array $parts): bool
{
if ([] === $parts) {
return false;
}
$header = $this->decodePartToArray($parts[0]);
if (null === $header) {
return false;
}
return isset($header['nonce']);
}
/**
* @param array<string> $parts
*/
private function validateGraphToken(array $parts): AuthorizationResult
{
// Intentionally claim-based only for example Graph token compatibility.
if (\count($parts) < 2) {
return AuthorizationResult::unauthorized('invalid_token', 'Invalid token format.');
}
$payload = $this->decodePartToArray($parts[1]);
if (null === $payload) {
return AuthorizationResult::unauthorized('invalid_token', 'Invalid token payload.');
}
if (isset($payload['exp']) && is_numeric($payload['exp']) && (int) $payload['exp'] < time()) {
return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.');
}
if (isset($payload['nbf']) && is_numeric($payload['nbf']) && (int) $payload['nbf'] > time() + $this->notBeforeLeewaySeconds) {
return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.');
}
$issuer = $payload['iss'] ?? '';
if (!\is_string($issuer) || !$this->isTrustedGraphIssuer($issuer)) {
return AuthorizationResult::unauthorized('invalid_token', 'Invalid token issuer for Graph token.');
}
$scopes = $this->extractScopes($payload);
$attributes = [
'oauth.claims' => $payload,
'oauth.scopes' => $scopes,
'oauth.graph_token' => true,
];
if (isset($payload['sub'])) {
$attributes['oauth.subject'] = $payload['sub'];
}
if (isset($payload['oid'])) {
$attributes['oauth.object_id'] = $payload['oid'];
}
if (isset($payload['name'])) {
$attributes['oauth.name'] = $payload['name'];
}
return AuthorizationResult::allow($attributes);
}
private function isTrustedGraphIssuer(string $issuer): bool
{
$host = parse_url($issuer, \PHP_URL_HOST);
return \in_array($host, $this->trustedGraphIssuers, true);
}
/**
* @param array<string, mixed> $claims
*
* @return list<string>
*/
private function extractScopes(array $claims): array
{
if (!isset($claims[$this->scopeClaim])) {
return [];
}
$scopeValue = $claims[$this->scopeClaim];
if (\is_array($scopeValue)) {
return array_values(array_filter($scopeValue, 'is_string'));
}
if (\is_string($scopeValue)) {
return array_values(array_filter(explode(' ', $scopeValue)));
}
return [];
}
/**
* @return array<string, mixed>|null
*/
private function decodePartToArray(string $part): ?array
{
$decoded = base64_decode(strtr($part, '-_', '+/'));
if (false === $decoded) {
return null;
}
$data = json_decode($decoded, true);
return \is_array($data) ? $data : null;
}
}

View file

@ -0,0 +1,38 @@
<?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\OAuthMicrosoft;
use Mcp\Server\Transport\Http\OAuth\OidcDiscoveryMetadataPolicyInterface;
/**
* Lenient metadata policy for Microsoft discovery quirks.
*
* `code_challenge_methods_supported` is optional in this policy.
*
* @author Volodymyr Panivko <sveneld300@gmail.com>
*/
final class MicrosoftOidcMetadataPolicy implements OidcDiscoveryMetadataPolicyInterface
{
public function isValid(mixed $metadata): bool
{
return \is_array($metadata)
&& isset($metadata['authorization_endpoint'], $metadata['token_endpoint'], $metadata['jwks_uri'])
&& \is_string($metadata['authorization_endpoint'])
&& '' !== trim($metadata['authorization_endpoint'])
&& \is_string($metadata['token_endpoint'])
&& '' !== trim($metadata['token_endpoint'])
&& \is_string($metadata['jwks_uri'])
&& '' !== trim($metadata['jwks_uri']);
}
}

View file

@ -0,0 +1,222 @@
# OAuth Microsoft Entra ID Example
This example demonstrates MCP server authorization using Microsoft Entra ID (formerly Azure AD) as the OAuth 2.0 / OpenID Connect provider.
## Features
- JWT token validation with Microsoft Entra ID
- Microsoft-specific validator/discovery overrides for Entra quirks
- Protected Resource Metadata (RFC 9728)
- MCP tools that access Microsoft claims
- Optional Microsoft Graph API integration
## Prerequisites
1. **Azure Subscription** with access to Entra ID
2. **App Registration** in Azure Portal
## Azure Setup
### 1. Create App Registration
1. Go to [Azure Portal](https://portal.azure.com) > **Entra ID** > **App registrations**
2. Click **New registration**
3. Configure:
- **Name**: `MCP Server`
- **Supported account types**: Choose based on your needs
- **Redirect URI**: Leave empty for now (this is a resource server)
4. Click **Register**
### 2. Configure the App
After registration:
1. **Copy values for `.env`**:
- **Application (client) ID** → `AZURE_CLIENT_ID`
- **Directory (tenant) ID** → `AZURE_TENANT_ID`
2. **Expose an API** (optional, for custom scopes):
- Go to **Expose an API**
- Set **Application ID URI** (e.g., `api://your-client-id`)
- Add scopes like `mcp.read`, `mcp.write`
3. **Create client secret** (for Graph API calls):
- Go to **Certificates & secrets**
- Click **New client secret**
- Copy the secret value → `AZURE_CLIENT_SECRET`
4. **API Permissions** (for Graph API):
- Go to **API permissions**
- Add **Microsoft Graph** > **Delegated permissions**:
- `User.Read` (for profile)
- `Mail.Read` (for emails, optional)
- Grant admin consent if required
### 3. Create a Client App (for testing)
Create a separate app registration for the client:
1. **New registration**:
- **Name**: `MCP Client`
- **Redirect URI**: `http://localhost` (Public client/native)
2. **Authentication**:
- Enable **Allow public client flows** for PKCE
3. **API permissions**:
- Add permission to your MCP Server app's exposed API
## Quick Start
1. **Copy environment file:**
```bash
cp env.example .env
```
2. **Edit `.env` with your Azure values:**
```bash
AZURE_TENANT_ID=your-tenant-id
AZURE_CLIENT_ID=your-client-id
AZURE_CLIENT_SECRET=your-client-secret # Optional, for Graph API
```
3. **Start the services:**
```bash
docker compose up -d
```
4. **Get an access token:**
Using Azure CLI:
```bash
# Login
az login
# Get token for your app
TOKEN=$(az account get-access-token \
--resource api://your-client-id \
--query accessToken -o tsv)
```
Or using MSAL / OAuth flow in your client application.
5. **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"}}}'
```
## Architecture
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ MCP Client │────▶│ Nginx │────▶│ PHP-FPM │
│ │ │ (port 8000) │ │ MCP Server │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
│ Get Token │ Validate JWT
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Microsoft │◀───────────────────────────│ JWKS Fetch │
│ Entra ID │ │ │
└─────────────────┘ └─────────────────┘
│
│ (Optional) Graph API
▼
┌─────────────────┐
│ Microsoft │
│ Graph API │
└─────────────────┘
```
## Files
- `docker-compose.yml` - Docker Compose configuration
- `Dockerfile` - PHP-FPM container
- `nginx/default.conf` - Nginx configuration
- `env.example` - Environment variables template
- `server.php` - MCP server with OAuth middleware
- `MicrosoftJwtTokenValidator.php` - Example-specific validator for Graph/non-Graph tokens
- `MicrosoftOidcMetadataPolicy.php` - Lenient metadata validation policy
- `McpElements.php` - MCP tools including Graph API integration
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `AZURE_TENANT_ID` | Yes | Azure AD tenant ID |
| `AZURE_CLIENT_ID` | Yes | Application (client) ID |
| `AZURE_CLIENT_SECRET` | No | Client secret for Graph API calls |
## Microsoft Token Structure
Microsoft Entra ID tokens include these common claims:
| Claim | Description |
|-------|-------------|
| `oid` | Object ID (unique user identifier in tenant) |
| `tid` | Tenant ID |
| `sub` | Subject (unique user identifier) |
| `name` | Display name |
| `preferred_username` | Usually the UPN |
| `email` | Email address (if available) |
| `upn` | User Principal Name |
## Troubleshooting
### "Invalid issuer" error
Microsoft uses different issuer URLs depending on the token flow:
- v2.0 endpoint (user/delegated flows): `https://login.microsoftonline.com/{tenant}/v2.0`
- v1.0 endpoint (client credentials flow): `https://sts.windows.net/{tenant}/`
This example **automatically accepts both formats** by configuring multiple issuers in the `MicrosoftJwtTokenValidator`.
Check your token's `iss` claim to verify which format is being used.
### "Invalid audience" error
The `aud` claim must match `AZURE_CLIENT_ID`. For v2.0 tokens with custom scopes,
the audience might be `api://your-client-id`.
### JWKS fetch fails
Microsoft's JWKS endpoint is public. Ensure your container can reach:
`https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys`
### `code_challenge_methods_supported` missing in discovery metadata
This example configures `OidcDiscovery` with `MicrosoftOidcMetadataPolicy`, so this
field can be missing or malformed and will not fail discovery.
### Graph API errors
1. Ensure `AZURE_CLIENT_SECRET` is set
2. Verify API permissions have admin consent
3. Check that the user exists in your tenant
## Security Notes
1. **Never commit `.env` files** - they contain secrets
2. **Use managed identities** in Azure deployments instead of client secrets
3. **Implement proper token refresh** in production clients
4. **Validate scopes** for sensitive operations
5. **Important:** `MicrosoftJwtTokenValidator` in this example accepts `nonce` Graph-style tokens via claim checks only (`iss`/`exp`/`nbf`) without signature verification. Treat this as demo-only behavior and replace it with full signature validation for production.
## Cleanup
```bash
docker compose down -v
```

View file

@ -0,0 +1,43 @@
services:
php:
build:
context: .
dockerfile: Dockerfile
container_name: mcp-php-microsoft
volumes:
- ../../../:/app
working_dir: /app
env_file:
- .env
environment:
AZURE_TENANT_ID: ${AZURE_TENANT_ID:-}
AZURE_CLIENT_ID: ${AZURE_CLIENT_ID:-}
AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET:-}
command: >
sh -c "mkdir -p /app/examples/server/oauth-microsoft/sessions;
chmod -R 0777 /app/examples/server/oauth-microsoft/sessions;
touch /app/examples/server/oauth-microsoft/dev.log;
chmod 0666 /app/examples/server/oauth-microsoft/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-microsoft
ports:
- "${MCP_HTTP_PORT:-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

View file

@ -0,0 +1,18 @@
# Microsoft Entra ID (Azure AD) Configuration
# Copy this file to .env and fill in your values
# Your Azure AD tenant ID
# Find at: Azure Portal > Entra ID > Overview > Tenant ID
AZURE_TENANT_ID=your-tenant-id-here
# Application (client) ID for the MCP server app registration
# This is the audience that tokens must be issued for
AZURE_CLIENT_ID=your-client-id-here
# Client secret for calling Microsoft Graph API (optional)
# Only needed if your MCP tools call Graph API on behalf of users
AZURE_CLIENT_SECRET=your-client-secret-here
# Optional: Specific API permissions/scopes your MCP server accepts
# Comma-separated list of custom scopes defined in your app registration
# MCP_SCOPES=api://your-client-id/mcp.read,api://your-client-id/mcp.write

View file

@ -0,0 +1,25 @@
server {
listen 80;
server_name localhost;
root /app/examples/server/oauth-microsoft;
# 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;
}
}

View file

@ -0,0 +1,91 @@
<?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\Example\Server\OAuthMicrosoft\MicrosoftJwtTokenValidator;
use Mcp\Example\Server\OAuthMicrosoft\MicrosoftOidcMetadataPolicy;
use Mcp\Server;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
use Mcp\Server\Transport\Http\Middleware\OAuthProxyMiddleware;
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;
$tenantId = getenv('AZURE_TENANT_ID') ?: throw new RuntimeException('AZURE_TENANT_ID environment variable is required');
$clientId = getenv('AZURE_CLIENT_ID') ?: throw new RuntimeException('AZURE_CLIENT_ID environment variable is required');
$issuerV2 = "https://login.microsoftonline.com/{$tenantId}/v2.0";
$issuerV1 = "https://sts.windows.net/{$tenantId}/";
$localBaseUrl = 'http://localhost:8000';
$discovery = new OidcDiscovery(
metadataPolicy: new MicrosoftOidcMetadataPolicy(),
);
$jwtTokenValidator = new JwtTokenValidator(
issuer: [$issuerV2, $issuerV1],
audience: $clientId,
jwksProvider: new JwksProvider($discovery),
jwksUri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys',
scopeClaim: 'scp',
);
$validator = new MicrosoftJwtTokenValidator($jwtTokenValidator);
$protectedResourceMetadata = new ProtectedResourceMetadata(
authorizationServers: [$localBaseUrl],
scopesSupported: ['openid', 'profile', 'email'],
resourceName: 'OAuth Microsoft Example MCP Server',
resourceDocumentation: $localBaseUrl,
);
$metadataMiddleware = new ProtectedResourceMetadataMiddleware($protectedResourceMetadata);
$clientSecret = getenv('AZURE_CLIENT_SECRET') ?: null;
$oauthProxyMiddleware = new OAuthProxyMiddleware(
upstreamIssuer: $issuerV2,
localBaseUrl: $localBaseUrl,
discovery: $discovery,
clientSecret: $clientSecret,
);
$authMiddleware = new AuthorizationMiddleware(
$validator,
$protectedResourceMetadata,
);
$server = Server::builder()
->setServerInfo('OAuth Microsoft Example', '1.0.0')
->setLogger(logger())
->setSession(new FileSessionStore(__DIR__.'/sessions'))
->setDiscovery(__DIR__)
->build();
$transport = new StreamableHttpTransport(
(new Psr17Factory())->createServerRequestFromGlobals(),
logger: logger(),
middleware: [$oauthProxyMiddleware, $metadataMiddleware, $authMiddleware, new OAuthRequestMetaMiddleware()],
);
$response = $server->run($transport);
(new SapiEmitter())->emit($response);

View file

@ -0,0 +1,300 @@
<?php
/*
* 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\OAuthMicrosoft\Tests\Unit;
use Firebase\JWT\JWT;
use Mcp\Example\Server\OAuthMicrosoft\MicrosoftJwtTokenValidator;
use Mcp\Server\Transport\Http\OAuth\AuthorizationResult;
use Mcp\Server\Transport\Http\OAuth\JwksProvider;
use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator;
use Mcp\Server\Transport\Http\OAuth\OidcDiscoveryInterface;
use Nyholm\Psr7\Factory\Psr17Factory;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Tests MicrosoftJwtTokenValidator for Graph and non-Graph token flows.
*
* @author Volodymyr Panivko <sveneld300@gmail.com>
*/
class MicrosoftJwtTokenValidatorTest extends TestCase
{
#[TestDox('non-Graph Microsoft token is validated via JWKS')]
public function testNonGraphTokenUsesStandardJwtValidation(): void
{
$factory = new Psr17Factory();
[$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid');
$jwksUri = 'https://login.microsoftonline.com/common/discovery/v2.0/keys';
$httpClient = $this->createHttpClientMock([
$factory->createResponse(200)
->withHeader('Content-Type', 'application/json')
->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))),
]);
$jwtTokenValidator = new JwtTokenValidator(
issuer: 'https://login.microsoftonline.com/tenant-id/v2.0',
audience: 'mcp-api',
jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory),
jwksUri: $jwksUri,
scopeClaim: 'scp',
);
$validator = new MicrosoftJwtTokenValidator(jwtTokenValidator: $jwtTokenValidator);
$token = JWT::encode(
[
'iss' => 'https://login.microsoftonline.com/tenant-id/v2.0',
'aud' => 'mcp-api',
'sub' => 'user-123',
'scp' => 'files.read files.write',
'iat' => time() - 10,
'exp' => time() + 600,
],
$privateKeyPem,
'RS256',
keyId: 'test-kid',
);
$result = $validator->validate($token);
$this->assertTrue($result->isAllowed());
$this->assertSame(['files.read', 'files.write'], $result->getAttributes()['oauth.scopes']);
$this->assertSame('user-123', $result->getAttributes()['oauth.subject']);
$this->assertArrayNotHasKey('oauth.graph_token', $result->getAttributes());
}
#[TestDox('Graph token with nonce header is validated by claims only')]
public function testGraphTokenWithNonceHeaderIsAllowed(): void
{
$factory = new Psr17Factory();
$token = $this->buildGraphToken([
'iss' => 'https://login.microsoftonline.com/tenant-id/v2.0',
'aud' => 'mcp-api',
'sub' => 'user-graph',
'scp' => 'files.read files.write',
'iat' => time() - 10,
'exp' => time() + 600,
]);
$jwtTokenValidator = new JwtTokenValidator(
issuer: ['https://auth.example.com'],
audience: ['mcp-api'],
jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(),
httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0),
requestFactory: $factory
),
jwksUri: 'https://unused.example.com/jwks',
scopeClaim: 'scp',
);
$validator = new MicrosoftJwtTokenValidator(
jwtTokenValidator: $jwtTokenValidator,
scopeClaim: 'scp',
);
$result = $validator->validate($token);
$this->assertTrue($result->isAllowed());
$this->assertTrue($result->getAttributes()['oauth.graph_token']);
$this->assertSame(['files.read', 'files.write'], $result->getAttributes()['oauth.scopes']);
$this->assertSame('user-graph', $result->getAttributes()['oauth.subject']);
}
#[TestDox('Graph token with invalid payload is unauthorized')]
public function testGraphTokenInvalidPayloadIsUnauthorized(): void
{
$factory = new Psr17Factory();
$header = $this->b64urlEncode(json_encode([
'alg' => 'none',
'typ' => 'JWT',
'nonce' => 'abc',
], \JSON_THROW_ON_ERROR));
$token = $header.'..';
$jwtTokenValidator = new JwtTokenValidator(
issuer: ['https://auth.example.com'],
audience: ['mcp-api'],
jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(),
httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0),
requestFactory: $factory
),
jwksUri: 'https://unused.example.com/jwks',
scopeClaim: 'scp',
);
$validator = new MicrosoftJwtTokenValidator(
jwtTokenValidator: $jwtTokenValidator,
scopeClaim: 'scp',
);
$result = $validator->validate($token);
$this->assertFalse($result->isAllowed());
$this->assertSame(401, $result->getStatusCode());
$this->assertSame('invalid_token', $result->getError());
$this->assertSame('Invalid token payload.', $result->getErrorDescription());
}
#[TestDox('Graph token with invalid issuer is unauthorized')]
public function testGraphTokenInvalidIssuerIsUnauthorized(): void
{
$factory = new Psr17Factory();
$token = $this->buildGraphToken([
'iss' => 'https://evil.example.com',
'aud' => 'mcp-api',
'sub' => 'user-graph',
'scp' => 'files.read',
'iat' => time() - 10,
'exp' => time() + 600,
]);
$jwtTokenValidator = new JwtTokenValidator(
issuer: ['https://auth.example.com'],
audience: ['mcp-api'],
jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(),
httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0),
requestFactory: $factory
),
jwksUri: 'https://unused.example.com/jwks',
scopeClaim: 'scp',
);
$validator = new MicrosoftJwtTokenValidator(
jwtTokenValidator: $jwtTokenValidator,
scopeClaim: 'scp',
);
$result = $validator->validate($token);
$this->assertFalse($result->isAllowed());
$this->assertSame(401, $result->getStatusCode());
$this->assertSame('invalid_token', $result->getError());
$this->assertSame('Invalid token issuer for Graph token.', $result->getErrorDescription());
}
#[TestDox('scope checks are delegated to base JwtTokenValidator')]
public function testRequireScopesDelegatesToJwtTokenValidator(): void
{
$factory = new Psr17Factory();
$jwtTokenValidator = new JwtTokenValidator(
issuer: ['https://auth.example.com'],
audience: ['mcp-api'],
jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(),
httpClient: $this->createHttpClientMock([], 0),
requestFactory: $factory,
),
jwksUri: 'https://unused.example.com/jwks',
scopeClaim: 'scp',
);
$validator = new MicrosoftJwtTokenValidator(
jwtTokenValidator: $jwtTokenValidator,
scopeClaim: 'scp',
);
$result = AuthorizationResult::allow([
'oauth.scopes' => ['files.read'],
]);
$scoped = $validator->requireScopes($result, ['files.read', 'files.write']);
$this->assertFalse($scoped->isAllowed());
$this->assertSame(403, $scoped->getStatusCode());
$this->assertSame('insufficient_scope', $scoped->getError());
}
/**
* @param array<string, mixed> $claims
*/
private function buildGraphToken(array $claims): string
{
$header = $this->b64urlEncode(json_encode([
'alg' => 'none',
'typ' => 'JWT',
'nonce' => 'abc',
], \JSON_THROW_ON_ERROR));
$payload = $this->b64urlEncode(json_encode($claims, \JSON_THROW_ON_ERROR));
return $header.'.'.$payload.'.';
}
/**
* @return array{0: string, 1: array<string, mixed>}
*/
private function generateRsaKeypairAsJwk(string $kid): array
{
$key = openssl_pkey_new([
'private_key_type' => \OPENSSL_KEYTYPE_RSA,
'private_key_bits' => 2048,
]);
if (false === $key) {
$this->fail('Failed to generate RSA keypair via OpenSSL.');
}
$privateKeyPem = '';
if (!openssl_pkey_export($key, $privateKeyPem)) {
$this->fail('Failed to export RSA private key.');
}
$details = openssl_pkey_get_details($key);
if (false === $details || !isset($details['rsa']['n'], $details['rsa']['e'])) {
$this->fail('Failed to read RSA key details.');
}
$n = $this->b64urlEncode($details['rsa']['n']);
$e = $this->b64urlEncode($details['rsa']['e']);
$publicJwk = [
'kty' => 'RSA',
'kid' => $kid,
'use' => 'sig',
'alg' => 'RS256',
'n' => $n,
'e' => $e,
];
return [$privateKeyPem, $publicJwk];
}
private function b64urlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private function createDiscoveryStub(): OidcDiscoveryInterface
{
return $this->createStub(OidcDiscoveryInterface::class);
}
/**
* @param list<ResponseInterface> $responses
*/
private function createHttpClientMock(array $responses, ?int $expectedCalls = null): ClientInterface
{
$expectedCalls ??= \count($responses);
$client = $this->createMock(ClientInterface::class);
$client
->expects($this->exactly($expectedCalls))
->method('sendRequest')
->with($this->isInstanceOf(RequestInterface::class))
->willReturnCallback(static function () use (&$responses): ResponseInterface {
if ([] === $responses) {
throw new \RuntimeException('No more mocked responses available.');
}
return array_shift($responses);
});
return $client;
}
}

Some files were not shown because too many files have changed in this diff Show more