PHPackages                             milpa/mcp-server - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [API Development](/categories/api)
4. /
5. milpa/mcp-server

ActiveLibrary[API Development](/categories/api)

milpa/mcp-server
================

Model Context Protocol (MCP) transport core for the Milpa PHP framework: a transport-agnostic JSON-RPC 2.0 dispatcher over the tool-runtime registry, plus the auth seam a host implements to turn a bearer token into an authenticated caller.

v0.3.0(2d ago)075↓37.5%1Apache-2.0PHP &gt;=8.3

Since Jul 7Compare

[ Source](https://github.com/getmilpa/mcp-server)[ Packagist](https://packagist.org/packages/milpa/mcp-server)[ RSS](/packages/milpa-mcp-server/feed)WikiDiscussions Synced today

READMEChangelog (5)Dependencies (7)Versions (6)Used By (1)

 [   ![Milpa](https://raw.githubusercontent.com/getmilpa/core/main/art/lockup/milpa-lockup-v-color-light.svg)  ](https://github.com/getmilpa)

Milpa McpServer
===============

[](#milpa-mcpserver)

> The **Model Context Protocol (MCP) transport core** for the Milpa PHP framework, built on **`milpa/core`** and **`milpa/tool-runtime`**. A transport-agnostic JSON-RPC 2.0 dispatcher over the tool-runtime registry (`initialize`, `notifications/initialized`, `tools/list`, `tools/call`), plus the auth seam a host implements to turn a bearer token into an authenticated caller. **No HTTP kernel, no SSE, no concrete token store** — those live in your host application.

[![CI](https://github.com/getmilpa/mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/getmilpa/mcp-server/actions/workflows/ci.yml)[![Packagist](https://camo.githubusercontent.com/b3af8073500500caca57f7bfb030b180c670cdcb83ddd1daae73d0a8ff3fbb56/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6d696c70612f6d63702d7365727665722e737667)](https://packagist.org/packages/milpa/mcp-server)[![PHP](https://camo.githubusercontent.com/ca03f11ea27dac4dedc8ad56a7bdfc4a9ff5feb825055f9d2983616115076607/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d254532253839254135253230382e332d3737376262342e737667)](https://www.php.net/)[![License](https://camo.githubusercontent.com/798509b4df525f56802b56f8096862487f08023e3d7561c68656f8dab10d0d6e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4170616368652d2d322e302d626c75652e737667)](LICENSE)[![Docs](https://camo.githubusercontent.com/c6dc6a3411e15b0ac7cc4583e8e6a8144181caedb82f5d98753353decda06d77/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f646f63732d4150492532307265666572656e63652d626c75652e737667)](https://getmilpa.github.io/mcp-server/)

Install
-------

[](#install)

```
composer require milpa/mcp-server
```

What it is
----------

[](#what-it-is)

Milpa splits its surface into small, dependency-light packages. `milpa/tool-runtime` is the concrete engine that runs `#[Tool]`-attributed methods through one pipeline (resolve, validate, authorize, execute, audit); `milpa/mcp-server` is one **transport** for that engine — the [Model Context Protocol](https://modelcontextprotocol.io/), the JSON-RPC-based protocol LLM clients speak to discover and call tools over MCP.

This package is deliberately narrow. It ships:

- **`JsonRpcService`** — decodes a JSON-RPC 2.0 request array, dispatches `initialize`, `notifications/initialized`, `tools/list`, and `tools/call` against a `Milpa\ToolRuntime\ToolRegistry`, and returns a JSON-RPC response array. It never touches HTTP, SSE, stdio, or a socket — it operates on plain arrays in, plain arrays out.
- **`Auth\TokenValidatorInterface` + `Auth\Principal`** — the seam a host implements to authenticate an MCP caller. The transport layer needs exactly one thing from auth: turn a bearer token into a `Principal` (or `null`) it can build a `ToolContext` from. It does not need — and this package does not ship — a concrete token store, a Doctrine entity, or an identity provider.

What it does **not** ship, on purpose: the HTTP endpoint that terminates MCP's Streamable HTTP transport, the SSE stream, or an OAuth 2.0 authorization server for token issuance/exchange (registration, PKCE, the `/oauth/*` endpoints MCP clients probe per RFC 8414/9728). Those are real, substantial pieces of a production MCP server, but they are **host wiring** — they depend on your HTTP framework's request/response types, your session/storage layer, and your product's own auth UI — not on the MCP protocol logic itself. Build them in your host application as an adapter around `JsonRpcService` and `TokenValidatorInterface`.

Quick example
-------------

[](#quick-example)

```
use Milpa\McpServer\JsonRpcService;
use Milpa\ToolRuntime\Contracts\ToolContext;
use Milpa\ToolRuntime\ToolRegistry;
use Psr\Log\NullLogger;

$registry = new ToolRegistry(new NullLogger());
// ... register tools on $registry, e.g. via ToolScanner ...

$rpc = new JsonRpcService($registry);

$ctx = new ToolContext(principal: 'user:42', channel: 'mcp', scopes: ['notes:read']);

$response = $rpc->handle([
    'jsonrpc' => '2.0',
    'method' => 'tools/call',
    'params' => ['name' => 'list_notes', 'arguments' => ['page' => 1]],
    'id' => 1,
], $ctx);

// $response is a JSON-RPC 2.0 response array, or null. json_encode() an array response onto
// whatever transport your host terminates (an HTTP POST body, a stdio pipe, ...); a null
// response means the request was a notification and your transport MUST write nothing at all
// — see "Recipes" below.
```

Events (0.3)
------------

[](#events-03)

`JsonRpcService` takes an optional second constructor argument: an `?MilpaEventDispatcherInterface` (from `milpa/core`), nullable and defaulting to `null` (the same pattern `milpa/tool-runtime`'s `HumanVerifier` uses). Leave it unset and `handle()` behaves exactly as it did in 0.2 — nothing below is a breaking change for existing callers.

Wired up, `handle()` brackets every *resolved* method dispatch — i.e. once `$method` has passed the batch/`jsonrpc`/missing-`method` guards, which are protocol-level failures no plugin gets a say in — with two events:

- **`mcp.request`** (PRE, stoppable) fires right before the method would run, with a payload shaped `['event' => McpRequestEvent, 'slot' => InterceptionSlot]`. A listener reads the method/params/caller context off the event, and can act on the slot:
    - `$slot->stop()` — pure veto. The method never runs; the caller gets a well-formed `-32001 Method vetoed by an mcp.request listener: ` JSON-RPC error.
    - `$slot->shortCircuit($response)` — serve a canned or cached response in the method's place. The method never runs; `$response` becomes the JSON-RPC `result` as-is.
    - Neither call — the method runs normally, exactly as in 0.2.
- **`mcp.responded`** (POST, readonly) fires afterward with `['event' => McpRespondedEvent]`once a response envelope exists, no matter how it was produced (the method ran, a listener short-circuited it, or a listener vetoed it). It fires even for notifications (a request with no `id` member) — `handle()`'s own return value is still `null` on the wire per the 0.2 contract, but the event carries the response that *would* have been sent, so observability never goes blind just because the wire response was suppressed.

```
use Milpa\Events\InterceptionSlot;
use Milpa\McpServer\Events\McpRequestEvent;
use Milpa\McpServer\Events\McpRespondedEvent;
use Milpa\McpServer\JsonRpcService;

// A cache plugin: short-circuit tools/call for a known-idempotent tool + args pair.
$dispatcher->subscribe('mcp.request', function (string $eventName, array $payload): void {
    $event = $payload['event']; // McpRequestEvent
    /** @var InterceptionSlot $slot */
    $slot = $payload['slot'];

    if ($event->getMethod() === 'tools/call') {
        $cached = $cache->get($event->getParams());
        if ($cached !== null) {
            $slot->shortCircuit($cached); // the tool never actually runs
        }
    }
});

// An audit plugin: log the outcome of every dispatched method, cache hit or not.
$dispatcher->subscribe('mcp.responded', function (string $eventName, array $payload): void {
    /** @var McpRespondedEvent $event */
    $event = $payload['event'];
    $logger->info('mcp method responded', [
        'method' => $event->getMethod(),
        'ok' => !isset($event->getResponse()['error']),
    ]);
});

$rpc = new JsonRpcService($registry, $dispatcher);
```

The auth seam
-------------

[](#the-auth-seam)

`JsonRpcService` itself takes an already-resolved `?ToolContext` — it has no opinion on how you got there. The seam this package *does* define is one layer up: how a host turns a raw bearer token, arriving over whatever transport it terminates, into the `Principal` that becomes that `ToolContext`.

```
use Milpa\McpServer\Auth\Principal;
use Milpa\McpServer\Auth\TokenValidatorInterface;

final class DatabaseTokenValidator implements TokenValidatorInterface
{
    public function validate(string $token, ?string $ip = null): ?Principal
    {
        $record = $this->tokens->findValidByToken($token); // your own store, your own rules
        if ($record === null) {
            return null;
        }

        $record->recordUsage($ip); // optional side effect, entirely up to the implementation

        return new Principal(
            id: "user:{$record->ownerId()}",
            displayName: $record->ownerName(),
            scopes: $record->scopes(),
            extra: ['token_prefix' => $record->prefix()],
        );
    }
}
```

Your transport adapter (an HTTP controller, typically) then does three things per request: extract the bearer token, call `$validator->validate($token, $ip)`, and — on a non-null `Principal` — build the `ToolContext` via `$principal->toToolContext($ip, $userAgent)` before handing the request to `JsonRpcService::handle()`. A `null` result means "reject with 401"; nothing about *why* it was null (expired vs. revoked vs. unknown) crosses this seam — that distinction, if you want it, lives entirely inside your `TokenValidatorInterface`implementation.

### Why one interface, not two

[](#why-one-interface-not-two)

The obvious alternative is splitting "is this token valid" from "who does it belong to" into two seams (a validator and a separate principal resolver). This package does not, because nothing in its own reference host ever calls them independently — every real call site authenticates a request by immediately needing both the yes/no *and* the principal in the same breath. A second seam here would be indirection with no consumer, not abstraction.

Recipes
-------

[](#recipes)

Three things the first real consumer of this package's stdio transport had to work out by reading source instead of a doc. Fixed here for the next one.

### Running without auth (a trusted local process)

[](#running-without-auth-a-trusted-local-process)

`JsonRpcService` itself is auth-agnostic — it just takes whatever `?ToolContext` you hand it. But `milpa/tool-runtime`'s `PolicyGate` ships a built-in `mcp` channel policy that is *not*agnostic: `channelPolicies['mcp']` is `['allow_all' => false, 'require_auth' => true]`, so **any `ToolContext` with an empty `principal` gets every `tools/call` silently `FORBIDDEN`** — including non-mutating reads. Nothing about this surfaces from `JsonRpcService`'s side; the only way to find it is to read `PolicyGate`'s source or puzzle over a confusing `success: false` on every single tool call.

If your transport genuinely has no per-request auth — a local stdio server spawned by an agent you already trust to run the whole process — the fix is a fixed principal and wildcard scopes, the same "no real auth, but the channel police accepts a hard-coded identity" pattern `ToolContext::cli()` already uses for the CLI channel:

```
use Milpa\ToolRuntime\Contracts\ToolContext;

$ctx = ToolContext::mcp(
    requestId: (string) ($request['id'] ?? uniqid('mcp-', true)),
    principal: 'stdio',   // any non-empty string satisfies require_auth
    scopes: ['*'],
);
```

**Security caveat:** this is process-level trust, not per-caller authentication — every request through this process runs as `stdio` with every scope. It is only appropriate when you already trust whoever can talk to the process (e.g. an agent you spawned yourself over a private pipe), never when the transport is reachable by anyone else (HTTP, a shared socket, a multi-tenant host, ...). For real per-caller auth, implement `Auth\TokenValidatorInterface` and resolve a distinct `Principal` per request instead — see "The auth seam" above.

### Notifications: branch on `null`, not on your own `id` check

[](#notifications-branch-on-null-not-on-your-own-id-check)

A JSON-RPC notification is a request with no `id` member at all (§4.1) — the server MUST NOT reply, not even with an empty body. As of 0.2, `handle()` knows this itself: it still dispatches the method (so any side effects happen), but returns `null` instead of a response array whenever the original request had no `id` key. **Your transport's whole job is "if `handle()` returned null, write nothing"** — there is no need to re-derive notification-ness from the raw request yourself anymore.

```
while (($line = fgets(STDIN)) !== false) {
    $request = json_decode(trim($line), true);
    // ... decode/validate $request as JSON, handle parse errors, etc ...

    $response = $rpc->handle($request, $ctx);

    if ($response === null) {
        continue; // notification (or a batch/notification hybrid edge case) — write nothing
    }

    fwrite(STDOUT, json_encode($response) . "\n");
    fflush(STDOUT);
}
```

One wrinkle worth knowing about, not one you need to handle yourself: an explicit `{"id": null, ...}` member IS a request, not a notification — `handle()` tells the two apart internally with `array_key_exists('id', $request)`, deliberately never `isset()` (which would treat a present-but-null `id` the same as a missing one and wrongly suppress a reply the caller expected). You don't need to replicate that distinction; just branch on `handle()`'s return value.

### Batches: rejected, on purpose

[](#batches-rejected-on-purpose)

`handle()` is single-request only and always has been — one decoded request array in, one response array (or `null`) out. As of 0.2 it also says so on the wire: if the decoded request is itself a list (a JSON-RPC batch — `[{...}, {...}]`, or even the empty `[]`) it returns a `-32600 Batch requests are not supported` error with `id: null`, instead of silently misreading the list as one malformed request and leaving a batching-capable client hanging for responses that will never arrive.

This is also why `initialize()` now declares protocol revision **`2025-06-18`** rather than the earlier `2025-03-26`: that revision dropped batching from MCP's JSON-RPC transport entirely, so a server that only ever spoke single-request JSON-RPC is now spec-exact instead of an approximation with an undocumented gap. (The `2025-06-18` revision's other, capability-gated optional features — `structuredContent`, elicitation — are out of scope for this package; it declares the revision, not every feature introduced in it.)

If your host still wants its own batch guard ahead of `handle()` — e.g. to short-circuit before touching your own auth or logging — that's harmless. `handle()`'s guard makes it redundant, not wrong; keep it as defense-in-depth if you'd rather not depend on this package's internals for that decision.

What lives where
----------------

[](#what-lives-where)

LayerPackageOwnsContracts`milpa/core`Framework-wide primitives — events, attributes, DI/container contracts.Tool engine`milpa/tool-runtime``ToolRegistry`, `#[Tool]`, `ToolContext`, the resolve→validate→authorize→execute→audit pipeline.**MCP transport****`milpa/mcp-server`** (this package)`JsonRpcService` (the JSON-RPC 2.0 ⇄ `ToolRegistry` bridge) and the `TokenValidatorInterface`/`Principal` auth seam.Your appyour host / pluginsThe concrete HTTP/SSE endpoint, the concrete `TokenValidatorInterface` implementation (token storage, expiry, revocation), and — if you need MCP clients to obtain tokens themselves — an OAuth 2.0 authorization server in front of it.Requirements
------------

[](#requirements)

- PHP **≥ 8.3**
- [`milpa/core`](https://packagist.org/packages/milpa/core) **^0.5** (0.3's events + 0.5's `InterceptionSlot` — see "Events (0.3)" above)
- [`milpa/tool-runtime`](https://packagist.org/packages/milpa/tool-runtime) **^0.2**
- [`psr/log`](https://packagist.org/packages/psr/log) **^3**

Documentation
-------------

[](#documentation)

**Full API reference: [getmilpa.github.io/mcp-server](https://getmilpa.github.io/mcp-server/)** — generated straight from the source DocBlocks and dressed with the Milpa design system.

Contributing
------------

[](#contributing)

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Please report security issues via [SECURITY.md](SECURITY.md), and note that this project follows a [Code of Conduct](CODE_OF_CONDUCT.md).

License
-------

[](#license)

[Apache-2.0](LICENSE) © TeamX Agency.

---

Milpa is designed, built, and maintained by **[TeamX Agency](https://teamx.agency/?utm_source=github&utm_medium=readme&utm_campaign=milpa&utm_content=mcp-server)**.

###  Health Score

42

—

FairBetter than 88% of packages

Maintenance99

Actively maintained with recent releases

Popularity13

Limited adoption so far

Community2

Small or concentrated contributor base

Maturity42

Maturing project, gaining track record

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

5

Last Release

2d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/1993784?v=4)[rodrigomx](/maintainers/rodrigomx)[@rodrigomx](https://github.com/rodrigomx)

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/milpa-mcp-server/health.svg)

```
[![Health](https://phpackages.com/badges/milpa-mcp-server/health.svg)](https://phpackages.com/packages/milpa-mcp-server)
```

###  Alternatives

[matomo/matomo

Matomo is the leading Free/Libre open analytics platform

21.7k38.9k](/packages/matomo-matomo)[algolia/algoliasearch-client-php

API powering the features of Algolia.

69735.1M162](/packages/algolia-algoliasearch-client-php)[avalara/avataxclient

Client library for Avalara's AvaTax suite of business tax calculation and processing services. Uses the REST v2 API.

528.5M7](/packages/avalara-avataxclient)[keboola/storage-api-client

Keboola Storage API PHP Client

10405.9k40](/packages/keboola-storage-api-client)[flowwow/cloudpayments-php-client

cloudpayments api client

21101.2k](/packages/flowwow-cloudpayments-php-client)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
