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
13 changes: 13 additions & 0 deletions src/Server/Primitive.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ abstract class Primitive implements Arrayable

protected string $description = '';

/**
* @var array<string, mixed>
*/
protected array $meta = [];

public function name(): string
{
return $this->name === ''
Expand All @@ -40,6 +45,14 @@ public function description(): string
: $this->description;
}

/**
* @return array<string, mixed>
*/
public function meta(): array
{
return $this->meta;
}

public function eligibleForRegistration(): bool
{
if (method_exists($this, 'shouldRegister')) {
Expand Down
22 changes: 19 additions & 3 deletions src/Server/Tool.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Illuminate\JsonSchema\JsonSchema;
use Laravel\Mcp\Server\Contracts\Tools\Annotation;
use Laravel\Mcp\Support\SecurityScheme;
use ReflectionAttribute;
use ReflectionClass;

Expand All @@ -19,6 +20,14 @@ public function schema(JsonSchema $schema): array
return [];
}

/**
* @return array<string, mixed>
*/
public function securitySchemes(SecurityScheme $scheme): array
{
return [];
}

/**
* @return array<string, mixed>
*/
Expand Down Expand Up @@ -51,7 +60,9 @@ public function toMethodCall(): array
* title?: string|null,
* description?: string|null,
* inputSchema?: array<string, mixed>,
* annotations?: array<string, mixed>|object
* annotations?: array<string, mixed>|object,
* securitySchemes?: array<string, mixed>,
* _meta?: array<string, mixed>
* }
*/
public function toArray(): array
Expand All @@ -63,12 +74,17 @@ public function toArray(): array

$schema['properties'] ??= (object) [];

return [
return array_merge([
'name' => $this->name(),
'title' => $this->title(),
'description' => $this->description(),
'inputSchema' => $schema,
'annotations' => $annotations === [] ? (object) [] : $annotations,
];
], array_filter([
'securitySchemes' => SecurityScheme::make(
$this->securitySchemes(...),
),
'_meta' => filled($this->meta()) ? $this->meta() : null,
], filled(...)));
}
}
152 changes: 152 additions & 0 deletions src/Support/SecurityScheme.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Support;

use Closure;

class SecurityScheme
{
protected string $type;

/** @var array<int, string> */
protected array $scopes = [];

/** @var array<string, mixed> */
protected array $additionalData = [];

private function __construct(string $type = '')
{
if ($type !== '') {
$this->type = $type;
}
}

protected function setType(string $type): self
{
$this->type = $type;

return $this;
}

/**
* @param (Closure(SecurityScheme): array<string, SecurityScheme|array<string, mixed>>)|array<string, SecurityScheme|array<string, mixed>> $schemes
* @return array<string, array<string, mixed>>
*/
public static function make(Closure|array $schemes = []): array
{
if ($schemes instanceof Closure) {
$schemes = $schemes(new self);
}

$result = collect($schemes)->map(
fn ($scheme): array => $scheme instanceof self ? $scheme->toArray() : $scheme
);

return $result->toArray();
}

/**
* @param string|array<int, string> ...$scopes
*/
public function scopes(string|array ...$scopes): self
{
$this->scopes = collect($scopes)
->flatten()
->toArray();

return $this;
}

public function with(string $key, mixed $value): self
{
$this->additionalData[$key] = $value;

return $this;
}

/**
* @return array<string, mixed>
*/
public function toArray(): array
{
$scheme = array_merge(['type' => $this->type], $this->additionalData);

if ($this->scopes !== []) {
$scheme['scopes'] = $this->scopes;
}

return $scheme;
}

public static function type(string $type): self
{
return new self($type);
}

/**
* @return array<string, string>
*/
public static function noauth(): array
{
return ['type' => 'noauth'];
}

/**
* @param string|array<int, string> ...$scopes
*/
public static function oauth2(string|array ...$scopes): self
{
$instance = self::type('oauth2');

if ($scopes !== []) {
$instance->scopes(...$scopes);
}

return $instance;
}

/**
* @return array<string, string>
*/
public static function apiKey(string $name = 'api_key', string $in = 'header'): array
{
return [
'type' => 'apiKey',
'name' => $name,
'in' => $in,
];
}

/**
* @return array<string, string>
*/
public static function bearer(string $format = 'JWT'): array
{
return [
'type' => 'http',
'scheme' => 'bearer',
'bearerFormat' => $format,
];
}

/**
* @return array<string, string>
*/
public static function basic(): array
{
return [
'type' => 'http',
'scheme' => 'basic',
];
}

/**
* @return array<string, mixed>
*/
public function __invoke(): array
{
return $this->toArray();
}
}
109 changes: 109 additions & 0 deletions tests/Unit/Support/SecuritySchemeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

