diff --git a/.gitignore b/.gitignore index d6b0d9f..a222301 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ phpunit.xml ray.php .claude/ +CLAUDE.md diff --git a/README.md b/README.md index a0bb783..e1e50f1 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ This command creates an interactive CLI that lets you input prompts that will be ## Transport Types -Arc supports multiple transport mechanisms: +Relay supports multiple transport mechanisms: ### HTTP Transport @@ -142,6 +142,56 @@ For MCP servers that communicate over HTTP: ], ``` +#### OAuth / Bearer Token Authentication + +The [MCP 2025-11-25 authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) defines an OAuth 2.1 flow for HTTP-based transports. **Relay handles only the last step — attaching the token to every request.** Your application is responsible for running the full OAuth flow and supplying the resulting access token at runtime. + +##### What your application must handle + +Before calling `withToken()`, your code must: + +1. **Discover the authorization server** — make an unauthenticated request to the MCP server and read the `WWW-Authenticate` header from the `401` response, or probe `/.well-known/oauth-protected-resource`. The response contains the authorization server URL. +2. **Run the OAuth 2.1 authorization code flow** — including PKCE (`S256` code challenge is required by the spec) and the `resource` parameter ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html)) identifying the MCP server. +3. **Exchange the code for a token** and store/refresh it as needed. Tokens are short-lived; implement refresh token rotation. + +> [!NOTE] +> The spec covers this in detail: [MCP Authorization — 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). + +##### Passing the token to Relay + +Once you have a valid Bearer token, pass it to Relay at request time. The runtime token takes priority over any static `api_key` set in config. + +```php +use Prism\Relay\Facades\Relay; +use Prism\Relay\Exceptions\AuthorizationException; +use Prism\Relay\Exceptions\TransportException; + +// Via the Facade (returns a RelayBuilder) +$tools = Relay::withToken($request->user()->mcp_token)->tools('github'); + +// Or on a Relay instance +$relay = Relay::make('github'); +$tools = $relay->withToken($request->user()->mcp_token)->tools(); +``` + +When the MCP server rejects the token with HTTP 401, Relay throws `AuthorizationException`. Use this to trigger a re-authorization flow in your app: + +```php +try { + $tools = Relay::withToken($token)->tools('github'); +} catch (AuthorizationException $e) { + // Token is missing, expired, or rejected (HTTP 401) + // Re-run the OAuth flow to get a fresh token + return redirect('/oauth/reconnect'); +} catch (TransportException $e) { + // Other transport failure (non-401) + Log::error('MCP Transport error: ' . $e->getMessage()); +} +``` + +> [!NOTE] +> OAuth tokens are only supported with HTTP transport. Passing a token to a Stdio-configured server throws a `ServerConfigurationException`. For Stdio servers, provide credentials via the `env` config key instead. + ### STDIO Transport For locally running MCP servers that communicate via standard I/O: @@ -199,6 +249,7 @@ $response = Prism::text() The package uses specific exception types for better error handling: ```php +use Prism\Relay\Exceptions\AuthorizationException; use Prism\Relay\Exceptions\RelayException; use Prism\Relay\Exceptions\ServerConfigurationException; use Prism\Relay\Exceptions\ToolCallException; @@ -214,6 +265,9 @@ try { } catch (ToolDefinitionException $e) { // Handle issues with tool definitions from the MCP server Log::error('MCP Tool definition error: ' . $e->getMessage()); +} catch (AuthorizationException $e) { + // Handle HTTP 401 — token is missing, expired, or invalid + return redirect('/oauth/reconnect'); } catch (TransportException $e) { // Handle communication errors with the MCP server Log::error('MCP Transport error: ' . $e->getMessage()); diff --git a/src/Exceptions/AuthorizationException.php b/src/Exceptions/AuthorizationException.php new file mode 100644 index 0000000..14052c6 --- /dev/null +++ b/src/Exceptions/AuthorizationException.php @@ -0,0 +1,7 @@ +serverName; } + /** + * @throws ServerConfigurationException + */ + public function withToken(string $token): static + { + $transportType = $this->serverConfig['transport'] ?? EnumsTransport::Http; + + if ($transportType === EnumsTransport::Stdio) { + throw new ServerConfigurationException( + 'OAuth access tokens are only supported with HTTP transport. Stdio transport credentials should be provided via environment variables.' + ); + } + + $this->serverConfig['access_token'] = $token; + $this->initializeTransport(); + + return $this; + } + /** * @return array * @@ -66,7 +85,7 @@ public function tools(): array */ protected function fetchToolDefinitions(): array { - $cacheKey = "relay-tools-definitions-{$this->serverName}"; + $cacheKey = $this->buildCacheKey(); $cacheDuration = config('relay.cache_duration', 60); if ($cacheDuration > 0 && Cache::has($cacheKey)) { @@ -556,6 +575,17 @@ protected function convertResponseToString(mixed $response): string return $defaultMessage; } + protected function buildCacheKey(): string + { + $key = "relay-tools-definitions-{$this->serverName}"; + + if (isset($this->serverConfig['access_token'])) { + $key .= '-'.substr(hash('sha256', (string) $this->serverConfig['access_token']), 0, 16); + } + + return $key; + } + /** * @throws ServerConfigurationException */ diff --git a/src/RelayBuilder.php b/src/RelayBuilder.php new file mode 100644 index 0000000..be81362 --- /dev/null +++ b/src/RelayBuilder.php @@ -0,0 +1,34 @@ +|null $config + * + * @throws \Prism\Relay\Exceptions\ServerConfigurationException + */ + public function make(string $serverName, ?array $config = null): Relay + { + return (new Relay($serverName, $config))->withToken($this->token); + } + + /** + * @param array|null $config + * @return array + * + * @throws \Prism\Relay\Exceptions\ServerConfigurationException + * @throws \Prism\Relay\Exceptions\ToolDefinitionException + */ + public function tools(string $serverName, ?array $config = null): array + { + return $this->make($serverName, $config)->tools(); + } +} diff --git a/src/RelayFactory.php b/src/RelayFactory.php index 6a3e321..78f540e 100644 --- a/src/RelayFactory.php +++ b/src/RelayFactory.php @@ -31,4 +31,9 @@ public function tools(string $serverName, ?array $config = null): array { return $this->make($serverName, $config)->tools(); } + + public function withToken(string $token): RelayBuilder + { + return new RelayBuilder($token); + } } diff --git a/src/Transport/HttpTransport.php b/src/Transport/HttpTransport.php index 3f32424..1a3cb22 100644 --- a/src/Transport/HttpTransport.php +++ b/src/Transport/HttpTransport.php @@ -6,6 +6,8 @@ use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; +use Prism\Relay\Exceptions\AuthorizationException; +use Prism\Relay\Exceptions\RelayException; use Prism\Relay\Exceptions\TransportException; class HttpTransport implements Transport @@ -39,7 +41,7 @@ public function sendRequest(string $method, array $params = []): array return $this->processResponse($response); } catch (\Throwable $e) { - if ($e instanceof TransportException) { + if ($e instanceof RelayException) { throw $e; } @@ -72,11 +74,13 @@ protected function createRequestPayload(string $method, array $params = []): arr */ protected function sendHttpRequest(array $payload): Response { + $token = $this->resolveAuthToken(); + return Http::timeout($this->getTimeout()) ->acceptJson() ->when( - $this->hasApiKey(), - fn ($http) => $http->withToken($this->getApiKey()) + $token !== null, + fn ($http) => $http->withToken((string) $token) ) ->when( $this->hasHeaders(), @@ -85,21 +89,47 @@ protected function sendHttpRequest(array $payload): Response ->post($this->getServerUrl(), $payload); } + /** + * Resolve the authentication token, preferring access_token over api_key. + */ + protected function resolveAuthToken(): ?string + { + if ($this->hasAccessToken()) { + return $this->getAccessToken(); + } + + if ($this->hasApiKey()) { + return $this->getApiKey(); + } + + return null; + } + protected function getTimeout(): int { return $this->config['timeout'] ?? 30; } + protected function hasAccessToken(): bool + { + return isset($this->config['access_token']); + } + + protected function getAccessToken(): string + { + return (string) ($this->config['access_token'] ?? ''); + } + protected function hasApiKey(): bool { - return isset($this->config['api_key']) && $this->config['api_key'] !== null; + return isset($this->config['api_key']); } protected function hasHeaders(): bool { return isset($this->config['headers']) && is_array($this->config['headers']) - && (isset($this->config['headers']) && $this->config['headers'] !== []); + && $this->config['headers'] !== []; } protected function getApiKey(): string @@ -139,10 +169,17 @@ protected function processResponse(Response $response): array } /** + * @throws AuthorizationException * @throws TransportException */ protected function validateHttpResponse(Response $response): void { + if ($response->status() === 401) { + throw new AuthorizationException( + 'MCP server returned 401 Unauthorized. The access token may be missing, expired, or invalid.' + ); + } + if ($response->failed()) { throw new TransportException( "HTTP request failed with status code: {$response->status()}" @@ -177,12 +214,11 @@ protected function handleJsonRpcError(array $error): void { $errorMessage = $error['message'] ?? 'Unknown error'; $errorCode = $error['code'] ?? -1; - $errorData = isset($error['data']) ? json_encode($error['data']) : ''; + $errorData = isset($error['data']) ? json_encode($error['data']) : null; - $detailsSuffix = ''; - if (! ($errorData === '' || $errorData === '0' || $errorData === false) && $errorData !== '0' && $errorData !== 'false') { - $detailsSuffix = " Details: {$errorData}"; - } + $detailsSuffix = $errorData !== null && $errorData !== false + ? " Details: {$errorData}" + : ''; throw new TransportException( "JSON-RPC error: {$errorMessage} (code: {$errorCode}){$detailsSuffix}" diff --git a/tests/TestDoubles/HttpTransportFake.php b/tests/TestDoubles/HttpTransportFake.php index 9452b00..febc717 100644 --- a/tests/TestDoubles/HttpTransportFake.php +++ b/tests/TestDoubles/HttpTransportFake.php @@ -20,6 +20,8 @@ class HttpTransportFake extends HttpTransport protected int $failStatus = 500; + protected bool $returnUnauthorized = false; + protected bool $invalidJsonRpc = false; protected bool $returnError = false; @@ -38,6 +40,13 @@ public function setResponse(string $method, array $response): self return $this; } + public function returnUnauthorized(): self + { + $this->returnUnauthorized = true; + + return $this; + } + public function failHttpRequest(int $status = 500): self { $this->failHttp = true; @@ -68,6 +77,14 @@ public function returnJsonRpcError(string $message = 'Error message', int $code #[\Override] protected function sendHttpRequest(array $payload): Response { + if ($this->returnUnauthorized) { + $response = Mockery::mock(Response::class); + $response->shouldReceive('failed')->andReturn(true); + $response->shouldReceive('status')->andReturn(401); + + return $response; + } + if ($this->failHttp) { return $this->mockFailedResponse(); } diff --git a/tests/Unit/RelayFactoryWithTokenTest.php b/tests/Unit/RelayFactoryWithTokenTest.php new file mode 100644 index 0000000..d8fc017 --- /dev/null +++ b/tests/Unit/RelayFactoryWithTokenTest.php @@ -0,0 +1,65 @@ +set('relay.servers.github', [ + 'transport' => Transport::Http, + 'url' => 'http://example.com/api', + 'timeout' => 30, + ]); + config()->set('relay.cache_duration', 0); +}); + +it('withToken returns a RelayBuilder instance', function (): void { + $factory = new RelayFactory; + $builder = $factory->withToken('my-oauth-token'); + + expect($builder)->toBeInstanceOf(RelayBuilder::class); +}); + +it('RelayBuilder::make returns a Relay instance with the token applied', function (): void { + $factory = new RelayFactory; + $relay = $factory->withToken('my-oauth-token')->make('github'); + + expect($relay)->toBeInstanceOf(Relay::class) + ->and($relay->getServerName())->toBe('github'); +}); + +it('RelayBuilder::tools calls Relay::tools with the token injected', function (): void { + Http::fake([ + 'http://example.com/api' => Http::response([ + 'jsonrpc' => '2.0', + 'id' => '1', + 'result' => [ + 'tools' => [ + [ + 'name' => 'test_tool', + 'description' => 'A test tool', + 'inputSchema' => ['type' => 'object', 'properties' => []], + ], + ], + ], + ]), + ]); + + $factory = new RelayFactory; + $factory->withToken('my-oauth-token')->tools('github'); + + Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer my-oauth-token')); +}); + +it('calling withToken twice returns independent RelayBuilder instances', function (): void { + $factory = new RelayFactory; + + $builder1 = $factory->withToken('token-one'); + $builder2 = $factory->withToken('token-two'); + + expect($builder1)->not->toBe($builder2); +}); diff --git a/tests/Unit/RelayOAuthTest.php b/tests/Unit/RelayOAuthTest.php new file mode 100644 index 0000000..1cbfbf2 --- /dev/null +++ b/tests/Unit/RelayOAuthTest.php @@ -0,0 +1,116 @@ +serverName = 'github'; + $this->httpConfig = [ + 'transport' => Transport::Http, + 'url' => 'http://example.com/api', + 'timeout' => 30, + ]; + $this->stdioConfig = [ + 'transport' => Transport::Stdio, + 'command' => ['echo', 'test'], + 'env' => [], + 'timeout' => 30, + ]; + + config()->set('relay.servers.'.$this->serverName, $this->httpConfig); + config()->set('relay.cache_duration', 60); + Cache::flush(); +}); + +it('withToken returns the same Relay instance', function (): void { + $relay = new Relay($this->serverName); + $result = $relay->withToken('my-oauth-token'); + + expect($result)->toBe($relay); +}); + +it('withToken on Stdio-configured Relay throws ServerConfigurationException', function (): void { + config()->set('relay.servers.'.$this->serverName, $this->stdioConfig); + + $relay = new Relay($this->serverName); + + expect(fn (): \Prism\Relay\Relay => $relay->withToken('my-oauth-token')) + ->toThrow(ServerConfigurationException::class, 'OAuth access tokens are only supported with HTTP transport'); +}); + +it('withToken injects token into HTTP requests when tools() is called', function (): void { + Http::fake([ + 'http://example.com/api' => Http::response([ + 'jsonrpc' => '2.0', + 'id' => '1', + 'result' => [ + 'tools' => [ + [ + 'name' => 'test_tool', + 'description' => 'A test tool', + 'inputSchema' => ['type' => 'object', 'properties' => []], + ], + ], + ], + ]), + ]); + + $relay = new Relay($this->serverName); + $relay->withToken('my-oauth-token')->tools(); + + Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer my-oauth-token')); +}); + +it('cache key includes token hash when token is set', function (): void { + $token = 'my-oauth-token'; + $expectedHash = substr(hash('sha256', $token), 0, 16); + + $relay = new class($this->serverName) extends Relay + { + public function getCacheKey(): string + { + return $this->buildCacheKey(); + } + }; + + $relay->withToken($token); + + expect($relay->getCacheKey())->toBe("relay-tools-definitions-{$this->serverName}-{$expectedHash}"); +}); + +it('cache key does not include hash when no token is set', function (): void { + $relay = new class($this->serverName) extends Relay + { + public function getCacheKey(): string + { + return $this->buildCacheKey(); + } + }; + + expect($relay->getCacheKey())->toBe("relay-tools-definitions-{$this->serverName}"); +}); + +it('two different tokens produce two different cache keys', function (): void { + $makeRelay = fn (): \Prism\Relay\Relay => new class($this->serverName) extends Relay + { + public function getCacheKey(): string + { + return $this->buildCacheKey(); + } + }; + + $relay1 = $makeRelay(); + $relay1->withToken('token-alpha'); + + $relay2 = $makeRelay(); + $relay2->withToken('token-beta'); + + expect($relay1->getCacheKey())->not->toBe($relay2->getCacheKey()); +}); diff --git a/tests/Unit/Transport/HttpTransportOAuthTest.php b/tests/Unit/Transport/HttpTransportOAuthTest.php new file mode 100644 index 0000000..773f1a8 --- /dev/null +++ b/tests/Unit/Transport/HttpTransportOAuthTest.php @@ -0,0 +1,97 @@ + Http::response([ + 'jsonrpc' => '2.0', + 'id' => '1', + 'result' => ['status' => 'success'], + ]), + ]); + + $transport = new HttpTransport([ + 'url' => 'http://example.com/api', + 'access_token' => 'my-oauth-token', + 'timeout' => 30, + ]); + + $transport->sendRequest('test/method'); + + Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer my-oauth-token')); +}); + +it('uses access_token over api_key when both are present', function (): void { + Http::fake([ + 'http://example.com/api' => Http::response([ + 'jsonrpc' => '2.0', + 'id' => '1', + 'result' => ['status' => 'success'], + ]), + ]); + + $transport = new HttpTransport([ + 'url' => 'http://example.com/api', + 'access_token' => 'runtime-oauth-token', + 'api_key' => 'static-api-key', + 'timeout' => 30, + ]); + + $transport->sendRequest('test/method'); + + Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer runtime-oauth-token')); +}); + +it('falls back to api_key when no access_token is set', function (): void { + Http::fake([ + 'http://example.com/api' => Http::response([ + 'jsonrpc' => '2.0', + 'id' => '1', + 'result' => ['status' => 'success'], + ]), + ]); + + $transport = new HttpTransport([ + 'url' => 'http://example.com/api', + 'api_key' => 'static-api-key', + 'timeout' => 30, + ]); + + $transport->sendRequest('test/method'); + + Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer static-api-key')); +}); + +it('throws AuthorizationException on HTTP 401 response', function (): void { + $transport = new HttpTransportFake([ + 'url' => 'http://example.com/api', + 'access_token' => 'expired-token', + 'timeout' => 30, + ]); + + $transport->returnUnauthorized(); + + expect(fn (): array => $transport->sendRequest('tools/list')) + ->toThrow(AuthorizationException::class, 'MCP server returned 401 Unauthorized'); +}); + +it('throws TransportException on HTTP 403 response, not AuthorizationException', function (): void { + $transport = new HttpTransportFake([ + 'url' => 'http://example.com/api', + 'access_token' => 'some-token', + 'timeout' => 30, + ]); + + $transport->failHttpRequest(403); + + expect(fn (): array => $transport->sendRequest('tools/list')) + ->toThrow(TransportException::class) + ->not->toThrow(AuthorizationException::class); +});