Search by

The Superconductor MCP framework module for VenusianPHP.

Package info

github.com/superconductor-mcp/core

pkg:composer/superconductor/mcp

Statistics

Installs: 182

Dependents: 7

Suggesters: 0

Stars: 0

Open Issues: 0

0.2.4 2025-06-11 16:32 UTC

README

superconductor/core

The Model Context Protocol as a VenusianPHP module: serve it, consume it, same event loop

CI Packagist PHP MCP License OKF

The Model Context Protocol is how an LLM harness talks to the tools, documents and prompts an application wants to offer it: JSON-RPC 2.0 messages with a fixed vocabulary, over a transport the two sides agree on. Every SDK for it gives you a server class, a client class, and an event loop of its own to run them on.

A VenusianPHP application already has an event loop. Its sketches run on it, its worker pools run on it, its sockets and timers wake on it. The only problem? An MCP SDK brings its own loop and wants the application to wait on it.

superconductor/core speaks MCP as a native citizen of that loop. A server is an MCPServer class that lists what it offers, run as a sketch by a transport package. A client is an MCPClient the application holds inside its own loop, one per connection, built from an mcp_servers list in the same shape Claude Desktop uses. Neither side owns a loop; both ride the one the application already has.

Basic usage

Most ways you will interface with superconductor/core are in two parts -

Serving. A server is a class with a #[Server] name, its capabilities, and what it offers. Each tool, resource or prompt is a definition pointing at a class with handle().

#[Server('concept')]
class ConceptServer extends MCPServer
{
    protected array $entity_info = ['name' => 'Concept', 'version' => '1.0.0'];

    protected array $entity_capabilities = [
        'tools' => ['listChanged' => true],
        'resources' => ['subscribe' => true, 'listChanged' => false],
        'prompts' => ['listChanged' => false],
        'logging' => [],
    ];

    public function tools(): array
    {
        return [
            new ToolDefinition('echo', 'You get out what you put in', ['input' => new StringProperty('What to send back')], ['input'], action: EchoTool::class),
        ];
    }
}

#[ToolCall]
class EchoTool
{
    public function handle(string $input): TextProperty
    {
        return new TextProperty($input);
    }
}
php rocket mcp:cli concept      # with superconductor/stdio installed

Consuming. Name the servers in config/mcp.php, then take the manager out of the container. Every method that talks to a server returns an IOPools promise.

'mcp_servers' => [
    'concept' => ['type' => 'stdio', 'command' => PHP_BINARY, 'args' => ['rocket', 'mcp:cli', 'concept'], 'cwd' => base_path()],
    'neo4j'   => ['type' => 'stdio', 'command' => 'neo4j-mcp', 'env' => ['NEO4J_URI' => env('NEO4J_URI')]],
],
$clients = app(ClientManager::class);           // or app('mcp.clients')

foreach ($clients->connectAll() as $name => $client) {
    $client->ready()->wait();                   // handshake done, every list cached
    $client->tools();                           // the server's tools, every page
}

$result = $clients->client('neo4j')
    ->callTool('read-cypher', ['query' => 'MATCH (n) RETURN count(n) AS nodes'])
    ->wait();

$result->content;                               // [['type' => 'text', 'text' => '…']]
$clients->tools();                              // every server's tools as {server}__{tool}
$clients->closeAll();                           // each server shut down in the spec's order

Requirements

PHP 8.4.27 or newer in 8.4, or 8.5.12 or newer.
VenusianPHP A 0.11 application: venusian-voyager/console, contracts, io-pools, nuts-and-bolts and workflows. The code calls config() and app(), which the application's framework provides; the test suite gets them from venusian/framework in require-dev.
A transport One transport package. Today that is superconductor/stdio; superconductor/web (HTTP, built on venusian/serve) is planned.
Protocol 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05. Later stops on the line are added one at a time and never drop an earlier one. Batches are received from sessions below 2025-06-18, never sent.

Installation

composer require superconductor/core superconductor/stdio

The service provider is discovered. It merges config/mcp.php, discovers servers under App\Mcp\Servers, binds the server repository as mcp.servers and the client manager as mcp.clients. Publish the config to change any of it:

php computer vendor:publish --tag=mcp-config

Verify with the transport's own commands; with superconductor/stdio:

$ php rocket list | grep mcp:
  mcp:cli      Start an STDIO-driven MCP Server

$ printf '%s\n' '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"me","version":"1"}}}' | php rocket mcp:cli concept
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{"listChanged":true},…},"serverInfo":{"name":"Concept","version":"1.0.0"}}}

Configuration

config/mcp.php:

