Skip to content
Draft
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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"Mcp\\Example\\Server\\EnvVariables\\": "examples/server/env-variables/",
"Mcp\\Example\\Server\\ExplicitRegistration\\": "examples/server/explicit-registration/",
"Mcp\\Example\\Server\\McpApps\\": "examples/server/mcp-apps/",
"Mcp\\Example\\Server\\OAuthAuthorizationServer\\": "examples/server/oauth-authorization-server/",
"Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/",
"Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/",
"Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/",
Expand Down
3 changes: 3 additions & 0 deletions examples/server/oauth-authorization-server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
keys/
sessions/
cache/
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?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\OAuthAuthorizationServer;

use Http\Discovery\Psr17FactoryDiscovery;
use Mcp\Server\Transport\Http\OAuth\ResourceOwner;
use Mcp\Server\Transport\Http\OAuth\ResourceOwnerResolverInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
* Demo-only resource owner resolver.
*
* A real host authenticates the user against its own session/firewall. To keep
* this example fully scriptable, an unauthenticated request is "logged in" as a
* fixed demo user by setting a cookie and redirecting back to the authorize URL.
*
* DO NOT use this in production.
*/
final class DemoResourceOwnerResolver implements ResourceOwnerResolverInterface
{
private const COOKIE = 'demo_user';

private ResponseFactoryInterface $responseFactory;

public function __construct(
private readonly string $demoUserId = 'demo-user',
?ResponseFactoryInterface $responseFactory = null,
) {
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
}

public function resolve(ServerRequestInterface $request): ?ResourceOwner
{
$user = $request->getCookieParams()[self::COOKIE] ?? null;
if (!\is_string($user) || '' === $user) {
return null;
}

return new ResourceOwner($user, ['name' => 'Demo User']);
}

public function onUnauthenticated(ServerRequestInterface $request): ResponseInterface
{
// A real implementation renders/redirects to a login form. Here we
// auto-login the demo user and resume the authorization request.
return $this->responseFactory
->createResponse(302)
->withHeader('Location', (string) $request->getUri())
->withHeader('Set-Cookie', self::COOKIE.'='.rawurlencode($this->demoUserId).'; Path=/; HttpOnly; SameSite=Lax')
->withHeader('Cache-Control', 'no-store');
}
}
45 changes: 45 additions & 0 deletions examples/server/oauth-authorization-server/McpElements.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?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\OAuthAuthorizationServer;

use Mcp\Capability\Attribute\McpTool;
use Mcp\Server\RequestContext;

/**
* A protected MCP tool. Reaching it proves the request carried a valid access
* token issued by this server's own authorization endpoints.
*/
final class McpElements
{
/**
* @return array<string, mixed>
*/
#[McpTool(
name: 'whoami',
description: 'Return the authenticated subject and scopes from the access token.'
)]
public function whoami(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,
'subject' => $oauth['oauth.subject'] ?? ($claims['sub'] ?? null),
'client_id' => $oauth['oauth.client_id'] ?? ($claims['client_id'] ?? null),
'scopes' => $scopes,
'issuer' => $claims['iss'] ?? null,
];
}
}
70 changes: 70 additions & 0 deletions examples/server/oauth-authorization-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# OAuth Authorization Server Example

A self-contained MCP server that is **its own** OAuth 2.1 authorization server: it
registers clients, authorizes users, issues its own RS256 JWT access tokens, and
validates those same tokens to protect the MCP endpoint — with no external
identity provider and no `league/oauth2-server`.

It demonstrates the SDK's native authorization-server layer:

- `GET /.well-known/oauth-authorization-server` — RFC 8414 metadata (enriched with `registration_endpoint`)
- `GET /.well-known/oauth-protected-resource` — RFC 9728 metadata
- `GET /.well-known/jwks.json` — public signing key (JWK Set)
- `POST /register` — RFC 7591 Dynamic Client Registration
- `GET /authorize` — authorization code grant with mandatory PKCE (S256)
- `POST /token` — `authorization_code` and `refresh_token` grants
- `POST /` — the protected MCP JSON-RPC endpoint (requires `Authorization: Bearer <jwt>`)

