Skip to content

Commit 09bc7b9

Browse files
fsmonterluisdalmolinCopilot
authored
Implement Streamable HTTP Transport with SSE (POST request) (#12)
* Implement Streamable HTTP Transport with SSE (POST request) - Implement client-initiated request (POST endpoint) with JSON-RPC and SSE responses. - Fixes LaravelModelToolkit collection * Code style * Fix styling * Update routes/api.php Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Luís Dalmolin <[email protected]> Co-authored-by: luisdalmolin <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent e7ccf3a commit 09bc7b9

23 files changed

+475
-115
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: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@
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-
Route::prefix('mcp')
8-
->middleware(array_merge([SseEnabledMiddleware::class], (array) config('loop.sse.middleware', [])))
9-
->group(function () {
10-
Route::get('/', McpController::class);
11-
Route::post('/', McpController::class);
12-
});
7+
$path = config('loop.streamable_http.path', '/mcp');
8+
$streamableHttpEnabled = config('loop.streamable_http.enabled', false);
9+
10+
if ($streamableHttpEnabled) {
11+
Route::prefix($path)
12+
->middleware([
13+
StreamableHttpEnabledMiddleware::class,
14+
...config('loop.streamable_http.middleware', []),
15+
])
16+
->group(function () {
17+
Route::post('/', McpController::class);
18+
});
19+
}

src/Commands/LoopMcpServerStartCommand.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,15 @@ public function handle(McpHandler $mcpHandler): int
8484
info('Received signal: '.$signal.'. Shutting down...');
8585
$this->info('Received signal: '.$signal.'. Shutting down...');
8686
$loop->stop();
87+
8788
exit(0);
8889
});
8990

9091
$loop->addSignal(SIGTERM, function ($signal) use ($loop) {
9192
info('Received signal: '.$signal.'. Shutting down...');
9293
$this->info('Received signal: '.$signal.'. Shutting down...');
9394
$loop->stop();
95+
9496
exit(0);
9597
});
9698
} else {

src/Http/Controllers/LoopController.php

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -93,16 +93,6 @@ public function storeMessage(Request $request): JsonResponse
9393
]);
9494
}
9595

96-
/**
97-
* Store a message in the cache
98-
*/
99-
protected function storeMessageInCache(array $message): void
100-
{
101-
$messages = Cache::get($this->cacheKey, []);
102-
$messages[] = $message;
103-
Cache::put($this->cacheKey, $messages, 3600); // Store for 1 hour
104-
}
105-
10696
/**
10797
* Get all messages in the conversation
10898
*/
@@ -133,6 +123,16 @@ public function clearMessages(): JsonResponse
133123
]);
134124
}
135125

