dvaknheo / duckmcp
An MCP (Model Context Protocol) server extention for DuckPHP framework
Requires
- php: >=7.4.0
- dvaknheo/duckphp: ^1.4.1
- phpstan/phpdoc-parser: ^2.3
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-27 13:19:30 UTC
README
English · 中文
An MCP (Model Context Protocol) server extension for
DuckPHP: add one line to your application and it serves tools over stdio and Streamable
HTTP. A tool is a public method of a *Mcp class — its name, description and JSON Schema
come from the method itself, so there is nothing to register and no schema to write.
What you get
- two transports from the same extension:
stdio(what a client spawns) and Streamable HTTP (POST /mcp, JSON or SSE answers); - tools by convention: every public method of every
*Mcpclass under yourControllernamespace, recursively — helper methods stay helpers; - schema derived from the signature:
@param/@returndocblocks becomeinputSchemaandoutputSchema, a literal union becomes anenum, and the server enforces what it publishes (-32602on a violation); - three console commands registered for you:
stdio,tools,info; - protocol details handled:
2025-06-18(plus2025-03-26/2024-11-05fallback), cursor pagination, resources, logging, JSON-RPC batching,-32700/-32600/-32601/-32602/-32603.
Requirements
| PHP | >= 7.4 |
| DuckPHP | ^1.4.1 (installed as a dependency) |
| Composer | any recent version |
1. Install
composer require dvaknheo/duckmcp
2. Wire it in — one line
Add the extension to your application options (App::$options):
'ext' => [DuckMcpExt::class => true],
true means "follow the app": the extension is initialised with all of the application
options, and it reads every mcp_* key from them (see Options).
That one line gives your application:
| a route hook | serves mcp_http_route (/mcp by default) when mcp_http_enable is on |
| three console commands | php bin/<your-cli> stdio / tools / info — nothing to write |
| the protocol core | method table, envelopes, sessions, resources, logging |
Nothing else is needed: no route definition, no action class, no tool list.
3. Add a tool — one method
namespace YourApp\Controller; class ReportMcp { /** * Count the notes matching a keyword. * * @param string $keyword the keyword to match * @param int $limit how many notes to count * @return array{keyword:string,count:int} */ public function noteCount(string $keyword = '', int $limit = 20): array { return ['keyword' => $keyword, 'count' => $limit]; } }
That method is the tool Report/noteCount: class name minus the Mcp postfix + / +
the method name (case sensitive). Its description is the first docblock line, its
inputSchema is the two parameters, its outputSchema is the @return shape.
The rules in one place
| You write | The client receives | Override |
|---|---|---|
noteCount() in class ReportMcp |
tool name Report/noteCount (class minus Mcp + / + method) |
not overridable |
| first docblock line | description |
@mcpDescription |
— (method name humanized: noteCount → Note Count) |
title |
@mcpTitle |
@param int $limit how many notes to count |
{"type":"integer","description":"how many notes to count"} |
not overridable |
| a parameter with no default | goes into required |
not overridable |
@return array{keyword:string,count:int} |
outputSchema (nested array<int,array{...}> supported) |
not overridable |
@param 'a'|'b' $mode |
{"type":"string","enum":["a","b"]}, enforced (-32602 outside it) |
not overridable |
| add/delete/save/place… in the method name | readOnlyHint:false, destructiveHint:true |
@mcpReadOnlyHint / @mcpDestructiveHint |
| a read-only tool | idempotentHint follows readOnly |
@mcpIdempotentHint |
— (default false: local tools) |
openWorldHint:false |
@mcpOpenWorldHint |
- types and names get no escape hatch; every other field has exactly one
@mcp*tag; - the four hint tags take booleans (
true/false/1/0/yes/no; a tag without a value meanstrue); a self-contradictory pair (read-only and destructive) is an error, not a silent override; - docblocks are parsed with
phpstan/phpdoc-parser, soarray{a: int},array<int, string>,int|string,?intand'a'|'b'all mean what they say; - do not hand-write JSON Schema: the schema is a projection of the signature, and hand-writing it makes the two drift apart.
How tools are discovered
No list, no registration — src/Ext/McpToolLister.php walks your code:
- namespace = your
App::namespace+mcp_tool_namespace(defaultController); an option starting with\is taken as an absolute namespace; - the directory is derived from your application class (the way
RouteLister::getAllControllerClasses()does it), then walked recursively — a subdirectory is a sub-namespace, soController/Admin/AdminMcp.phpbecomesAdmin/Admin/<method>; an absolute namespace outside the application class is located through the probe classes inclasses_to_get_tool_path; - files ending with
mcp_tool_class_postfix(defaultMcp) are tool classes, the file name is the class short name (PSR-4); - the result is sorted by class name, so
tools/listorder is stable; every public method of the class is a tool.
Consequences worth knowing: helper classes should not end with Mcp; a file that follows the
convention but does not declare the matching class is an error (not skipped); two classes
claiming the same tool name is an error (not a silent override).
4. Run it
stdio (what an MCP client spawns)
php bin/<your-cli>.php stdio # serve until STDIN closes php bin/<your-cli>.php stdio --verbose # log the handshake to STDERR
{
"mcpServers": {
"your-app": {
"command": "php",
"args": ["/path/to/your-app/bin/<your-cli>.php", "stdio"]
}
}
}
STDOUT carries protocol frames only. Add
--verbosewhile debugging: the handshake and everytools/callthen go to STDERR, where they cannot corrupt a frame.
Streamable HTTP
php -S 127.0.0.1:8099 -t public curl -s -X POST http://127.0.0.1:8099/mcp \ -H 'Content-Type: application/json' -H 'Accept: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}'
Inspect without a client
php bin/<your-cli>.php tools # the same payload tools/list returns php bin/<your-cli>.php tools --full # ... with inputSchema / outputSchema php bin/<your-cli>.php info # the serverInfo initialize returns
Try it by hand
npx @modelcontextprotocol/inspector php /path/to/your-app/bin/<your-cli>.php stdio
Options
All of them are application options — nothing is read from the environment, and no code
changes are needed to switch behaviour. The defaults below are the ones this repository ships
in src/System/App.php.
Identity
| Option | Default | Meaning |
|---|---|---|
mcp_server_name |
DuckMcpExt::NAME |
the identity the client sees |
mcp_server_title |
DuckMcpExt::TITLE |
display name |
mcp_server_version |
DuckMcpExt::VERSION |
version reported in serverInfo |
mcp_instructions |
'' |
text handed to the model; empty = a built-in demo text |
Discovery
| Option | Default | Meaning |
|---|---|---|
mcp_tool_namespace |
Controller |
namespace (relative to your app namespace) that holds the tool classes; \-prefixed = absolute |
mcp_tool_class_postfix |
Mcp |
class/file name postfix that marks a tool class |
classes_to_get_tool_path |
[] |
probe classes used to locate an absolute tool namespace on disk |
Transports
| Option | Default | Meaning |
|---|---|---|
mcp_stdio_max_frame |
4194304 |
longest accepted stdio frame (0 = no limit); beyond it -32700 |
mcp_http_enable |
true |
false = stdio only |
mcp_http_route |
/mcp |
the HTTP endpoint |
mcp_http_require_session |
true |
after initialize the Mcp-Session-Id header is required; false = trust the client like stdio |
mcp_http_cors |
'*' |
'*', an allow-list of origins, or false = send no CORS headers |
mcp_http_max_body |
1048576 |
413 beyond this (0 = no limit) |
mcp_http_require_json_content_type |
false |
true = a non-application/json POST gets 415 (a missing content type is still allowed) |
mcp_http_secret |
'' |
non-empty = the session id is signed; a forged or expired id gets 404 |
mcp_http_session_ttl |
0 |
seconds an id stays valid (0 = no expiry; signing mode only) |
mcp_http_session |
false |
true = keep the per-session state (version, log level, clientInfo) in PHP sessions |
Logging
| Option | Default | Meaning |
|---|---|---|
mcp_log_level |
warning |
threshold; a client can raise it with logging/setLevel |
mcp_log_path |
runtime |
where the MCP log lives (separate from the framework's log_*.log) |
mcp_log_file_template |
mcp_%Y-%m-%d.log |
file name template |
mcp_log_prefix |
DuckMcp |
line prefix |
Replacing the log implementation needs no option — use the framework's singleton hook:
class MyMcpLogger extends \DuckMcp\Ext\McpLogger { /* override log() or the options */ } \DuckMcp\Ext\McpLogger::_(new MyMcpLogger());
HTTP behaviour
| Request | Behaviour |
|---|---|
POST /mcp + Accept: application/json |
200, a JSON-RPC answer; initialize mints a Mcp-Session-Id |
POST /mcp with a notification (no id) |
202, empty body |
POST /mcp + Accept: text/event-stream |
200, the answer wrapped in one SSE message event |
GET /mcp |
405 (no server push) |
OPTIONS /mcp |
204 + CORS preflight headers |
DELETE /mcp + session |
200, ends the session; 404 when the id is unknown |
tools/list without a session |
400 (call initialize first) |
MCP-Protocol-Version names an unsupported version |
400 |
Accept without application/json / text/event-stream (including q=0) |
406 |
body larger than mcp_http_max_body |
413; wrong content type with the switch on → 415 |
any URL other than mcp_http_route |
normal DuckPHP routing |
Before you go public
The defaults are "works out of the box" (so Inspector and a browser can connect directly). Before exposing the endpoint to the internet, walk this list — all of it is configuration:
| Switch | Default | Production advice | Why |
|---|---|---|---|
mcp_http_secret |
'' |
a long random string (32+ chars) | while empty, any non-empty session id is accepted, so unknown or expired sessions cannot be refused; with it set the id carries an HMAC |
mcp_http_session_ttl |
0 |
the session lifetime (e.g. 3600) |
0 means a minted id never expires |
mcp_http_require_session |
true |
keep true |
false means "no session is treated as initialized" |
mcp_http_cors |
'*' |
an allow-list, or false |
'*' lets any site call it cross-origin |
mcp_http_max_body |
1 MiB | size it to the business | beyond it a 413 |
mcp_http_require_json_content_type |
false |
true |
otherwise form-encoded bodies are accepted too |
is_debug |
true |
false |
debug mode renders notices/exceptions as a debug page |
Three things that are not switches:
runtime/must be writable if you use the MCP log and a SQLite database;mcp_http_sessionneeds a writablesession.save_path— its location is PHP's business (php.ini/ FPM pool), the code does not touch it; when it is not usable the transport degrades to stateless and logs a warning instead of failing the request;- scaling out is free by default (stateless); only with
mcp_http_sessionon do you need shared session storage or sticky routing.
Errors: protocol error vs tool failure
| Situation | Answer |
|---|---|
| unknown method, bad envelope, unknown tool, an argument not matching the schema | JSON-RPC error (-32601 / -32600 / -32602) — the client made a mistake it can fix |
| the tool itself throws | result with isError: true — the tool failed, the server is fine |
| the transport itself breaks (frame read/write, broken session store) | logged; HTTP answers a -32603 frame, stdio logs and carries on with the next frame |
Keeping those apart matters: a client that sees -32602 can retry with fixed arguments,
while isError means "the operation failed" and should be shown to the user.
Protocol coverage
| Capability | Status |
|---|---|
| transports | stdio (newline delimited), Streamable HTTP (JSON / SSE, 202 notifications, DELETE) |
| protocol versions | 2025-06-18 (default), 2025-03-26, 2024-11-05; an older client version falls back, as the spec says |
initialize / notifications/initialized / ping |
yes, including capabilities, serverInfo, instructions |
tools/list |
yes: cursor pagination (50 per page), inputSchema, outputSchema, annotations |
tools/call |
yes: schema validation (-32602), content + structuredContent, tool exceptions as isError |
resources/list / resources/read |
yes: built-in duckmcp://manifest (server + tool list) |
prompts/list |
empty list (reserved; completions is not advertised) |
logging/setLevel + logging |
yes: really filters by level, writes runtime/mcp_*.log, STDERR only during stdio |
JSON-RPC batching, -32700/-32600/-32601/-32602/-32603 |
yes, including envelope validation |
| empty results | encoded as {}, not [] (e.g. ping) |
notifications/cancelled, progress, sampling, roots, elicitation |
not implemented |
Troubleshooting
| Symptom | Cause |
|---|---|
| the client cannot parse a frame | something wrote to STDOUT. Logs go to runtime/mcp_*.log and (inside the stdio loop) STDERR; use --verbose instead of echo/var_dump |
| the stdio server dies mid-conversation | an exception escaped the transport. That path is covered (per-frame try/catch), so check the MCP log — it records what escaped and when |
tools/list is empty |
the discovery namespace is wrong: it is your app namespace + mcp_tool_namespace, and the file name must end with mcp_tool_class_postfix |
| a tool cannot be called | the wire name must be <Class minus postfix>/<method>, spelled exactly as declared (method lookup is case insensitive in PHP, the wire name is not) |
tools/list throws "Duplicate tool name" |
two classes produce the same name — usually a discovery/scan problem, never silently ignored |
The demo in this repository
This repository is also a runnable demo application (the library is src/Ext/; everything
else — src/System, src/Controller, src/Business, src/Model, bin/, public/ — is
the demo):
composer install php bin/mcp.php tools --full # the 13 demo tools with their schemas php bin/mcp.php stdio # serve over stdio php -S 127.0.0.1:8099 -t public # serve over HTTP at /mcp php tests/smoke.php # 131 assertions: protocol, tools, both transports
The demo tools live in src/Controller/*Mcp.php and are ordinary application code — copy the
shape, then delete what you do not need.
Using an AI agent to wire this into an existing application? AGENTS.md is the
task guide for that (what to edit, what not to touch, how to verify).
License
MIT — see LICENSE.