Search by

stevecoug / mcp-devkit

stevecoug

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.

Package info

github.com/stevecoug/mcp-devkit

pkg:composer/stevecoug/mcp-devkit

Statistics

Installs: 26

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.4 2026-08-29 20:59 UTC

This package is auto-updated.

Last update: 2026-08-29 21:00:27 UTC


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.

License: MIT PHP 8.2+

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:

  1. 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.
  2. There's no quick way to poke a tool. To check that list_issues returns 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

  • reload tool — 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 the reload tool 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:

  • reload is 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 next reload, 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 (via Builder::setRegistry()), enumerates tools with RegistryInterface::getTools(), and reads each tool's inputSchema to build its usage line and coerce your arguments. To invoke, it resolves the tool with getTool() and runs it through the SDK's ReferenceHandler, wrapping the return in a CallToolResult — the same few steps the SDK's own CallToolHandler performs.
  • Reload owns the SessionManager (via Builder::setSession()) so the re-exec'd process can seed a valid session, and requires toolsListChanged: true to be advertised at the original initialize (the client only honors a later list_changed if it was promised up front). The devkit sets that for you.
  • filterTools() prunes after build() with RegistryInterface::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