Search by

dvaknheo / duckmcp

dvaknheo

An MCP (Model Context Protocol) server extention for DuckPHP framework

Package info

github.com/dvaknheo/duckmcp

pkg:composer/dvaknheo/duckmcp

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-master 2026-10-27 13:15 UTC

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 *Mcp class under your Controller namespace, recursively — helper methods stay helpers;
  • schema derived from the signature: @param / @return docblocks become inputSchema and outputSchema, a literal union becomes an enum, and the server enforces what it publishes (-32602 on a violation);
  • three console commands registered for you: stdio, tools, info;
  • protocol details handled: 2025-06-18 (plus 2025-03-26 / 2024-11-05 fallback), 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 means true); a self-contradictory pair (read-only and destructive) is an error, not a silent override;
  • docblocks are parsed with phpstan/phpdoc-parser, so array{a: int}, array<int, string>, int|string, ?int and '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:

  1. namespace = your App::namespace + mcp_tool_namespace (default Controller); an option starting with \ is taken as an absolute namespace;
  2. the directory is derived from your application class (the way RouteLister::getAllControllerClasses() does it), then walked recursively — a subdirectory is a sub-namespace, so Controller/Admin/AdminMcp.php becomes Admin/Admin/<method>; an absolute namespace outside the application class is located through the probe classes in classes_to_get_tool_path;
  3. files ending with mcp_tool_class_postfix (default Mcp) are tool classes, the file name is the class short name (PSR-4);
  4. the result is sorted by class name, so tools/list order 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 --verbose while debugging: the handshake and every tools/call then 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_session needs a writable session.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_session on 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.