Skip to content
Merged
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ phpunit.xml
ray.php

.claude/
CLAUDE.md
56 changes: 55 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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;
Expand All @@ -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());
Expand Down
7 changes: 7 additions & 0 deletions src/Exceptions/AuthorizationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

declare(strict_types=1);

namespace Prism\Relay\Exceptions;

class AuthorizationException extends RelayException {}
32 changes: 31 additions & 1 deletion src/Relay.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ public function getServerName(): string
return $this->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<int, Tool>
*
Expand All @@ -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)) {
Expand Down Expand Up @@ -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
*/
Expand Down
34 changes: 34 additions & 0 deletions src/RelayBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Prism\Relay;

use Prism\Prism\Tool;

class RelayBuilder
{
public function __construct(private readonly string $token) {}

/**
* @param array<string, mixed>|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<string, mixed>|null $config
* @return array<int, Tool>
*
* @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();
}
}
5 changes: 5 additions & 0 deletions src/RelayFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
56 changes: 46 additions & 10 deletions src/Transport/HttpTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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(),
Expand All @@ -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
Expand Down Expand Up @@ -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()}"
Expand Down Expand Up @@ -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}"
Expand Down
17 changes: 17 additions & 0 deletions tests/TestDoubles/HttpTransportFake.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ class HttpTransportFake extends HttpTransport

protected int $failStatus = 500;

protected bool $returnUnauthorized = false;

protected bool $invalidJsonRpc = false;

protected bool $returnError = false;
Expand All @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down
Loading
Loading