# MCP (Model Context Protocol) server
`MMO\core\controllers\MCPController` turns any MMOFramework application into a
[Model Context Protocol](https://modelcontextprotocol.io) server: a single JSON-RPC 2.0 endpoint that any
MCP-compatible AI agent (Claude, or any other client implementing the protocol) can connect to in order to
**learn how to write code against MMOFramework**, and to **introspect the specific application it's running
in** (its actual entities, routes, classes and helper functions — not just the framework in the abstract).
This document explains what was built, why it's shaped the way it is, and how to extend it.
## Route
```
POST|mcp.json
```
registered like any other controller — connect it with `\MMO\app()->connectRoutes(MCPController::class)` or via
config:
```php
\MMO\config([
\MMO\core\Application::class => ['routes' => [\MMO\core\controllers\MCPController::class]],
]);
```
It is also included by `bootstrap.php` alongside the framework's other built-in controllers, so it is available
in any project that requires MMOFramework, the moment it is connected. (It previously existed as a file but was
never `include`d by `bootstrap.php` — that omission is fixed as part of this change; see "What changed" below.)
This is a single endpoint, request/response transport: one HTTP `POST` in, one JSON body out. It does not open
a Server-Sent Events stream (the other half of the MCP "Streamable HTTP" transport) — every client that only
needs request/response semantics (which covers `tools/call`, `resources/read`, `prompts/get`, etc.) works fine
against it as-is. Streaming server-initiated messages would need a second `GET|mcp.json` route built on top of
`\MMO\core\server\Websocket` or a chunked `Stream` response; it was left out because nothing in this server
currently needs to push unsolicited messages to the client (no long-running tools, no resource subscriptions).
**The response body is a bare JSON-RPC object, not the framework's usual envelope.** A normal
`Response::Json([...])` merges the returned array into the framework's own document body via
`Route::addContent()`, which a non-raw `JsonDocument` would print as `{"meta":...,"links":...,"data":...,
"logs":[...]}` — not what any JSON-RPC/MCP client expects. `onRoute()` instead sets
`Response::Json(JsonDocument::Raw([], ['printer' => JsonPrinter::Shared()]))`, whose `Raw` document prints its
body exactly as set (`$document->getBody()`), so `routeMCP()`'s `{"jsonrpc":...,"id":...,"result":...}` (or
`"error"`) reaches the client untouched.
## Protocol surface
| JSON-RPC method | What it does |
|---|---|
| `initialize` | Version negotiation + capabilities + the full coding guide as `instructions`. |
| `notifications/initialized`, `notifications/cancelled` | Accepted, logged, ignored — this server keeps no per-session state, so there's nothing to initialize or cancel. |
| `ping` | Returns `{}`. |
| `tools/list` / `tools/call` | 12 tools, described below. |
| `resources/list` / `resources/read` | Guide, live route/entity/package listings, every bundled `*.md` doc (searched recursively), and per-entity JSON Schemas. |
| `prompts/list` / `prompts/get` | 3 ready-made, parameterized prompts. |
| `server/discover` | Legacy alias for `initialize`, kept because a previous, non-standard version of this file already used that method name. |
Protocol version negotiation (`initialize`): if the client's requested `protocolVersion` is one this server
recognizes (`2025-06-18`, `2025-03-26`, `2024-11-05`), it's echoed back; otherwise the server's own latest
(`2025-06-18`) is returned, per spec.
**JSON-RPC batching is refused on purpose.** A request body that is a JSON array is answered with an
`invalid request` error rather than processed. MCP protocol revision 2025-06-18 removed batching support
entirely, and refusing it explicitly is simpler and less surprising than half-supporting it.
### Error mapping
Two different kinds of failure need two different shapes, and the controller is deliberately explicit about
which is which:
- **Protocol-level problems** (bad/missing top-level `method`, an unknown `method`, an unknown tool/resource/
prompt name, a missing required argument) become a real **JSON-RPC error** object
(`{"error": {"code": ..., "message": ...}}`), via `MCPController::ERROR_*`:
- `-32700` parse error (request body isn't valid JSON — practically unreachable here since the surrounding
`#[APIRoute(mediaTypes: ['json'])]` already parses the body before this controller runs; kept for
completeness/documentation).
- `-32600` invalid request (body isn't a single object — includes the refused-batch case).
- `-32601` method not found (the *outer* JSON-RPC `method`, e.g. `foo/bar`).
- `-32602` invalid params (anything else: bad/missing arguments, an unknown tool/resource/prompt *name* —
treated as an invalid parameter to a method that itself exists).
- `-32603` internal error (an unexpected exception; logged via `$this->error()` before being reported).
- **Tool execution problems** (the tool exists and was called correctly, but doing the work failed — an
unknown class name passed to `describe_class`, a non-Entity class passed to `get_entity_schema`, …) are
reported **inside a normal `tools/call` result**, as `{"content": [...], "isError": true}`, *not* as a
JSON-RPC error. This is what the spec recommends: it lets the calling model see what went wrong and retry
with different arguments, instead of the whole call blowing up.
This mapping is a deliberate design choice, not a transcription of some canonical error-code table — the base
JSON-RPC 2.0 spec only reserves `-32000..-32099` for implementation-defined server errors and leaves the rest
to judgment, so if a specific MCP SDK you're integrating against expects different codes for some case, adjust
`MCPController::routeMCP()`'s catch blocks.
## The coding guide (`MCPController::GUIDE`)
This is the actual answer to "how do I code with this framework": singletons/DI, entities (the ORM layer),
controllers/routing, schemas, events, and the namespaced-plain-function convention, plus the sharp edges that
have caused real bugs in this codebase before (`??` vs `===` precedence, extension-less template filenames,
`shell\escape()` only covering the one argument it escapes, ascending middleware order). It is served in three equivalent
places so every kind of MCP client picks it up somewhere:
- `initialize`'s `result.instructions` (many clients feed this straight into the model's system context on
connect),
- the `mmoframework://guide` resource (for clients that list/read resources instead),
- the `explain_framework` prompt (for clients that surface prompts as slash-commands).
It's a single PHP constant (`self::GUIDE`), and deliberately the *short* version: the full, canonical
knowledge is mmo.core's `mmoframework` skill (`.claude/skills/mmoframework/`), which the guide points to and
which is served as `mmoframework-skill://...` resources. When one changes, check the other.
## Tools
Tools are the part that make this genuinely useful instead of just a static doc dump: they let an agent ask
questions about *this application*, not the framework in general.
| Tool | Purpose |
|---|---|
| `describe_class` | Reflects any class/interface/enum: parent, interfaces, MMOFramework role (Entity / controller / singleton), and its own public methods with signatures + doc comments. For a controller, also lists its live routes; for an Entity, also lists its properties and `@unique` groups. |
| `list_entities` | Every Entity class currently reachable through a connected controller's `getRepositories()` (see "How repositories are discovered" below). |
| `get_entity_schema` | The full `Schema::FromClass()` JSON Schema for one Entity/EmbeddedEntity. |
| `list_routes` | Every connected `#[APIRoute]`: method, url, operation id, description. |
| `list_functions` / `describe_function` | Live reflection over `get_defined_functions()['user']`, filtered by namespace prefix — this is always accurate because it reads the *actual loaded* functions, not a hand-maintained list that would drift as the framework grows. |
| `generate_entity_stub` / `generate_controller_stub` / `generate_test_stub` | Return ready-to-paste PHP source (as text, in the tool result) following the framework's exact conventions: `strict_types`, `#[ArrayProperty]`, never redeclaring `Entity`'s own properties, the right controller base. The code comes from `\MMO\core\code\entity()`/`controller()`/`test()` (`src/functions/code.php`), the same functions `php tools/mmo.php make` uses to write a class into a library together with its bootstrap include and its test. They never write to disk — this server has no filesystem-writing tool at all, on purpose (see "Security notes"). |
| `validate_schema` | Validate a value against a JSON Schema, or against an Entity class's schema: `{valid, errors}`, each error with its keyword/instance location and a plain-text message. Pure computation: nothing is written or executed. |
| `search_documentation` | Full-text search across every bundled doc file, recursively (see next section), plus the guide itself. `package` narrows it to one package's documentation (the guide is mmo.core's). When the application loads mmo.doc, the **documented symbols** the query names come first (see "The documentation index" below): same `{resource, line, excerpt}` shape, plus `symbol`, `kind`, `language`, `package` and `url`. |
| `documentation_index` | Only listed when the application loads mmo.doc: the index of everything documented — every namespace, class, abstract class, interface, trait, enum, exception, function, constant, custom element, view, mixin and guide of every documented package (methods on request, with `kind: method`) — filtered by `kind`, `package`, `language` and `filter` words, paged with `limit`/`offset`. `{total, kinds, entries}`: `kinds` counts what the other filters let through, per kind. |
| `list_packages` | Every `PackageManifest` currently `included` (`PackageManager::Shared()->loopIncluded()`) — mmo.core plus every `mmo-*` library the running application actually depends on — with its `title`/`description`/`status`/`keywords`/`dependencies` "showcase" metadata, `included: true`, and which documentation resources it contributes; with the `discovered` option, every package only discovered too, `included: false` (see "Documenting discovered packages" below). This is how an agent learns "what else is available" instead of assuming mmo.core is the whole framework. |
All tool results come back as `{"content": [{"type": "text", "text": "..."}]}`, usually a pretty-printed JSON
block for data-shaped answers, or a fenced ```php code block for the two generators. No `outputSchema`/
`structuredContent` is declared, on purpose: not every MCP client honours structured content, and a plain text
block is something literally every client can show a model.
### How repositories are discovered
`list_entities` and `describe_class` (for a controller) both need to find "every repository this application
actually has", without hardcoding a dependency on `RepositoryController` specifically. The mechanism:
```php fragment
foreach (array_keys(\MMO\app()->getRoutes()) as $class) {
if (method_exists($class, 'getRepositories')) {
foreach ($class::Shared()->getRepositories() as $name => $repo) { ... }
}
}
```
Both `aDataController` and `RepositoryController` already declare a public `getRepositories(): array`
returning `[name => iRepository]` — that's an existing, already-used convention, not something invented for
this controller. Any connected controller that follows the same convention (declares `getRepositories()`)
is picked up automatically, with no MCPController changes needed.
### How documentation is discovered
`search_documentation` and the `mmoframework-docs://...` resources come from every **included package's**
(and, with the `discovered` option, every discovered package's: see "Documenting discovered packages" below)
declared documentation folder(s) — `PackageManager::Shared()->loopIncluded()`, resolved through
`PackageManifest::getDocumentationFolders()` (each entry of `$documentations`, e.g. `["docs"]`, made absolute
and filtered to ones that actually exist) — then **recursively** scanned for `*.md`
(`\MMO\core\files\scanFolder($folder, true, '/\.md$/i')`). That means:
- Nested documentation is picked up for free: `docs/php/**/*.md` (mmo.core's own per-class reference, one file
per source file — see below), `docs/mcp/*.md` (the curated, MCP-oriented guide some packages, e.g.
`mmo.client`, ship alongside their reference docs) and any other subfolder all surface without extra code.
- This file (`docs/MCP.md`) is itself discoverable through the MCP server the moment it's saved — no wiring
needed, and no special-casing for "the MCP controller's own docs" in the code.
- Any other MMOFramework package built on top of `mmo.core` (an app, a plugin, any of the `mmo-*` libraries —
`mmo.client`, `mmo.oauth`, `mmo.pdo`, ...) that ships its own `docs/*.md` folder contributes its documentation
to the same `resources/list` / `search_documentation` surface for free, as long as it's `inc()`-included in
the running application (or only discovered, with the `discovered` option: see "Documenting discovered
packages" below) and its `mmo.json` declares `"documentations": ["docs"]`. `list_packages` (see the
tools table above) is the companion mechanism: it surfaces each such package's own identity (title,
description, status) rather than its file contents, using the same `PackageManifest` "showcase" fields
(`$title`, `$description`, `$keywords`, `$status`, `$links`, ...) that a package's own `mmo.json` declares —
see e.g. `mmo.client`'s or this package's own `mmo.json` for the shape.
**Agent skills** are served the same way, as `mmoframework-skill://{package}/{skill}/{file}.md`: every `*.md`
under a package's `.claude/skills/` folder (the `SKILL.md` and `references/` Claude Code itself loads). That
keeps one copy of the framework's knowledge — `mmo.core`'s `mmoframework` skill, `mmo.client`'s `mmo-client`
skill — for Claude Code and every other MCP client alike. A library that only *links* to another package's
skill (a symlink, so that Claude Code finds it when started in that library) does not list it a second time:
a skill file is only served under the package whose folder really holds it. A package built into a `.phar` —
which is how a release carries every package — holds its own skill folders under the same path, and not the
ones it links to (`PackageManifest::getSkillFolders()`), so a release's server serves the same skills.
### The documentation index
mmo.core does not depend on mmo.doc, but an application that loads it (the hub does) has an index of everything
its generators documented: `\MMO\doc\DocIndex` — the `index.json` files of every package's documentation,
completed with the php namespaces, the exceptions told apart from the other classes, and every hand-written
page. It is what mmo.doc's `documentation` html page lists and its search box searches, and the server reads the
very same one (`MCPController::DOC_INDEX`), only when that class is loaded:
- `documentation_index` lists it (the tool is left out of `tools/list` otherwise);
- `search_documentation` puts the symbols a query names ahead of its full-text matches — a symbol whose name
holds a word of the query and whose name, namespace or summary holds them all, at most `SYMBOL_LIMIT` (15), the
best named first, so `Route stop` answers `Route::stop` before any sentence mentioning both;
- `describe_class` and `describe_function` add a `documentation` block (`url`, `resource`, `line`) — and
`describe_class` still answers for a class whose code is not loaded (a package only discovered) when the index
documents it, with `loaded: false`, its summary and its page.
Every entry points at two places: `url`, its html page as the running application serves it, and `resource`,
its generated markdown page as a `mmoframework-docs://` uri (`resources/read` serves it; `line` is where the
symbol starts in it). Only the packages the server documents are indexed (see `discovered` below): an entry of a
package it leaves out is left out too.
### Documenting discovered packages
A package can be known to an application without being loaded: `PackageManager::discoverUrls()` (and every
manifest's `urls`) only *reads* manifests, and `PackageManager::getManifests()` returns them all, included or not.
Serving a package's documentation only needs its manifest, so the server can document those packages too, with
its `discovered` option:
```php
\MMO\config([
\MMO\core\controllers\MCPController::class => ['discovered' => true],
]);
```
In a configuration file, that is a `"MMO\\core\\controllers\\MCPController": {"discovered": true}` entry of its
own — not the controller's entry in `Application`'s `routes`, whose settings do not reach the controller.
Every package whose manifest was read is then documented like an included one: its `mmoframework-docs://` and
`mmoframework-skill://` resources — the uris it keeps once included — searched by `search_documentation`.
`list_packages` lists it with `included: false` — its code is not loaded, so `describe_class`/`list_functions`
cannot see its classes, and the application has to depend on it before using it — and its resources say "only
discovered". The included packages come first, in the order they always had, then the others by name: a search
ranks the included packages' equal matches first. Of a package read in several versions, the most recent is
documented. A scaffolding template — a manifest whose name still holds its `{{placeholders}}`, like the hub's
`templates/project/mmo.json` — documents no package and is left out, as `TestRunner::getLibraries()` leaves it out.
It is **off by default**, on purpose: an application's MCP server describes what it runs on, and what an
application discovers is not what it uses. In a checkout, mmo.core's own manifest (`"urls": ["../.."]`) discovers
the whole MMOFramework clone it comes from — the hub, every library, the project template — so a project running
on mmo.core and mmo.client would otherwise serve the documentation of a dozen libraries it does not have, whose
best matches would outrank its own in search results. The MMOFramework hub turns it on (its `configs/dev.json`): it
includes four libraries and only discovers the others, and showing every library of `libs/` is its whole job.
### Security notes
- **No tool writes to the filesystem, a database, or executes anything.** The `generate_*_stub` tools return
source code as text (`php tools/mmo.php make`, run by a person or an agent with shell access, is the way to
write it); the calling agent decides whether and where to save
it. This was a deliberate scope limit: an MCP server reachable by "any AI agent" should not expose a
one-call path to modifying application code or data.
- **`resources/read` never builds a filesystem path from client input.** Every `mmoframework-docs://...`/`mmoframework-skill://...` uri is
looked up by exact match against the list `discoverDocuments()` already computed server-side (i.e. only a
path this server itself discovered under a package's declared `documentations` folder or its `.claude/skills`
folder can ever be read; a symlink resolving outside that folder is skipped. With `discovered`, that includes the
packages the application only discovered: folders declared by a manifest its own discovery found) —
there is no path-traversal surface because the client's `uri` string is never concatenated into a path.
`mmoframework-schema://{class}` is the only resource scheme that takes an arbitrary client-supplied value
into real logic, and that value only ever reaches `is_subclass_of()`/reflection (not I/O), gated to
Entity/EmbeddedEntity subclasses.
## Prompts
`explain_framework` (no arguments) just returns the guide. `create_entity` and `create_controller` take a
target class name and a short comma-separated field/route spec, and return one `user`-role message that:
embeds the relevant subset of the guide's rules inline (so the model doesn't need a second round-trip to fetch
the guide), then appends a real generated stub from the corresponding tool. Point any MCP client's
slash-command / prompt picker at these for a scaffold-and-explain flow in one step.
## What changed / how to extend
- **`src/controllers/MCPController.php`** was rewritten from a small, non-standard `server/discover`-only stub
into the full JSON-RPC 2.0 MCP server described above, then iterated on directly in the framework (the bare
JSON-RPC response envelope fix, and moving document discovery onto `PackageManifest::getDocumentationFolders()`
once that method existed), then extended again with the `list_packages` tool/resource and a Schemas section
in the guide corrected against the actual `commentInformation()`/`PRIMITIVE_CONSTRAINTS` implementation (the
real docblock-tag set — `@unique` is comma-separated, not space-separated; there is no `@hash`/`@uid`/
`@internal` tag despite one documentation draft describing them — see the guide's Schemas section for what's
actually implemented).
- **`src/bootstrap.php`** `include`s `controllers/MCPController.php` alongside the framework's other built-in
controllers. It was previously missing from that list, which meant the class — despite existing in the repo —
was never actually loaded or reachable by any application.
To add a new tool: add one entry to `MCPController::TOOLS` (name, description, JSON-Schema `inputSchema`,
`handler` method name) and implement the `protected function toolXxx(array $arguments): array` method,
returning `$this->textResult(...)`. To add a new resource: extend `mcpResourcesList()`/`mcpResourcesRead()`
following the same pattern as the existing `mmoframework://...` entries. Whenever a `docs/*.md` file changes in
a way that affects how code should be written, check whether `MCPController::GUIDE` needs the same correction —
it is meant to describe *verified, current* behaviour, not aspirational documentation; when the two disagree,
trust the actual source (a `describe_class`/`get_entity_schema` call, or a five-line test script), not the doc.
## Sharing other `mmo-*` libraries
Nothing in `MCPController` is specific to `mmo.core` beyond the guide text: `discoverDocuments()`,
`list_packages`, `list_functions`/`describe_function` and `describe_class` all work over *every included
package*, not just this one (the first two over every discovered one too, with the `discovered` option). For a
sibling library (`mmo.client`, `mmo.oauth`, `mmo.pdo`, ...) to show up correctly to an agent through this same
server, it only needs, in its own `mmo.json`:
- `"documentations": ["docs"]` (or wherever its docs live) so its `*.md` files are found — `MCPController` never
needs to know that folder exists ahead of time;
- the showcase fields (`title`, `description`, `keywords`, `status`, `links`, ...) filled in, so `list_packages`
describes it meaningfully instead of falling back to its bare package name;
- ideally a short, MCP-oriented entry document (see `mmo.client`'s `docs/mcp/Overview.md` for the pattern this
package's own `docs/MCP.md` and `MCPController::GUIDE` follow) pointing at its other, more detailed docs in
the order an agent should read them — the same shape as this file for mmo.core.
None of this requires touching `MCPController.php` itself.
## Verifying this yourself
Every behaviour described above (protocol negotiation, all 12 tools, all resources including live, recursive
document discovery from `docs/**/*.md` and the new `list_packages`/`mmoframework://packages`, all 3 prompts,
both error paths, and the bare-envelope response shape) was exercised directly — `require 'bootstrap.php'`,
connect `MCPController` (plus, for the entity-facing tools, a real `JsonRepository`-backed test entity), and
call each JSON-RPC method through reflection, including building a real `Route`/`Request` to check the actual
HTTP response body — rather than assumed from reading the code. There is no HTTP server or network round-trip
needed to do this; see `MMOFramework`'s own testing convention (`docs/API.md`, and the framework's general
"write a five-line script and run it" discipline) for the same approach applied to anything else in this
codebase.