126+
/**
127+
* Store a message in the cache
128+
*/
129+
protected function storeMessageInCache(array $message): void
130+
{
131+
$messages = Cache::get($this->cacheKey, []);
132+
$messages[] = $message;
133+
Cache::put($this->cacheKey, $messages, 3600); // Store for 1 hour
134+
}
135+
136136
/**
137137
* Get stored messages from cache
138138
*

src/Http/Controllers/McpController.php

Lines changed: 176 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,188 @@
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+
if ($request->isMethod('GET')) {
23+
return $this->handleGetRequest($request);
24+
}
25+
26+
$acceptsEventStream = strpos($request->header('Accept', ''), 'text/event-stream') !== false;
27+
$acceptsJson = strpos($request->header('Accept', ''), 'application/json') !== false;
28+
29+
if (! $acceptsEventStream && ! $acceptsJson) {
30+
return response()->json([
31+
'jsonrpc' => '2.0',
32+
'id' => null,
33+
'error' => [
34+
'message' => 'Not Acceptable: Client must accept text/event-stream or application/json',
35+
],
36+
], 406);
37+
}
38+
39+
$requestData = $request->all();
40+
$containsRequests = $this->containsJsonRpcRequests($requestData);
41+
42+
if (! $containsRequests) {
43+
$this->mcpHandler->handle($requestData);
44+
45+
return response('', 202);
46+
}
47+
48+
if ($this->clientPrefersJson($acceptsJson, $acceptsEventStream)) {
49+
return $this->handlePostJsonResponse($requestData);
50+
}
51+
52+
return $this->handlePostSseResponse($requestData);
53+
}
54+
55+
/**
56+
* Handle SSE response for POST requests.
57+
*
58+
* @param array<array-key, mixed> $messages JSON-RPC messages from the client
59+
*/
60+
protected function handlePostSseResponse(array $messages): StreamedResponse
61+
{
62+
$response = $this->sseService->createPostSseResponse(
63+
$messages,
64+
fn ($message) => $this->mcpHandler->handle($message),
65+
);
66+
67+
return $response;
68+
}
69+
70+
/**
71+
* Handle JSON response for POST requests.
72+
*
73+
* @param array<array-key, mixed> $messages JSON-RPC messages from the client
74+
*/
75+
protected function handlePostJsonResponse(array $messages): JsonResponse
76+
{
77+
$response = $this->mcpHandler->handle($messages);
78+
79+
return response()->json($response);
80+
}
81+
82+
/**
83+
* Handle GET requests for backwards compatibility with older clients
84+
* expecting an SSE stream for server-initiated messages.
85+
*/
86+
protected function handleGetRequest(Request $request): StreamedResponse|JsonResponse|Response
87+
{
88+
if (strpos($request->header('Accept', ''), 'text/event-stream') === false) {
89+
return response()->json([
90+
'jsonrpc' => '2.0',
91+
'id' => null,
92+
'error' => [
93+
// Using a generic error code; consult JSON-RPC spec for specific codes if necessary
94+
'code' => -32000,
95+
'message' => 'Not Acceptable: Client must include "Accept: text/event-stream" for this endpoint.',
96+
],
97+
], 406);
98+
}
99+
100+
// Placeholder for the "endpoint event" data structure.
101+
// Adjust this to match what older clients expect.
102+
$endpointEventData = [
103+
'status' => 'connected',
104+
'transport_protocol' => 'http_sse_deprecated_2024_11_05',
105+
'message' => 'SSE connection established for server-initiated messages.',
106+
];
107+
108+
return new StreamedResponse(function () use ($endpointEventData) {
109+
// Send the initial "endpoint event"
110+
// The event name 'endpoint' is a placeholder; adjust if old clients expect a different name.
111+
echo 'event: endpoint
112+
';
113+
echo 'data: '.json_encode($endpointEventData).'
114+
115+
';
116+
flush();
117+
118+
// This loop maintains the connection and sends heartbeats.
119+
// In a production environment, this section would need to integrate
120+
// with an event bus or message queue to push actual application events
121+
// to the client.
122+
while (true) {
123+
if (connection_aborted()) {
124+
break;
125+
}
126+
127+
// Send an SSE comment as a heartbeat to keep the connection alive
128+
// and help with proxy buffering.
129+
echo ': heartbeat
130+
131+
';
132+
flush();
133+
134+
// Pause before sending the next heartbeat.
135+
// The frequency of heartbeats can be adjusted.
136+
sleep(15);
137+
}
138+
}, 200, [
139+
'Content-Type' => 'text/event-stream',
140+
'Cache-Control' => 'no-cache',
141+
'X-Accel-Buffering' => 'no', // Important for Nginx and other reverse proxies
142+
'Connection' => 'keep-alive',
143+
]);
144+
}
145+
146+
/**
147+
* Check if the input contains any JSON-RPC requests.
148+
*
149+
* @param mixed $input
150+
*/
151+
protected function containsJsonRpcRequests($input): bool
13152
{
14-
$message = $request->all();
153+
// If input is an array with numeric keys, it's a batch
154+
if (is_array($input) && array_keys($input) === range(0, count($input) - 1)) {
155+
foreach ($input as $item) {
156+
if ($this->isJsonRpcRequest($item)) {
157+
return true;
158+
}
159+
}
160+
161+
return false;
162+
}
163+
164+
return $this->isJsonRpcRequest($input);
165+
}
15166

16-
return response()->json($mcpHandler->handle($message));
167+
/**
168+
* Check if the client prefers JSON over SSE.
169+
*
170+
* @param mixed $acceptsJson
171+
* @param mixed $acceptsEventStream
172+
*/
173+
protected function clientPrefersJson($acceptsJson, $acceptsEventStream): bool
174+
{
175+
return $acceptsJson && $acceptsEventStream === false;
176+
}
177+
178+
/**
179+
* Check if an item is a JSON-RPC request.
180+
*
181+
* @param mixed $item
182+
*/
183+
protected function isJsonRpcRequest($item): bool
184+
{
185+
return is_array($item) &&
186+
isset($item['jsonrpc']) &&
187+
$item['jsonrpc'] === '2.0' &&
188+
isset($item['method']) &&
189+
isset($item['id']);
17190
}
18191
}

0 commit comments

Comments
 (0)