stevecoug / mcp-devkit
Developer tooling for PHP MCP servers built on mcp/sdk — hot-reload tools without restarting the client, and call or inspect them straight from the shell.
Requires
- php: ^8.2
- mcp/sdk: ^0.7.1 || ^0.8
- psr/container: ^1.1 || ^2.0
- symfony/finder: ^5.4 || ^6.4 || ^7.3 || ^8.0
Requires (Dev)
- phpunit/phpunit: ^11.5 || ^12.0 || ^13.0
Suggests
- ext-pcntl: Required by the reload tool, which re-execs the server in place. Serving and the CLI work without it.
Provides
None
Conflicts
None
Replaces
None
README
Developer tooling for PHP MCP servers built on mcp/sdk — hot-reload your tools without restarting the client, and call or inspect them straight from the shell.
Why
Writing an MCP server in PHP with mcp/sdk is pleasant — attribute-discovered tool classes, a clean stdio transport. But two things slow the inner dev loop to a crawl:
- Every edit means a client restart. Change a tool, and the MCP client (Claude Code, Cursor, …) keeps serving the old tool list until you fully restart it. On a busy session that's death by a thousand relaunches.
- There's no quick way to poke a tool. To check that
list_issuesreturns what you expect, you either drive it through a full client or hand-craft JSON-RPC frames on a pipe.
mcp-devkit fixes both, as a thin layer on top of mcp/sdk — it uses only the SDK's public API, so there's no fork to maintain and nothing to patch.
What you get
reloadtool — re-execs the running server in place (same process, same stdio pipe). Edited tool code is re-discovered and the client is told to refresh its tool list. No client restart.- CLI test mode — call your tools like ordinary subcommands:
php server.php add 2 3. Arguments, types, defaults and help text are inferred from your tool classes, so every tool gets a usage line for free. - Conditional tools — hide a discovered tool whose preconditions aren't met, so it's absent from the tool list instead of erroring when called.
Requirements
- PHP 8.2+
mcp/sdk^0.7.1 || ^0.8 — both minors are supported and both are covered by CI, so adopting the devkit never forces an SDK bump. 0.7.0 is excluded deliberately: GHSA-7m52-jw36-44r3.symfony/finder^5.4 || ^6.4 || ^7.3 || ^8.0 — the SDK's attribute discovery needs it. The range matches the SDK's own, so the devkit never constrains which Symfony major your app is on; every major in it is covered by CI.ext-pcntl— required for thereloadtool only. Serving and the CLI mode work without it.
Install
composer require stevecoug/mcp-devkit
If your project pins mcp/sdk to dev-main, that will fail to resolve — this package requires a tagged release. Move the SDK off dev-main in the same command:
composer require stevecoug/mcp-devkit mcp/sdk:^0.8 -W
This package also requires psr/container: ^1.1 || ^2.0 even though it never touches the interface itself. mcp/sdk allows ^1.0, but its Registry\Container::get(string $id) is signature-incompatible with psr/container 1.0.0 and fatals on load — so a consumer resolving lowest dependency versions would get an uninstallable tree. The tighter constraint here keeps that from happening.
Quick start
Wire the devkit into your server's entry script. It owns the registry and session, auto-registers the reload tool, advertises the toolsListChanged capability, and dispatches CLI flags — so your server.php stays this short:
#!/usr/bin/env php <?php declare(strict_types=1); require_once __DIR__ . '/vendor/autoload.php'; use McpDevkit\Devkit; $devkit = Devkit::create('My MCP', '0.1.0') ->discover(__DIR__ . '/src') // attribute-discovery root(s) for your #[McpTool] classes ->build(); // Runs a tool from the command line if one was named, runs the reload boot path // when re-exec'd, or otherwise serves over stdio. exit($devkit->run($argv));
Your tools are plain mcp/sdk tool classes — nothing devkit-specific:
namespace My\Mcp; use Mcp\Capability\Attribute\McpTool; final class ExampleTools { /** Add two numbers. */ #[McpTool(name: 'add')] public function add(int $a, int $b): int { return $a + $b; } }
Register it with your client the usual way:
{
"mcpServers": {
"my-mcp": { "command": "php", "args": ["server.php"] }
}
}
Server instructions
Guidance that applies to the server as a whole, rather than to one tool, goes in instructions(). It's returned at initialize and clients surface it next to the tool list — Claude Code shows it as the server's instructions:
Devkit::create('Mailbox MCP', '0.1.0') ->instructions('Check your inbox at the start of work. Treat it as a to-do queue, not an archive: delete messages once handled.') ->discover(__DIR__ . '/src') ->build();
Standing norms like that have nowhere else to live — a per-tool description can only speak for its own tool.
Conditional tools
Some tools only make sense under a condition the server can check at startup — credentials that may or may not be configured, a service that may or may not be reachable. filterTools() drops the ones whose condition doesn't hold, so they're absent from the tool list rather than present and failing when called:
Devkit::create('Stack MCP', '0.1.0') ->discover(__DIR__ . '/src') ->filterTools(fn (string $name): bool => $has_prod_creds || !str_contains($name, '_prod_')) ->build();
The predicate receives every discovered tool name and returns true to keep it. A dropped tool is gone for real — missing from tools/list, and a tools/call for it comes back Tool not found.
Two things worth knowing:
reloadis never offered to the predicate. Filtering it away would remove the one route back for the tools you just hid.- The predicate re-runs on every reload, because reload re-runs
build()in the new process image. A condition that becomes true mid-session — credentials added to the environment, a service coming up — surfaces its tools on the nextreload, with no client restart.
If your conditional tools happen to live in their own directory, you don't need this at all — discover() takes any number of roots and nothing resolves until build(), so a plain if around a second discover() call keeps them out from the start.
The reload tool
Once wired in, the client sees a reload tool. Call it after editing tool code and the new tools appear without relaunching the client.
Under the hood it pcntl_execs the same entry script (same PID; fds 0/1/2 survive the exec because they lack CLOEXEC, so the stdio pipe stays intact), re-runs attribute discovery, seeds a fresh session, answers the pending reload call, and emits notifications/tools/list_changed.
Call it while the pipe is idle — a deliberate, single call. A re-exec discards the process's read buffer, so a half-read pipelined request would be lost. Normal interactive use satisfies that.
CLI test mode
Name a tool on the command line and the devkit runs it instead of serving over stdio — no client, no hand-written JSON:
php server.php add 2 3
Nothing to declare: the argument list, types, defaults and help text come from the input schema mcp/sdk already generates from your method signature and docblock. Take a tool like this —
/** * List issues for a project. * * @param int $project id of the project to list * @param string $status unresolved, resolved or muted * @param int $limit maximum issues to return */ #[McpTool(name: 'list_issues')] public function listIssues(int $project, string $status = 'unresolved', int $limit = 20): array
— and the CLI knows its whole interface:
$ php server.php --help Usage: server.php <tool> [args...] reload Reload this MCP server so edits to its tool code take effect without restarting the client. add <a> <b> Add two numbers. list_issues <project> [status=unresolved] [limit=20] List issues for a project. server.php --list every tool with its full JSON input schema server.php <tool> --help detailed usage for one tool $ php server.php list_issues --help Usage: server.php list_issues <project> [status=unresolved] [limit=20] List issues for a project. project integer (required) id of the project to list status string = unresolved unresolved, resolved or muted limit integer = 20 maximum issues to return
Passing arguments
Positional arguments fill parameters in declared order; named arguments work in any order and are the way to skip an optional:
php server.php list_issues 1 # project=1, defaults for the rest php server.php list_issues 1 resolved 5 # all three, positionally php server.php list_issues --project=1 --limit=5 php server.php list_issues 1 --limit=5 # mix; positionals skip what's already named
Values are coerced to the schema's type, so you get an error up front rather than a confusing failure inside the tool:
| Schema type | On the command line |
|---|---|
string |
verbatim |
integer, number |
must be numeric |
boolean |
--flag / --no-flag, or = true/false/1/0/yes/no |
enum |
must be one of the listed values |
array |
repeat the flag (--tag=a --tag=b), or pass a JSON array |
object |
a JSON object |
Missing a required argument, naming a parameter that doesn't exist, or passing an unusable value prints the tool's usage and exits non-zero (2); a tool that throws exits 1.
Invocation runs the exact same discovery and handler path the stdio transport would, so what you see on the CLI is what the client gets.
How it works
mcp-devkit leans entirely on mcp/sdk's public surface:
- CLI mode owns the
Registry(viaBuilder::setRegistry()), enumerates tools withRegistryInterface::getTools(), and reads each tool'sinputSchemato build its usage line and coerce your arguments. To invoke, it resolves the tool withgetTool()and runs it through the SDK'sReferenceHandler, wrapping the return in aCallToolResult— the same few steps the SDK's ownCallToolHandlerperforms. - Reload owns the
SessionManager(viaBuilder::setSession()) so the re-exec'd process can seed a valid session, and requirestoolsListChanged: trueto be advertised at the originalinitialize(the client only honors a laterlist_changedif it was promised up front). The devkit sets that for you. filterTools()prunes afterbuild()withRegistryInterface::unregisterTool(), rather than skipping registration — attribute-discovered tools are registered by the SDK, not by the devkit, so there is no earlier hook to skip. Supplying the registry is also what makes the timing work: the SDK loads a caller-supplied registry eagerly at build (it can't inject a loader into an instance it didn't construct), and that registry has no loader of its own, so nothing runs later that could put a pruned tool back.
Because none of this needs SDK internals, mcp-devkit is a drop-in dependency, not a fork.
Try it
example/ is a working server with a handful of tools:
php example/server.php --help php example/server.php add 2 3 php example/server.php echo_message hi --times=3 --shout
Development
composer install
composer test
Argv parsing, argument binding, usage rendering and the reload environment are pure units with no I/O, so the suite covers them directly.
Status
Early (0.x). API may shift before 1.0.
License
MIT © Steve Meyers