Key Default What it does
servers.default_discovery_namespace App\Mcp\Servers Where #[Server] classes are found. Must sit under the application namespace.
servers.supported_mcp_versions ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'] What a server will speak and what a client proposes (the latest).
elicitation.timeout 600 Seconds a handler waits for the user's answer before it cancels the question.
mcp_servers [] The servers this application connects to, keyed by name: type, command, args, env, cwd, disabled. An entry with a command and no type is stdio, so a Claude Desktop entry pastes in as it is.
client.connect_timeout 20 Seconds a client may spend between opening its connection and finishing the handshake.
pagination.page_size 50 Entries per page of tools/list, resources/list, resources/templates/list and prompts/list.
logging.default_level info The level a session starts at until its client sends logging/setLevel.
progress.min_interval 0.1 Shortest gap, in seconds, between two progress notifications for one request.
completion.rate_limit 10 per 1 s completion/complete calls a session may make per window.

Serving

Generate a server to start from:

php computer make:mcp-server WeatherStation      # app/Mcp/Servers/WeatherStation.php, served as `weather-station`

It is written under servers.default_discovery_namespace, refuses to overwrite a server unless you pass --force, and comes with one echo tool. Put your own stubs/mcp-server.stub in the application to change what it writes.

Everything a server offers is a definition. A definition's action is a closure or a class carrying the matching attribute and a public handle(); the request's arguments are injected by name.

Offer Definition Action attribute handle() returns
Tool ToolDefinition(name, description, properties, required, action); named title, annotations, output_schema, meta #[ToolCall] A SchemaPrimitive or a list of them: TextProperty, ImageProperty, AudioProperty, EmbeddedResourceProperty, ResourceLinkProperty. Or StructuredContent($data) for a data result. Throwing reports isError in the result.
Resource ResourceDefinition(uri, name, description, mimeType, action) #[ResourceRead] ResourceContents(uri, mimeType, text or blob).
Resource template ResourceTemplateDefinition(uriTemplate, name, description, mimeType, action, completers) #[ResourceRead] ResourceContents; the {variables} of the template arrive as arguments.
Prompt PromptDefinition(name, description, arguments, action) with PromptArgument(name, description, required, completer) #[PromptGet] A list of PromptMessage::user(…) / PromptMessage::assistant(…).
Completion A completer on a PromptArgument or template variable #[Completes] CompletionResult (at most 100 values, hasMore).

Override tools(), resources(), resourceTemplates() or prompts() to compute the list per call. What changes at runtime is announced through the server itself:

$this->notify(new ToolListChangedNotification());       // sent only if tools.listChanged is advertised
$this->notify(new ResourceUpdatedNotification($uri));   // reaches only sessions subscribed to $uri
$this->log(LogLevel::Info, 'document.flipped', ['visible' => false]);   // Fabricate\Contracts\NutsAndBolts\LogLevel; filtered by each session's level

Every definition takes a title for display, and resources and content items take Annotations (audience, priority, last modified).

Tools, resources, resource templates, prompts and a server's identity take icons (2025-11-25): new Icon($src, $mime_type, $sizes, $theme) or the spec's array ['src' => ..., 'mimeType' => ..., 'sizes' => ['48x48'], 'theme' => 'dark']. They go out as declared, to every client; a definition with a missing src or a theme other than light or dark fails when it is built. A server's $entity_info may also carry description and websiteUrl.

A call with a missing or unknown argument is answered with a tool result flagged isError and the problem as text from 2025-11-25 (so the model can correct itself), and with a -32602 error before; an unknown tool is a -32602 protocol error at every version.

A tool name is 1 to 128 characters of A-Z a-z 0-9 _ - . (2025-11-25); ToolDefinition throws InvalidArgumentException for any other name, at every protocol version.

A tool that returns data declares an output_schema and returns StructuredContent; the client gets structuredContent plus the same JSON as text. Nothing checks the data against the schema; the tool keeps that promise.

new ToolDefinition('measure_part', 'Measures the part', action: MeasurePartTool::class, title: 'Measure the part',
    output_schema: new ObjectSchema(['area_mm2' => new NumberProperty()], ['area_mm2']));

// MeasurePartTool::handle()
return new StructuredContent(['area_mm2' => 1000.0]);

A tool that needs the user mid-call takes Elicitation in its constructor (2025-06-18; the client must have declared it):

$answer = $this->elicitation->ask('How should the part be made?', new ObjectSchema(
    ['material' => new EnumProperty(['steel', 'pla']), 'thickness' => new NumberProperty(minimum: 0.5)],
    ['material'],
));

$answer->accepted() ? $answer->content['material'] : $answer->action->value;   // accept, decline or cancel

For what must not pass through the client (credentials, payment, a third-party sign-in), send the user to a URL instead (2025-11-25; the client must have declared URL mode). Accept means they agreed to open it; queue the completion when your own page sees them finish:

$answer = $this->elicitation->askUrl('Connect your calendar.', "https://mcp.example.com/connect?e={$id}", $id);

// later, once https://mcp.example.com/connect has verified the same user and finished
$server->notify(new ElicitationCompleteNotification($id));

A handler that can't go on until the user does that throws new UrlElicitationRequired([['message' => 'Connect your calendar.', 'url' => $url, 'elicitationId' => $id]]); the request is answered with error -32042 listing it, and the client may retry once it completes.