> The signing key is generated into `keys/private.pem` on first run, storage is
> in-memory, and login auto-approves a fixed demo user (`DemoResourceOwnerResolver`).
> Replace all three for production: a managed key, the PSR-16 (or your own) stores,
> and a real login/consent backed by your user system.

## Run

```bash
php -S localhost:8000 examples/server/oauth-authorization-server/server.php
```

## Walkthrough (the Claude.ai flow)

```bash
BASE=http://localhost:8000

# 1. Dynamic client registration (public client + PKCE)
curl -s -X POST $BASE/register -H 'Content-Type: application/json' -d '{
"redirect_uris": ["http://localhost:9999/callback"],
"token_endpoint_auth_method": "none",
"client_name": "Demo Client"
}'
# => { "client_id": "...", "redirect_uris": [...], ... }

# 2. Authorize (PKCE). Use a known verifier/challenge pair:
# verifier = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
# challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
curl -s -i -c cookies.txt -b cookies.txt \
"$BASE/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=http://localhost:9999/callback&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&scope=mcp:tools&state=xyz"
# => 302 Location: http://localhost:9999/callback?code=CODE&state=xyz

# 3. Exchange the code for tokens
curl -s -X POST $BASE/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d "grant_type=authorization_code&code=CODE&redirect_uri=http://localhost:9999/callback&client_id=CLIENT_ID&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
# => { "access_token": "<jwt>", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "..." }

# 4. Call the protected MCP endpoint
curl -s -X POST $BASE/ \
-H "Authorization: Bearer <jwt>" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"whoami","arguments":{}}}'
```

A request without a valid token returns `401` with a `WWW-Authenticate` header
pointing at `/.well-known/oauth-protected-resource`.

## Middleware order

The OAuth endpoints are composed before the bearer-protection middleware so they
stay public; only the MCP JSON-RPC path requires a token. `ClientRegistrationMiddleware`
sits outermost of the authorization-server metadata middleware so it can inject
`registration_endpoint` into the served document.
115 changes: 115 additions & 0 deletions examples/server/oauth-authorization-server/server.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?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.
*/

require_once dirname(__DIR__).'/bootstrap.php';

use Http\Discovery\Psr17Factory;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\Example\Server\OAuthAuthorizationServer\DemoResourceOwnerResolver;
use Mcp\Server;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Transport\Http\Middleware\AuthorizationEndpointMiddleware;
use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
use Mcp\Server\Transport\Http\Middleware\AuthorizationServerMetadataMiddleware;
use Mcp\Server\Transport\Http\Middleware\ClientRegistrationMiddleware;
use Mcp\Server\Transport\Http\Middleware\JwksMiddleware;
use Mcp\Server\Transport\Http\Middleware\OAuthRequestMetaMiddleware;
use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware;
use Mcp\Server\Transport\Http\Middleware\TokenEndpointMiddleware;
use Mcp\Server\Transport\Http\OAuth\AuthorizationServerMetadata;
use Mcp\Server\Transport\Http\OAuth\AutoApproveConsent;
use Mcp\Server\Transport\Http\OAuth\CacheAuthorizationCodeStore;
use Mcp\Server\Transport\Http\OAuth\CacheClientRepository;
use Mcp\Server\Transport\Http\OAuth\CacheRefreshTokenStore;
use Mcp\Server\Transport\Http\OAuth\JwtAccessTokenIssuer;
use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator;
use Mcp\Server\Transport\Http\OAuth\NativeAuthorizationCodeIssuer;
use Mcp\Server\Transport\Http\OAuth\NativeTokenGranter;
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
use Mcp\Server\Transport\Http\OAuth\RsaSigningKey;
use Mcp\Server\Transport\Http\OAuth\StaticJwksProvider;
use Mcp\Server\Transport\Http\OAuth\StoredClientRegistrar;
use Mcp\Server\Transport\StreamableHttpTransport;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Psr16Cache;

$baseUrl = getenv('MCP_BASE_URL') ?: 'http://localhost:8000';
$scopes = ['mcp:tools', 'mcp:resources'];

// --- Signing key (generated on first run; replace with a managed key in production) ---
$keyPath = __DIR__.'/keys/private.pem';
if (!is_file($keyPath)) {
@mkdir(dirname($keyPath), 0700, true);
$resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => \OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($resource, $pem);
file_put_contents($keyPath, $pem);
}
$signingKey = RsaSigningKey::fromFile($keyPath);

