Skip to content

Commit 4652f17

Browse files
authored
feat(notifications): implement complete MCP notification system (#44)
- Add HTTP 202 response support for notifications per MCP specification - Create make:mcp-notification command for generating notification handlers - Implement notification handler framework with void return type - Add comprehensive notification stub template with examples - Create standard notification handlers (Progress, Cancelled, Message, Initialized) - Add robust error handling and logging for notifications - Add ProcessMessageData.isNotification flag for proper response routing - Include test coverage for notification HTTP 202 behavior - Add practical documentation with real-world examples for beginners
1 parent e7d9c0f commit 4652f17

14 files changed

+633
-19
lines changed

CLAUDE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
1616
- **List all tools**: `php artisan mcp:test-tool --list`
1717
- **Test tool with JSON input**: `php artisan mcp:test-tool ToolName --input='{"param":"value"}'`
1818

19+
### MCP Notification Development
20+
- **Create notification handler**: `php artisan make:mcp-notification HandlerName --method=notifications/method`
21+
- **Test notification**: Returns HTTP 202 with empty body
22+
1923
### Configuration Publishing
2024
- **Publish config file**: `php artisan vendor:publish --provider="OPGG\LaravelMcpServer\LaravelMcpServerServiceProvider"`
2125

@@ -57,6 +61,12 @@ php artisan octane:start
5761
- **ResourcesReadHandler**: Reads resource content by URI
5862
- **PingHandler**: Health check endpoint
5963

64+
### Notification Handlers
65+
- **InitializedHandler**: Processes client initialization acknowledgments
66+
- **ProgressHandler**: Handles progress updates for long-running operations
67+
- **CancelledHandler**: Processes request cancellation notifications
68+
- **MessageHandler**: Handles general logging and communication messages
69+
6070
### Tool System
6171
Tools implement `ToolInterface` and are registered in `config/mcp-server.php`. Each tool defines:
6272
- Input schema for parameter validation
@@ -102,6 +112,12 @@ Primary config: `config/mcp-server.php`
102112
- Example resources: `src/Services/ResourceService/Examples/`
103113
- Resource stub templates: `src/stubs/resource.stub`, `src/stubs/resource_template.stub`
104114

115+
### Key Files for Notification Development
116+
- Notification handler base class: `src/Protocol/Handlers/NotificationHandler.php`
117+
- Standard notification handlers: `src/Server/Notification/`
118+
- Notification stub template: `src/stubs/notification.stub`
119+
- Make notification command: `src/Console/Commands/MakeMcpNotificationCommand.php`
120+
105121
## Package Development Notes
106122

107123
### Project Structure

README.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,85 @@ class WelcomePrompt extends Prompt
702702

703703
Prompts can embed resources and return sequences of messages to guide an LLM. See the official documentation for advanced examples and best practices.
704704

705+
### Working with Notifications
706+
707+
Notifications are one-way messages from MCP clients that return HTTP 202 (no response). Use them for logging, progress updates, and event handling.
708+
709+
**Create a notification handler:**
710+
711+
```bash
712+
php artisan make:mcp-notification ProgressHandler --method=notifications/progress
713+
```
714+
715+
**Example handlers for common scenarios:**
716+
717+
```php
718+
// Progress tracking for file uploads
719+
class ProgressHandler extends NotificationHandler
720+
{
721+
protected const HANDLE_METHOD = 'notifications/progress';
722+
723+
public function execute(?array $params = null): void
724+
{
725+
$token = $params['progressToken'] ?? null;
726+
$progress = $params['progress'] ?? 0;
727+
$total = $params['total'] ?? 100;
728+
729+
// Store in cache for real-time updates
730+
Cache::put("upload_progress_{$token}", [
731+
'progress' => $progress,
732+
'total' => $total,
733+
'percentage' => round(($progress / $total) * 100, 2)
734+
], 300);
735+
}
736+
}
737+
738+
// User activity logging
739+
class UserActivityHandler extends NotificationHandler
740+
{
741+
protected const HANDLE_METHOD = 'notifications/user_activity';
742+
743+
public function execute(?array $params = null): void
744+
{
745+
UserActivity::create([
746+
'user_id' => $params['userId'],
747+
'action' => $params['action'],
748+
'ip_address' => request()->ip(),
749+
'user_agent' => request()->userAgent(),
750+
]);
751+
}
752+
}
753+
754+
// Task cancellation
755+
class CancelledHandler extends NotificationHandler
756+
{
757+
protected const HANDLE_METHOD = 'notifications/cancelled';
758+
759+
public function execute(?array $params = null): void
760+
{
761+
$requestId = $params['requestId'] ?? null;
762+
if ($requestId) {
763+
// Stop background job
764+
Queue::deleteReserved('default', $requestId);
765+
\Log::info("Task {$requestId} cancelled by client");
766+
}
767+
}
768+
}
769+
```
770+
771+
**Register handlers in your service provider:**
772+
773+
```php
774+
// In AppServiceProvider or dedicated MCP service provider
775+
public function boot()
776+
{
777+
$server = app(MCPServer::class);
778+
$server->registerNotificationHandler(new ProgressHandler());
779+
$server->registerNotificationHandler(new UserActivityHandler());
780+
}
781+
```
782+
783+
**Built-in handlers:** `notifications/initialized`, `notifications/progress`, `notifications/cancelled`, `notifications/message`
705784

706785
### Testing MCP Tools
707786

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
<?php
2+
3+
namespace OPGG\LaravelMcpServer\Console\Commands;
4+
5+
use Illuminate\Console\Command;
6+
use Illuminate\Filesystem\Filesystem;
7+
use Illuminate\Support\Str;
8+
9+
class MakeMcpNotificationCommand extends Command
10+
{
11+
/**
12+
* The name and signature of the console command.
13+
*
14+
* @var string
15+
*/
16+
protected $signature = 'make:mcp-notification {name : The name of the MCP notification handler} {--method= : The notification method to handle}';
17+
18+
/**
19+
* The console command description.
20+
*
21+
* @var string
22+
*/
23+
protected $description = 'Create a new MCP notification handler class';
24+
25+
/**
26+
* The filesystem instance.
27+
*
28+
* @var \Illuminate\Filesystem\Filesystem
29+
*/
30+
protected $files;
31+
32+
/**
33+
* Create a new command instance.
34+
*
35+
* @return void
36+
*/
37+
public function __construct(Filesystem $files)
38+
{
39+
parent::__construct();
40+
41+
$this->files = $files;
42+
}
43+
44+
/**
45+
* Execute the console command.
46+
*
47+
* @return int
48+
*/
49+
public function handle()
50+
{
51+
$className = $this->getClassName();
52+
$path = $this->getPath($className);
53+
$method = $this->getNotificationMethod();
54+
55+
// Check if file already exists
56+
if ($this->files->exists($path)) {
57+
$this->error("❌ MCP notification handler {$className} already exists!");
58+
59+
return 1;
60+
}
61+
62+
// Create directories if they don't exist
63+
$this->makeDirectory($path);
64+
65+
// Generate the file using stub
66+
$this->files->put($path, $this->buildClass($className, $method));
67+
68+
$this->info("✅ Created: {$path}");
69+
70+
$fullClassName = "\\App\\MCP\\Notifications\\{$className}";
71+
72+
// Ask if they want to automatically register the notification handler
73+
if ($this->confirm('🤖 Would you like to automatically register this notification handler in your MCP server?', true)) {
74+
$this->info('☑️ Add this to your MCPServer registration:');
75+
$this->comment(' // In your service provider or server setup');
76+
$this->comment(" \$server->registerNotificationHandler(new {$fullClassName}());");
77+
} else {
78+
$this->info("☑️ Don't forget to register your notification handler:");
79+
$this->comment(' // In your service provider or server setup');
80+
$this->comment(" \$server->registerNotificationHandler(new {$fullClassName}());");
81+
}
82+
83+
// Display usage instructions
84+
$this->newLine();
85+
$this->info('📋 Your notification handler overview:');
86+
$this->comment(" • Method: {$method}");
87+
$this->comment(' • Returns: HTTP 202 (no response body)');
88+
$this->comment(' • Purpose: Fire-and-forget event processing');
89+
90+
$this->newLine();
91+
$this->info('📡 Clients can send this notification via JSON-RPC:');
92+
$this->comment(' {');
93+
$this->comment(' "jsonrpc": "2.0",');
94+
$this->comment(" \"method\": \"{$method}\",");
95+
$this->comment(' "params": {');
96+
$this->comment(' "key": "value",');
97+
$this->comment(' "data": { ... }');
98+
$this->comment(' }');
99+
$this->comment(' }');
100+
101+
$this->newLine();
102+
$this->info('💡 Common notification use cases:');
103+
$this->comment(' • Progress updates for long-running tasks');
104+
$this->comment(' • Event logging and activity tracking');
105+
$this->comment(' • Real-time notifications and broadcasts');
106+
$this->comment(' • Background job triggering');
107+
$this->comment(' • Request cancellation handling');
108+
109+
$this->newLine();
110+
$this->info('🧪 Test your notification handler:');
111+
$this->comment(' curl -X POST http://localhost:8000/mcp \\');
112+
$this->comment(' -H "Content-Type: application/json" \\');
113+
$this->comment(' -H "Accept: application/json, text/event-stream" \\');
114+
$this->comment(" -d '{\"jsonrpc\":\"2.0\",\"method\":\"{$method}\",\"params\":{}}'");
115+
$this->comment(' # Should return: HTTP 202 with empty body');
116+
117+
return 0;
118+
}
119+
120+
/**
121+
* Get the class name from the command argument.
122+
*
123+
* @return string
124+
*/
125+
protected function getClassName()
126+
{
127+
$name = $this->argument('name');
128+
129+
// Clean up the input: remove multiple spaces, hyphens, underscores
130+
// and handle mixed case input
131+
$name = preg_replace('/[\s\-_]+/', ' ', trim($name));
132+
133+
// Convert to StudlyCase
134+
$name = Str::studly($name);
135+
136+
// Ensure the class name ends with "Handler" if not already
137+
if (! Str::endsWith($name, 'Handler')) {
138+
$name .= 'Handler';
139+
}
140+
141+
return $name;
142+
}
143+
144+
/**
145+
* Get the notification method from option or ask user.
146+
*
147+
* @return string
148+
*/
149+
protected function getNotificationMethod()
150+
{
151+
$method = $this->option('method');
152+
153+
if (! $method) {
154+
$method = $this->ask('What notification method should this handler process? (e.g., notifications/progress)');
155+
}
156+
157+
// Ensure it starts with 'notifications/' if not already
158+
if (! Str::startsWith($method, 'notifications/')) {
159+
$method = 'notifications/'.ltrim($method, '/');
160+
}
161+
162+
return $method;
163+
}
164+
165+
/**
166+
* Get the destination file path.
167+
*
168+
* @return string
169+
*/
170+
protected function getPath(string $className)
171+
{
172+
// Create the file in the app/MCP/Notifications directory
173+
return app_path("MCP/Notifications/{$className}.php");
174+
}
175+
176+
/**
177+
* Build the directory for the class if necessary.
178+
*
179+
* @param string $path
180+
* @return string
181+
*/
182+
protected function makeDirectory($path)
183+
{
184+
$directory = dirname($path);
185+
186+
if (! $this->files->isDirectory($directory)) {
187+
$this->files->makeDirectory($directory, 0755, true, true);
188+
}
189+
190+
return $directory;
191+
}
192+
193+
/**
194+
* Build the class with the given name.
195+
*
196+
* @return string
197+
*/
198+
protected function buildClass(string $className, string $method)
199+
{
200+
$stub = $this->files->get($this->getStubPath());
201+
202+
return $this->replaceStubPlaceholders($stub, $className, $method);
203+
}
204+
205+
/**
206+
* Get the stub file path.
207+
*
208+
* @return string
209+
*/
210+
protected function getStubPath()
211+
{
212+
return __DIR__.'/../../stubs/notification.stub';
213+
}
214+
215+
/**
216+
* Replace the stub placeholders with actual values.
217+
*
218+
* @return string
219+
*/
220+
protected function replaceStubPlaceholders(string $stub, string $className, string $method)
221+
{
222+
return str_replace(
223+
['{{ class }}', '{{ namespace }}', '{{ method }}'],
224+
[$className, 'App\\MCP\\Notifications', $method],
225+
$stub
226+
);
227+
}
228+
}

src/Data/ProcessMessageData.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@ final class ProcessMessageData
1313

1414
public array|JsonRpcResultResource|JsonRpcErrorResource $resource;
1515

16-
public function __construct(ProcessMessageType $messageType, array|JsonRpcResultResource|JsonRpcErrorResource $resource)
16+
public bool $isNotification;
17+
18+
public function __construct(ProcessMessageType $messageType, array|JsonRpcResultResource|JsonRpcErrorResource $resource, bool $isNotification = false)
1719
{
1820
$this->messageType = $messageType;
1921
$this->resource = $resource;
22+
$this->isNotification = $isNotification;
2023
}
2124

2225
public function toArray(): array

src/Http/Controllers/StreamableHttpController.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ public function postHandle(Request $request)
3939
$messageJson = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR);
4040
$processMessageData = $server->requestMessage(clientId: $mcpSessionId, message: $messageJson);
4141

