Skip to content

Commit 0133a12

Browse files
committed
Implement Streamable HTTP Transport with SSE (POST request)
- Implement client-initiated request (POST endpoint) with JSON-RPC and SSE responses. - Fixes LaravelModelToolkit collection
1 parent 47f1e1c commit 0133a12

File tree

9 files changed

+293
-14
lines changed

9 files changed

+293
-14
lines changed

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,13 +112,23 @@ To add to Cursor, or any (most?) MCP clients with a config file:
112112
}
113113
```
114114

115-
### SSE
115+
### Streamable HTTP Transport with SSE
116116

117-
Coming soon.
117+
Laravel Loop supports the [streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports) for the MCP protocol, which includes SSE capabilities for client-initiated requests (POST).
118+
119+
To enable the Streamable HTTP transport, update your `.env` file:
120+
121+
```bash
122+
LOOP_STREAMABLE_HTTP_ENABLED=true
123+
```
124+
125+
This will expose an MCP endpoint at `/mcp` that supports both JSON-RPC and Server-Sent Events. The endpoint is protected by Laravel Sanctum by default.
126+
127+
See [HTTP Streaming Documentation](docs/http-streaming.md) for more details on configuration, and usage.
118128

119129
## Roadmap
120130

121131
- [ ] Add a chat component to the package, so you can use the tools inside the application without an MCP client.
122132
- [ ] Refine the existing tools
123133
- [ ] Add write capabilities to the existing tools
124-
- [ ] Add tests
134+
- [ ] Add tests

config/loop.php

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,27 @@
1010
| provided by the Laravel Loop package.
1111
|
1212
*/
13+
'streamable_http' => [
14+
/*
15+
|--------------------------------------------------------------------------
16+
| Streamable HTTP Endpoint Enabled?
17+
|--------------------------------------------------------------------------
18+
*/
19+
'enabled' => env('LOOP_STREAMABLE_HTTP_ENABLED', false),
1320

14-
'sse' => [
1521
/*
1622
|--------------------------------------------------------------------------
17-
| SSE Endpoint Enabled?
23+
| Streamable HTTP Endpoint Path
1824
|--------------------------------------------------------------------------
25+
|
26+
| The path where the MCP HTTP endpoint will be available.
27+
|
1928
*/
20-
'enabled' => env('LOOP_SSE_ENABLED', false),
29+
'path' => env('LOOP_STREAMABLE_HTTP_PATH', '/mcp'),
2130

2231
/*
2332
|--------------------------------------------------------------------------
24-
| SSE Endpoint Authentication Middleware
33+
| Streamable HTTP Endpoint Authentication Middleware
2534
|--------------------------------------------------------------------------
2635
|
2736
| The middleware used to authenticate MCP requests.

docs/http-streaming.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Laravel Loop HTTP Streaming
2+
3+
Laravel Loop provides a streamable HTTP transport for the Model Context Protocol (MCP) that supports client-initiated requests (POST). The MCP HTTP transport is able to process JSON and SSE streaming responses on a single endpoint.
4+
5+
## Configuration
6+
7+
To enable and configure the HTTP MCP endpoint, update your `.env` file and/or `config/loop.php`:
8+
9+
```php
10+
# Enable the HTTP MCP endpoint
11+
LOOP_STREAMABLE_HTTP_ENABLED=true
12+
13+
# Set the endpoint path (default: /mcp)
14+
LOOP_STREAMABLE_HTTP_PATH=/mcp
15+
```
16+
17+
### Security Considerations
18+
19+
The MCP HTTP endpoint should be protected with proper authentication. By default, it uses Laravel Sanctum, but you can configure the middleware stack in `config/loop.php`:
20+
21+
```php
22+
'middleware' => ['auth:sanctum'],
23+
```
24+
25+
## Client-Initiated Messages (POST)
26+
27+
Clients can send JSON-RPC 2.0 messages to the MCP server by making HTTP POST requests to the configured endpoint.
28+
29+
### Single Request
30+
31+
```bash
32+
curl -X POST http://your-app.test/mcp \
33+
-H "Content-Type: application/json" \
34+
-H "Accept: application/json" \
35+
-H "Authorization: Bearer YOUR_TOKEN" \
36+
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"Test Client"},"capabilities":{},"protocolVersion":"2024-11-05"}}'
37+
```
38+
39+
### Batch Requests
40+
41+
```bash
42+
curl -X POST http://your-app.test/mcp \
43+
-H "Content-Type: application/json" \
44+
-H "Accept: application/json" \
45+
-H "Authorization: Bearer YOUR_TOKEN" \
46+
-d '[{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"Test Client"},"capabilities":{},"protocolVersion":"2024-11-05"}},{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}]'
47+
```
48+
49+
### SSE Streaming Responses
50+
51+
Clients can request SSE responses for batch processing by setting the `Accept` header to include `text/event-stream`:
52+
53+
```bash
54+
curl -X POST http://your-app.test/mcp \
55+
-H "Content-Type: application/json" \
56+
-H "Accept: text/event-stream" \
57+
-H "Authorization: Bearer YOUR_TOKEN" \
58+
-d '[{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"Test Client"},"capabilities":{},"protocolVersion":"2024-11-05"}},{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}]'
59+
```
60+
61+
The server will respond with an SSE stream containing each response as a separate event.

routes/api.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,27 @@
22

33
use Illuminate\Support\Facades\Route;
44
use Kirschbaum\Loop\Http\Controllers\McpController;
5-
use Kirschbaum\Loop\Http\Middleware\SseEnabledMiddleware;
5+
use Kirschbaum\Loop\Http\Middleware\StreamableHttpEnabledMiddleware;
66

7+
<<<<<<< HEAD
78
Route::prefix('mcp')
89
->middleware(array_merge([SseEnabledMiddleware::class], (array) config('loop.sse.middleware', [])))
910
->group(function () {
1011
Route::get('/', McpController::class);
1112
Route::post('/', McpController::class);
1213
});
14+
=======
15+
$path = config('loop.streamable_http.path', '/mcp');
16+
$streamableHttpEnabled = config('loop.streamable_http.enabled', false);
17+
18+
if ($streamableHttpEnabled) {
19+
Route::prefix($path)
20+
->middleware([
21+
StreamableHttpEnabledMiddleware::class,
22+
...config('loop.sse.middleware', []),
23+
])
24+
->group(function () {
25+
Route::post('/', McpController::class);
26+
});
27+
}
28+
>>>>>>> 764429b (Implement Streamable HTTP Transport with SSE (POST request))

src/Http/Controllers/McpController.php

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,122 @@
44

55
use Illuminate\Http\JsonResponse;
66
use Illuminate\Http\Request;
7+
use Illuminate\Http\Response;
78
use Illuminate\Routing\Controller;
89
use Kirschbaum\Loop\McpHandler;
10+
use Kirschbaum\Loop\Services\SseService;
11+
use Symfony\Component\HttpFoundation\StreamedResponse;
912

1013
class McpController extends Controller
1114
{
12-
public function __invoke(Request $request, McpHandler $mcpHandler): JsonResponse
15+
public function __construct(protected McpHandler $mcpHandler, protected SseService $sseService) {}
16+
17+
/**
18+
* Handle MCP requests.
19+
*/
20+
public function __invoke(Request $request): JsonResponse|StreamedResponse|Response
21+
{
22+
$acceptsEventStream = strpos($request->header('Accept', ''), 'text/event-stream') !== false;
23+
$acceptsJson = strpos($request->header('Accept', ''), 'application/json') !== false;
24+
25+
if (! $acceptsEventStream && ! $acceptsJson) {
26+
return response()->json([
27+
'jsonrpc' => '2.0',
28+
'id' => null,
29+
'error' => [
30+
'message' => 'Not Acceptable: Client must accept text/event-stream or application/json',
31+
],
32+
], 406);
33+
}
34+
35+
$requestData = $request->all();
36+
$containsRequests = $this->containsJsonRpcRequests($requestData);
37+
38+
if (! $containsRequests) {
39+
$this->mcpHandler->handle($requestData);
40+
41+
return response('', 202);
42+
}
43+
44+
if ($this->clientPrefersJson($acceptsJson, $acceptsEventStream)) {
45+
return $this->handlePostJsonResponse($requestData);
46+
}
47+
48+
return $this->handlePostSseResponse($requestData);
49+
}
50+
51+
/**
52+
* Handle SSE response for POST requests.
53+
*
54+
* @param array $messages JSON-RPC messages from the client
55+
*/
56+
protected function handlePostSseResponse(
57+
array $messages
58+
): StreamedResponse {
59+
$response = $this->sseService->createPostSseResponse(
60+
$messages,
61+
fn ($message) => $this->mcpHandler->handle($message),
62+
);
63+
64+
return $response;
65+
}
66+
67+
/**
68+
* Handle JSON response for POST requests.
69+
*/
70+
protected function handlePostJsonResponse(array $messages): JsonResponse
1371
{
14-
$message = $request->all();
72+
$response = $this->mcpHandler->handle($messages);
1573

16-
return response()->json($mcpHandler->handle($message));
74+
return response()->json($response);
75+
}
76+
77+
/**
78+
* Handle GET requests (server-initiated messages) according to MCP specification.
79+
*/
80+
protected function handleGetRequest(Request $request)
81+
{
82+
// TODO
83+
}
84+
85+
/**
86+
* Check if the input contains any JSON-RPC requests.
87+
*
88+
* @param mixed $input
89+
*/
90+
protected function containsJsonRpcRequests($input): bool
91+
{
92+
// If input is an array with numeric keys, it's a batch
93+
if (is_array($input) && array_keys($input) === range(0, count($input) - 1)) {
94+
foreach ($input as $item) {
95+
if ($this->isJsonRpcRequest($item)) {
96+
return true;
97+
}
98+
}
99+
100+
return false;
101+
}
102+
103+
return $this->isJsonRpcRequest($input);
104+
}
105+
106+
/**
107+
* Check if the client prefers JSON over SSE.
108+
*/
109+
protected function clientPrefersJson($acceptsJson, $acceptsEventStream): bool
110+
{
111+
return $acceptsJson && $acceptsEventStream === false;
112+
}
113+
114+
/**
115+
* Check if an item is a JSON-RPC request.
116+
*/
117+
protected function isJsonRpcRequest($item): bool
118+
{
119+
return is_array($item) &&
120+
isset($item['jsonrpc']) &&
121+
$item['jsonrpc'] === '2.0' &&
122+
isset($item['method']) &&
123+
isset($item['id']);
17124
}
18125
}

src/Http/Middleware/SseEnabledMiddleware.php renamed to src/Http/Middleware/StreamableHttpEnabledMiddleware.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
use Illuminate\Http\Request;
77
use Symfony\Component\HttpFoundation\Response;
88

9-
class SseEnabledMiddleware
9+
class StreamableHttpEnabledMiddleware
1010
{
1111
public function handle(Request $request, Closure $next): Response
1212
{
13-
if (! config('loop.sse.enabled')) {
13+
if (! config('loop.streamable_http.enabled')) {
1414
return response('Not Found', 404);
1515
}
1616

src/LoopServiceProvider.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Kirschbaum\Loop;
44

55
use Kirschbaum\Loop\Commands\LoopMcpServerStartCommand;
6+
use Kirschbaum\Loop\Services\SseService;
67
use Spatie\LaravelPackageTools\Package;
78
use Spatie\LaravelPackageTools\PackageServiceProvider;
89

@@ -34,5 +35,9 @@ public function packageBooted(): void
3435

3536
return $loop;
3637
});
38+
39+
$this->app->singleton(SseService::class, function ($app) {
40+
return new SseService;
41+
});
3742
}
3843
}

src/Services/SseService.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
namespace Kirschbaum\Loop\Services;
4+
5+
use Symfony\Component\HttpFoundation\StreamedResponse;
6+
7+
class SseService
8+
{
9+
/**
10+
* Create a streamed SSE response for a POST request.
11+
*/
12+
public function createPostSseResponse(array $messages, callable $processor): StreamedResponse
13+
{
14+
$headers = [
15+
'Content-Type' => 'text/event-stream',
16+
'Cache-Control' => 'no-cache, no-store, max-age=0, must-revalidate',
17+
'Pragma' => 'no-cache',
18+
'X-Accel-Buffering' => 'no',
19+
];
20+
21+
if (! ini_get('safe_mode')) {
22+
set_time_limit(0);
23+
}
24+
25+
return new StreamedResponse(function () use ($messages, $processor) {
26+
if (ob_get_level()) {
27+
ob_end_clean();
28+
}
29+
30+
echo ": stream opened\n\n";
31+
flush();
32+
33+
if (! isset($messages[0])) {
34+
$messages = [$messages];
35+
}
36+
37+
foreach ($messages as $message) {
38+
if (! isset($message['id'])) {
39+
logger('Missing ID in message');
40+
41+
continue;
42+
}
43+
44+
$response = $processor($message);
45+
46+
echo $this->formatSseEvent($response, eventId: null);
47+
flush();
48+
}
49+
}, 200, $headers);
50+
}
51+
52+
/**
53+
* Format a JSON-RPC message as an SSE event.
54+
*/
55+
public function formatSseEvent(array $message, ?string $eventId = null): string
56+
{
57+
$output = '';
58+
59+
if ($eventId !== null) {
60+
$output .= "id: {$eventId}\n";
61+
}
62+
63+
// Add event type (default to 'message')
64+
$output .= "event: message\n";
65+
66+
$jsonData = json_encode($message);
67+
$output .= "data: {$jsonData}\n\n";
68+
69+
return $output;
70+
}
71+
}

src/Toolkits/LaravelModelToolkit.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public function getTools(): ToolCollection
3737
$tools = new ToolCollection;
3838

3939
foreach ($this->models as $model) {
40-
$tools->merge($this->buildModelTools($model));
40+
$tools = $tools->merge($this->buildModelTools($model));
4141
}
4242

4343
return $tools;

0 commit comments

Comments
 (0)