A tool that runs long takes a ProgressReporter in its constructor and calls report($progress, $total); the notifications go out while it runs. Tool calls, resource reads, prompt gets and completion run in a loop fiber, so a tool that waits on the loop ($loop->await(…), $loop->until(…)) keeps the server answering pings and honouring notifications/cancelled meanwhile.

Consuming

ClientManager (mcp.clients) is a MultipleInstanceManager: one client per mcp_servers entry, built by the transport the entry's type names. Application code types against Fabricate\Contracts\Client\MCPClient; the manager hands back that contract, and the concrete Fabricate\Client\MCPClient is what a transport constructs.

Call Gives
connectAll() Every client, each connecting at once. Read ready() or status() per client.
client($name) One client, connected on first use.
tools() Every connected client's tools as {server}__{tool}.
callTool('{server}__{tool}', $args) The call routed to the client that owns the tool.
closeAll() Every connection closing side by side; returns once each has ended.

MCPClient:

Call Gives
status() A Fabricate\Contracts\Client\ClientStatus: NotConnected, Connecting, Connected, Error or Disabled. lastError() says why for Error.
ready() A promise resolving with the client once the handshake is done and every list the server advertises is cached; rejected with RequestFailed when connecting failed.
serverInfo(), serverCapabilities(), protocolVersion() What the handshake negotiated.
tools(), resources(), resourceTemplates(), prompts() The caches, every page, refilled when the server sends a list_changed.
ping(), listTools($cursor), callTool($name, $args), listResources($cursor), listResourceTemplates($cursor), readResource($uri), listPrompts($cursor), getPrompt($name, $args) A promise of the typed response. Content comes back as the same primitives a server sends (TextProperty, ResourceContents, PromptMessage, …); content that does not fit them rejects. A JSON-RPC error reply rejects it with RequestFailed carrying rpc_code and data.
request(MCPRequest, $responseClass) The same for any request class.
close() Rejects what is pending and ends the connection.

The client answers the server's pings, refuses a server id it has already seen, logs the server's notifications/message lines into the application log at the level they were sent, and refuses to ask a server for a capability it did not declare.

To answer elicitation, set a handler before the client connects; it shows the question and returns the user's answer:

app(ClientManager::class)->onElicit(function (ElicitRequest $request, MCPClient $client): ElicitAnswer {
    // $request->message, $request->requested_schema: a flat object of string, number, integer, boolean, single-select and multi-select fields, each possibly with a default
    return ElicitAnswer::accept(['material' => 'pla', 'thickness' => 2.0, 'finishes' => ['matte', 'sanded']]);   // a multi-select answers with a list   // or ElicitAnswer::decline(), ::cancel()
});

URL mode (2025-11-25) has its own handler, which must show the full URL and its host and open it outside the client only if the user agrees; accept carries no content. A completion handler hears when the server says one finished:

app(ClientManager::class)
    ->onElicitUrl(fn (ElicitRequest $request, MCPClient $client): ElicitAnswer => askUserToOpen($request->url) ? ElicitAnswer::accept([]) : ElicitAnswer::decline())
    ->onElicitationComplete(fn (string $elicitation_id, MCPClient $client) => retryWaitingCalls($elicitation_id));

Packages you might use with this one

Package Gives
superconductor/stdio The stdio transport: rocket mcp:cli <server> to serve, StdioConnection to consume.

AI-assisted development

This codebase was built with LLM assistance under human direction. Prompts, reviews, and architectural decisions were owned by a professional software engineer — the model accelerates drafting and exploration; it does not replace judgment on APIs, dependency direction, or what ships.

Agentic Documentation

This repository carries an Open Knowledge Format bundle at .okf/: Markdown with YAML frontmatter, readable by you and loadable by a coding agent. It records what the README doesn't: why server and client are different entities on one loop, how a line becomes a typed message and how an error keeps its id, why each side keeps two id ledgers, which procedures run in a fiber and what that buys, and a log of every change that altered behaviour.

Start at .okf/index.md. AGENTS.md is the short rule sheet an agent reads before touching a message, a procedure, the client or the server; each rule points into the bundle for the reason behind it.

Point your agent at the repo and it has the same context the maintainer has. Point yourself at it when the README's answer is "it does X" and you want "because".

Resources

Running the test suite

The suite is Pest v4 and needs no server: the client runs on a fake connection that records what it sends and takes replies by hand, procedures run against a bench MCPServer, the decoder against lines. venusian/framework is in require-dev because the code calls config() and app().

git clone https://github.com/superconductor-mcp/core.git
cd core
composer install
vendor/bin/pest
Tests:    68 passed (327 assertions)

Contributing

Open an issue first for anything beyond a typo. Pull requests come with a Pest test for the behaviour and keep CI green. A change to a wire shape names the spec page it follows. Read AGENTS.md for the rules the code follows.

Security

A server runs the application's own tools for whichever client the transport connects; a client spawns the commands its config names. See SECURITY.md for the model and how to report a vulnerability.

License

MIT. See LICENSE.