42+
// MCP specification: notifications should return HTTP 202 with no body
43+
if ($processMessageData->isNotification) {
44+
return response('', 202);
45+
}
46+
4247
if (in_array($processMessageData->messageType, [ProcessMessageType::HTTP])
4348
&& ($processMessageData->resource instanceof JsonRpcResultResource || $processMessageData->resource instanceof JsonRpcErrorResource)) {
4449
return response()->json($processMessageData->resource->toResponse());

src/LaravelMcpServerServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Illuminate\Support\Facades\Config;
66
use Illuminate\Support\Facades\Route;
7+
use OPGG\LaravelMcpServer\Console\Commands\MakeMcpNotificationCommand;
78
use OPGG\LaravelMcpServer\Console\Commands\MakeMcpPromptCommand;
89
use OPGG\LaravelMcpServer\Console\Commands\MakeMcpResourceCommand;
910
use OPGG\LaravelMcpServer\Console\Commands\MakeMcpResourceTemplateCommand;
@@ -36,6 +37,7 @@ public function configurePackage(Package $package): void
3637
MakeMcpResourceCommand::class,
3738
MakeMcpResourceTemplateCommand::class,
3839
MakeMcpPromptCommand::class,
40+
MakeMcpNotificationCommand::class,
3941
TestMcpToolCommand::class,
4042
MigrateToolsCommand::class,
4143
]);

0 commit comments

Comments
 (0)