use Laravel\Mcp\Support\SecurityScheme;

it('returns an oauth scheme', function (): void {
$scheme = SecurityScheme::oauth2('read', 'write')
->with('flows', [
'authorizationCode' => [
'authorizationUrl' => 'https://example.com/oauth/authorize',
'tokenUrl' => 'https://example.com/oauth/token',
],
]);

expect($scheme->toArray())->toBe([
'type' => 'oauth2',
'flows' => [
'authorizationCode' => [
'authorizationUrl' => 'https://example.com/oauth/authorize',
'tokenUrl' => 'https://example.com/oauth/token',
],
],
'scopes' => ['read', 'write'],
]);
});

it('can set scopes', function (): void {
$scheme = SecurityScheme::oauth2()
->scopes('read', 'write', 'delete');

expect($scheme->toArray()['scopes'])->toBe(['read', 'write', 'delete']);
});

it('can set an oauth tyoe', function (): void {
$scheme = SecurityScheme::oauth2();

expect($scheme->toArray()['type'])->toBe('oauth2');
});

it('can set a noauth type', function (): void {
$scheme = SecurityScheme::noauth();

expect($scheme)->toBe([
'type' => 'noauth',
]);
});

it('can set a type', function (): void {
$scheme = SecurityScheme::type('apiKey');

expect($scheme->toArray()['type'])->toBe('apiKey');
});

it('can set an apiKey auth', function (): void {
$scheme = SecurityScheme::apiKey('X-API-KEY', 'header');

expect($scheme)->toBe([
'type' => 'apiKey',
'name' => 'X-API-KEY',
'in' => 'header',
]);
});

it('can set a bearer auth', function (): void {
$scheme = SecurityScheme::bearer('JWT');

expect($scheme)->toBe([
'type' => 'http',
'scheme' => 'bearer',
'bearerFormat' => 'JWT',
]);
});

it('can set a basic auth', function (): void {
$scheme = SecurityScheme::basic();

expect($scheme)->toBe([
'type' => 'http',
'scheme' => 'basic',
]);
});

it('can make a set of schemes', function (): void {
$schemes = SecurityScheme::make([
SecurityScheme::basic(),
SecurityScheme::bearer('JWT'),
[
'type' => 'apiKey',
'name' => 'X-API-KEY',
'in' => 'header',
],
]);

expect($schemes)->toBe([
[
'type' => 'http',
'scheme' => 'basic',
],
[
'type' => 'http',
'scheme' => 'bearer',
'bearerFormat' => 'JWT',
],
[
'type' => 'apiKey',
'name' => 'X-API-KEY',
'in' => 'header',
],
]);
});
35 changes: 35 additions & 0 deletions tests/Unit/Tools/ToolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Laravel\Mcp\Server\Tools\Annotations\IsOpenWorld;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tools\ToolResult;
use Laravel\Mcp\Support\SecurityScheme;

test('the default name is in kebab case', function (): void {
$tool = new AnotherComplexToolName;
Expand Down Expand Up @@ -94,6 +95,23 @@
->and($array['inputSchema']['required'])->toEqual(['message']);
});

it('returns no meta by default', function (): void {
$tool = new TestTool;
expect($tool->meta())->toEqual([]);
});

it('can have custom meta', function (): void {
$tool = new CustomMetaTool;
expect($tool->toArray()['_meta'])->toEqual(['key' => 'value']);
});

it('can set security schemes', function (): void {
$tool = new SecuritySchemesTool;
expect($tool->toArray()['securitySchemes'])->toEqual([
['type' => 'oauth2', 'scopes' => ['read', 'write']],
]);
});

class TestTool extends Tool
{
public function description(): string
Expand Down Expand Up @@ -155,3 +173,20 @@ public function schema(\Illuminate\JsonSchema\JsonSchema $schema): array
];
}
}

class CustomMetaTool extends TestTool
{
protected array $meta = [
'key' => 'value',
];
}

class SecuritySchemesTool extends TestTool
{
public function securitySchemes(SecurityScheme $scheme): array
{
return [
$scheme::oauth2('read', 'write'),
];
}
}