// --- Storage (PSR-16 filesystem cache so state survives across requests under
// `php -S`; swap for your own ClientRepository/stores in production) ---
$cache = new Psr16Cache(new FilesystemAdapter('mcp_oauth', 0, __DIR__.'/cache'));
$clients = new CacheClientRepository($cache);
$codes = new CacheAuthorizationCodeStore($cache);
$refreshTokens = new CacheRefreshTokenStore($cache);

// --- Authorization server engine ---
$accessTokenIssuer = new JwtAccessTokenIssuer($signingKey, $baseUrl);
$codeIssuer = new NativeAuthorizationCodeIssuer($codes);
$granter = new NativeTokenGranter($clients, $codes, $refreshTokens, $accessTokenIssuer, resource: $baseUrl);

// --- Resource server: validate our own self-issued tokens, no network needed ---
$tokenValidator = new JwtTokenValidator(
issuer: $baseUrl,
audience: $baseUrl,
jwksProvider: new StaticJwksProvider($signingKey),
);

$authServerMetadata = new AuthorizationServerMetadata(
issuer: $baseUrl,
scopesSupported: $scopes,
);

$protectedResourceMetadata = new ProtectedResourceMetadata(
authorizationServers: [$baseUrl],
scopesSupported: $scopes,
resource: $baseUrl,
resourceName: 'OAuth Authorization Server Example',
);

$server = Server::builder()
->setServerInfo('OAuth Authorization Server Example', '1.0.0')
->setLogger(logger())
->setSession(new FileSessionStore(__DIR__.'/sessions'))
->setDiscovery(__DIR__)
->build();

$transport = new StreamableHttpTransport(
(new Psr17Factory())->createServerRequestFromGlobals(),
logger: logger(),
middleware: [
...StreamableHttpTransport::defaultMiddleware(),
// ClientRegistration must be OUTER of the metadata middleware so it can
// enrich the served document with registration_endpoint.
new ClientRegistrationMiddleware(new StoredClientRegistrar($clients, $scopes), $baseUrl),
new AuthorizationServerMetadataMiddleware($authServerMetadata),
new JwksMiddleware($signingKey),
new ProtectedResourceMetadataMiddleware($protectedResourceMetadata),
new AuthorizationEndpointMiddleware($clients, $codeIssuer, new DemoResourceOwnerResolver(), new AutoApproveConsent(), $scopes),
new TokenEndpointMiddleware($granter),
new AuthorizationMiddleware($tokenValidator, $protectedResourceMetadata),
new OAuthRequestMetaMiddleware(),
],
);

$response = $server->run($transport);

(new SapiEmitter())->emit($response);
79 changes: 79 additions & 0 deletions src/Exception/OAuthException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?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\Exception;

/**
* OAuth 2.0 protocol error (RFC 6749 Section 5.2 / RFC 6750).
*
* Carries the OAuth error code and the HTTP status code that the authorization
* server endpoints should respond with. The message is the human-readable
* error_description returned to the client — never include internal details.
*
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
*/
class OAuthException extends \RuntimeException implements ExceptionInterface
{
public function __construct(
public readonly string $error,
string $errorDescription,
public readonly int $httpStatus = 400,
?\Throwable $previous = null,
) {
parent::__construct($errorDescription, 0, $previous);
}

public static function invalidRequest(string $description): self
{
return new self('invalid_request', $description, 400);
}

public static function invalidClient(string $description): self
{
return new self('invalid_client', $description, 401);
}

public static function invalidGrant(string $description): self
{
return new self('invalid_grant', $description, 400);
}

public static function unauthorizedClient(string $description): self
{
return new self('unauthorized_client', $description, 400);
}

public static function unsupportedGrantType(string $description): self
{
return new self('unsupported_grant_type', $description, 400);
}

public static function invalidScope(string $description): self
{
return new self('invalid_scope', $description, 400);
}

public static function serverError(string $description): self
{
return new self('server_error', $description, 500);
}

/**
* @return array{error: string, error_description: string}
*/
public function toArray(): array
{
return [
'error' => $this->error,
'error_description' => $this->getMessage(),
];
}
}
Loading