Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,18 @@
"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",
"psr/http-server-middleware": "^1.0",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0"
},
"require-dev": {
"firebase/php-jwt": "^6.10",
"guzzlehttp/guzzle": "^7.0",
"laminas/laminas-httphandlerrunner": "^2.12",
"nyholm/psr7": "^1.8",
"nyholm/psr7-server": "^1.1",
Expand Down Expand Up @@ -63,6 +68,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/"
}
Expand Down
40 changes: 40 additions & 0 deletions docs/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,46 @@ Default CORS headers:
- `Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS`
- `Access-Control-Allow-Headers: Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, Authorization, Accept`

### PSR-15 Middleware

`StreamableHttpTransport` can run a PSR-15 middleware chain before it processes the request. Middleware can log,
enforce auth, or short-circuit with a response for any HTTP method.

```php
use Mcp\Server\Transport\StreamableHttpTransport;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

final class AuthMiddleware implements MiddlewareInterface
{
public function __construct(private ResponseFactoryInterface $responses)
{
}

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler)
{
if (!$request->hasHeader('Authorization')) {
return $this->responses->createResponse(401);
}

return $handler->handle($request);
}
}

$transport = new StreamableHttpTransport(
$request,
$responseFactory,
$streamFactory,
[],
$logger,
[new AuthMiddleware($responseFactory)],
);
```

If middleware returns a response, the transport will still ensure CORS headers are present unless you set them yourself.

### Architecture

The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that
Expand Down
23 changes: 23 additions & 0 deletions examples/server/oauth-keycloak/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM php:8.2-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"]
109 changes: 109 additions & 0 deletions examples/server/oauth-keycloak/McpElements.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?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.
*/

declare(strict_types=1);

namespace Mcp\Example\Server\OAuthKeycloak;

use Mcp\Capability\Attribute\McpPrompt;
use Mcp\Capability\Attribute\McpResource;
use Mcp\Capability\Attribute\McpTool;

/**
* 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.
*/
#[McpTool(
name: 'get_auth_status',
description: 'Confirm authentication status - only accessible with valid OAuth token'
)]
public function getAuthStatus(): array
{
return [
'authenticated' => true,
'message' => 'You have successfully authenticated with OAuth!',
'timestamp' => date('c'),
'note' => 'This endpoint is protected by JWT validation. If you see this, your token was valid.',
];
}

/**
* Simulates calling a protected external API.
*/
#[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.
*/
#[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!",
};
}
}
136 changes: 136 additions & 0 deletions examples/server/oauth-keycloak/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# 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:**

The MCP Inspector doesn't support OAuth out of the box, but you can test using curl or build a custom client.

## 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

## Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `KEYCLOAK_EXTERNAL_URL` | `http://localhost:8180` | Keycloak URL as seen by clients (token issuer) |
| `KEYCLOAK_INTERNAL_URL` | `http://keycloak:8080` | Keycloak URL from within Docker network (for JWKS) |
| `KEYCLOAK_REALM` | `mcp` | Keycloak realm name |
| `MCP_AUDIENCE` | `mcp-server` | Expected JWT audience |

## 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:8080` (Docker network).
For local development outside Docker, use `http://localhost:8180`.

## Cleanup

```bash
docker compose down -v
```
Loading
Loading