Index
# 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.