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
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@

Version 1.5.0 focuses on structured tool output, richer prompt support, and improved discoverability across the MCP protocol:

- **Structured tool responses** – Use `ToolResponse::structured()` to emit plain text and JSON payloads simultaneously. The server automatically publishes `structuredContent`, and `tools/call` now attaches structured metadata when returning arrays to satisfy the MCP 2025-06-18 specification. Tool interfaces optionally expose `title()` and `outputSchema()` so schema-aware clients can display richer results.
- **Structured tool responses** – Use `ToolResponse::structured()` to emit plain text and JSON payloads simultaneously. Existing tools keep returning JSON strings inside the `content` array for backwards compatibility, while new stubs expose a `$autoStructuredOutput = true` flag so array responses automatically populate `structuredContent` per the MCP 2025-06-18 specification. Tool interfaces optionally expose `title()` and `outputSchema()` so schema-aware clients can display richer results.
- **Tabular response helpers** – The new `FormatsTabularToolResponses` trait converts array data into CSV or Markdown tables with consistent MIME typing. Example tools and Pest tests demonstrate column normalization, validation, and multi-format output generation for data-heavy workflows.
- **Enhanced tool pagination & metadata** – Cursor-based pagination for `tools/list` scales to large catalogs, configurable via the `MCP_TOOLS_PAGE_SIZE` environment variable. The server advertises schema awareness and `listChanged` hints during capability negotiation, with integration tests covering `nextCursor` behavior.
- **Prompt registry & generator** – A full prompt registry backed by configuration files powers the new `prompts/list` and `prompts/get` handlers. Developers can scaffold prompts using `php artisan make:mcp-prompt`, while the service provider surfaces prompt schemas inside the MCP handshake for immediate client discovery.
Expand Down Expand Up @@ -802,6 +802,30 @@ if ($validator->fails()) {
// Proceed with validated $arguments['userId'] and $arguments['includeDetails']
```

#### Automatic structuredContent opt-in for array responses (v1.5+)

Laravel MCP Server 1.5 keeps backwards compatibility with legacy tools by leaving associative-array results as JSON strings under the `content` field. New installations created from the `make:mcp-tool` stub expose a `$autoStructuredOutput = true` property so array payloads are promoted into the `structuredContent` field automatically.

To enable the new behaviour on an existing tool, declare the property on your class:

```php
class OrderLookupTool implements ToolInterface
{
protected bool $autoStructuredOutput = true;

public function execute(array $arguments): array
{
// Returning an array now fills the `structuredContent` field automatically.
return [
'orderId' => $arguments['id'],
'status' => 'shipped',
];
}
}
```

You can always bypass the flag by returning a `ToolResponse` instance directly—use `ToolResponse::structured()` when you need full control over both human-readable text and machine-readable metadata.

#### Formatting flat tool results as CSV or Markdown (v1.5.0+)

When your tool needs to return structured tabular data—like the `lol_list_champions` example—you can opt into richer response formats by returning a `ToolResponse`. The new helper trait `OPGG\LaravelMcpServer\Services\ToolService\Concerns\FormatsTabularToolResponses` provides convenience methods to turn flat arrays into CSV strings or Markdown tables. Nothing is automatic: simply `use` the trait in tools that need it.
Expand Down
53 changes: 37 additions & 16 deletions src/Server/Request/ToolsCallHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ public function execute(string $method, ?array $params = null): array
$arguments = $params['arguments'] ?? [];
$result = $tool->execute($arguments);

$autoStructuredOutput = false;
if (property_exists($tool, 'autoStructuredOutput')) {
$autoStructuredOutput = (bool) (function () {
return $this->autoStructuredOutput;
})->call($tool);
}

$preparedResult = $result instanceof ToolResponse
? $result->toArray()
: $result;
Expand All @@ -81,17 +88,23 @@ public function execute(string $method, ?array $params = null): array
return $preparedResult;
}

try {
json_encode($preparedResult, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
} catch (JsonException $exception) {
throw new JsonRpcErrorException(
message: 'Failed to encode tool result as JSON: '.$exception->getMessage(),
code: JsonRpcErrorCode::INTERNAL_ERROR
);
if ($autoStructuredOutput) {
$this->encodeJson($preparedResult);

return [
'structuredContent' => $preparedResult,
];
}

$text = $this->encodeJson($preparedResult);

return [
'structuredContent' => $preparedResult,
'content' => [
[
'type' => 'text',
'text' => $text,
],
],
];
}

Expand All @@ -106,14 +119,7 @@ public function execute(string $method, ?array $params = null): array
];
}

try {
$text = json_encode($preparedResult, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
} catch (JsonException $exception) {
throw new JsonRpcErrorException(
message: 'Failed to encode tool result as JSON: '.$exception->getMessage(),
code: JsonRpcErrorCode::INTERNAL_ERROR
);
}
$text = $this->encodeJson($preparedResult);

return [
'content' => [
Expand All @@ -129,4 +135,19 @@ public function execute(string $method, ?array $params = null): array
];
}
}

/**
* Ensure results remain JSON serializable while providing consistent error handling.
*/
private function encodeJson(mixed $value): string
{
try {
return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
} catch (JsonException $exception) {
throw new JsonRpcErrorException(
message: 'Failed to encode tool result as JSON: '.$exception->getMessage(),
code: JsonRpcErrorCode::INTERNAL_ERROR
);
}
}
}
15 changes: 15 additions & 0 deletions src/stubs/tool.stub
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ use OPGG\LaravelMcpServer\Services\ToolService\ToolInterface;
*/
class {{ className }} implements ToolInterface
{
/**
* Opt into automatic structuredContent conversion for array responses.
*
* HISTORY:
* - Introduced in v1.5.0 alongside the MCP 2025-06-18 structured payload spec.
* - Tools created before v1.5 omitted this property and kept legacy JSON-string outputs.
*
* BEHAVIOR:
* - true (stub default): associative arrays returned from execute() are emitted via `structuredContent`-key.
* - false: arrays are JSON-encoded and wrapped under the text-based `content`-key list for backwards compatibility.
*
* Returning a ToolResponse bypasses the flag entirely, so you can mix structured payloads with rich text manually.
*/
protected bool $autoStructuredOutput = true;

/**
* OPTIONAL: Determines if this tool requires streaming (SSE) instead of standard HTTP.
*
Expand Down
44 changes: 44 additions & 0 deletions tests/Fixtures/Tools/AutoStructuredArrayTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace OPGG\LaravelMcpServer\Tests\Fixtures\Tools;

use OPGG\LaravelMcpServer\Services\ToolService\ToolInterface;

class AutoStructuredArrayTool implements ToolInterface
{
/**
* Opt into automatic structuredContent detection for array payloads.
*/
protected bool $autoStructuredOutput = true;

public function name(): string
{
return 'auto-structured-array-tool';
}

public function description(): string
{
return 'Returns an array that should be emitted via structuredContent.';
}

public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [],
];
}

public function annotations(): array
{
return [];
}

public function execute(array $arguments): array
{
return [
'status' => 'ok',
'echo' => $arguments,
];
}
}
39 changes: 39 additions & 0 deletions tests/Fixtures/Tools/LegacyArrayTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace OPGG\LaravelMcpServer\Tests\Fixtures\Tools;

use OPGG\LaravelMcpServer\Services\ToolService\ToolInterface;

class LegacyArrayTool implements ToolInterface
{
public function name(): string
{
return 'legacy-array-tool';
}

public function description(): string
{
return 'Returns a simple associative array for backward compatibility tests.';
}

public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [],
];
}

public function annotations(): array
{
return [];
}

public function execute(array $arguments): array
{
return [
'status' => 'ok',
'echo' => $arguments,
];
}
}
85 changes: 85 additions & 0 deletions tests/Http/StreamableHttpTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

use OPGG\LaravelMcpServer\Server\MCPServer;
use OPGG\LaravelMcpServer\Services\ToolService\ToolRepository;
use OPGG\LaravelMcpServer\Tests\Fixtures\Tools\AutoStructuredArrayTool;
use OPGG\LaravelMcpServer\Tests\Fixtures\Tools\LegacyArrayTool;
use OPGG\LaravelMcpServer\Tests\Fixtures\Tools\TabularChampionsTool;

test('streamable http GET returns method not allowed', function () {
Expand Down Expand Up @@ -43,6 +45,89 @@
->toContain('HelloWorld `Tester` developer');
});

test('legacy array tool keeps payload in content by default', function () {
$originalTools = config('mcp-server.tools');
$tools = $originalTools;
$tools[] = LegacyArrayTool::class;
config()->set('mcp-server.tools', array_values(array_unique($tools)));

app()->forgetInstance(ToolRepository::class);
app()->forgetInstance(MCPServer::class);

$payload = [
'jsonrpc' => '2.0',
'id' => 12,
'method' => 'tools/call',
'params' => [
'name' => 'legacy-array-tool',
'arguments' => [
'foo' => 'bar',
],
],
];

$response = $this->postJson('/mcp', $payload);

$response->assertStatus(200);
$data = $response->json('result');

expect($data)->toHaveKey('content');
expect($data)->not->toHaveKey('structuredContent');
expect($data['content'][0]['type'])->toBe('text');

$decoded = json_decode($data['content'][0]['text'], true, 512, JSON_THROW_ON_ERROR);
expect($decoded)->toBe([
'status' => 'ok',
'echo' => [
'foo' => 'bar',
],
]);

config()->set('mcp-server.tools', $originalTools);
app()->forgetInstance(ToolRepository::class);
app()->forgetInstance(MCPServer::class);
});

test('tools can opt into automatic structuredContent detection', function () {
$originalTools = config('mcp-server.tools');
$tools = $originalTools;
$tools[] = AutoStructuredArrayTool::class;
config()->set('mcp-server.tools', array_values(array_unique($tools)));

app()->forgetInstance(ToolRepository::class);
app()->forgetInstance(MCPServer::class);

$payload = [
'jsonrpc' => '2.0',
'id' => 13,
'method' => 'tools/call',
'params' => [
'name' => 'auto-structured-array-tool',
'arguments' => [
'alpha' => 'beta',
],
],
];

$response = $this->postJson('/mcp', $payload);

$response->assertStatus(200);
$data = $response->json('result');

expect($data)->not->toHaveKey('content');
expect($data)->toHaveKey('structuredContent');
expect($data['structuredContent'])->toBe([
'status' => 'ok',
'echo' => [
'alpha' => 'beta',
],
]);

config()->set('mcp-server.tools', $originalTools);
app()->forgetInstance(ToolRepository::class);
app()->forgetInstance(MCPServer::class);
});

test('notification returns HTTP 202 with no body', function () {
$payload = [
'jsonrpc' => '2.0',
Expand Down