@animalabs/mcpl-core
Published on npm as
@animalabs/mcpl-core. Inside the connectome ecosystem it's consumed as a sibling source checkout under the@connectome/mcpl-coredependency key (file:../mcpl-core-ts) — npm keysfile:deps by name, so both work side by side.
TypeScript core for MCPL — an MCP-superset wire protocol that adds server-initiated push events, channels (bidirectional, push-driven message streams), conversation branches, inference hooks, host-managed state, and capability / feature-set negotiation on top of plain JSON-RPC.
This package is the shared, transport-agnostic core used across the connectome agents (relays, bridges, hosts). It contains no business logic — just the wire contract and a connection primitive.
What's in here
McplConnection— a JSON-RPC 2.0 connection over a readable/writable stream pair (McplConnection.fromStreams(stdin, stdout)), with a pull-basednextMessage()for incoming requests/notifications and promise-basedsendRequest()/sendResponse()/sendNotification()/sendError(). Over a stream, each message is one line, ended by\n(the last one also by the end of the input) and by nothing else, so a U+2028 or U+2029 inside a JSON string stays in its message; over a WebSocket (fromWebSocket), each text message is one message. It sends both characters escaped (\u2028,\u2029), so a peer whose line reader splits there, as Node 24's readline does, still reads one message. It closes once: when either stream fails (a TCP reset, a WebSocket error, a child process's broken pipe), when its input ends or is destroyed, when its output is destroyed before it finished, or onclose(). An output ended on purpose leaves the input to finish: the connection still reads, and sends nothing more. Closing takes effect before anything is reported: nothing more is sent or read, pending requests reject withConnectionClosedError, andnextMessage()returns what was already queued and then rejects. Then the stream error that closed it is emitted, once, as'error', and'close'follows; once it has closed, nothing more is read and a later stream error isn't reported. A malformed line is emitted as'error', and so is an exception a'request'or'notification'listener throws: an Error as itself, and any other thrown value as thecauseof an Error. Neither closes the connection, and the message a listener threw for still reachesnextMessage(). A request drained from the queue after the close can't be answered. A connection built on a stream that was already done is closed from the start, and reports it on a later turn of the event loop, to listeners added before anything else is awaited: that stream's error if it had one, then'close'.close()also releases the output, once (it destroys the output stream, and closes a WebSocket; an input given tofromStreamsstays the caller's), even if a'close'listener throws, so call it after a close the peer caused, too.method— the method-name constants:initialize,push/event,channels/*(list,open,close,publish,changed, …),branches/*,inference/*,state/*,model/info, …- Typed params/results for every method (e.g.
ChannelDescriptor,PushEventParams,ChannelsPublishParams). - Content blocks —
ContentBlock(text/image/audio) plus helpers liketextContent(...). - Capabilities & feature sets — negotiation types (
InitializeCapabilities,McplCapabilities,FeatureSetDeclaration, context/inference hook caps), the closedCAPABILITY_PATHSvocabulary (SPEC §6.2), the recursive advertisement walk (advertisedCapabilities, §5.1), grant matching (capabilityGranted, §5.4) and fail-closedusesvalidation (validateUses, §6.4). - Manifest changes (§17 / RFC-003) —
manifestDigest(the canonical content digest),changedDomains, andManifestTracker: callsetManifest(next)and the digest, the diff, the per-connectionmcpl/manifestChangedannouncement and themcpl/manifestsnapshot all follow. Servers never hand-author an announcement. - Errors —
ConnectionClosedError, whose message says what the close meant for the call (nothing was sent; it was handed to the connection's output and got no answer; or, fromnextMessage(), no more messages will come), and whoseoutcomeis'not-sent'or'no-response'for a request and undefined fornextMessage();RpcError, a JSON-RPC error with itscodeanddata, whose message has no code in front of it: the other end's own message, or a sentence that says what came when it sent none as text;ConnectionTimeoutError, theRpcError(code -32000,outcome'no-response') a request with no reply in time rejects with; andERR_*protocol error codes.
Capability advertisement is a tree, and absence is denial
channels, contextHooks.beforeInference and inferenceRequest are objects
whose members are the leaves of the §6.2 vocabulary. A boolean true at any
level is shorthand for every leaf beneath it; false or absence means none.
const mcpl: McplCapabilities = {
version: '0.5',
channels: { register: true, publish: true, incoming: true, streaming: true },
contextHooks: {
beforeInference: { observe: false, inject: { beforeUser: true, afterUser: true } },
},
};
advertisedCapabilities(mcpl);
// Set { 'channels.register', 'channels.publish', 'channels.incoming',
// 'channels.streaming',
// 'contextHooks.beforeInference.inject.beforeUser',
// 'contextHooks.beforeInference.inject.afterUser' }
That is an input to the host's grant computation, never an authorization.
effectiveCapabilities (§5.4) is the sole normative allowlist: every path not
present is denied. deniedCapabilities is diagnostics and MUST NOT reach an
authorization decision.
Conformance vectors
test/vectors/manifest-digest-vectors.json is a verbatim copy of the frozen
interop vectors from
anima-research/mcpl
conformance/manifest-digest-vectors.json. It is not edited here — a vector
believed wrong is an issue against that repo. Run a newer checkout with:
MCPL_DIGEST_VECTORS=/path/to/mcpl/conformance/manifest-digest-vectors.json npm test
Toolsets and tool hints (RFC-014, RFC-015 — drafts)
ToolsetServer is a server-side runtime for the draft MCPL RFC-014 (tool presence, toolsets
and surfaces). It is a framework, not wire contract, so it lives on its own entry point,
@animalabs/mcpl-core/toolsets; the package root carries only the RFC-014/015 method names
(method.TOOLSETS_OPEN, method.ACCESS_TOKEN, …) and wire types. A server keeps its full tool list and says, per tool, whether it is static or
dynamic, whether it is visible right now, and which toolset it belongs to. Hosts that
understand the hints keep dynamic tools out of the model's stable tool list; hosts that don't
see the full list, plus toolset_open / toolset_close (and, for toolsets with instances,
toolset_use) tools so the server stays usable.
import { McplConnection } from '@animalabs/mcpl-core';
import { ToolsetServer } from '@animalabs/mcpl-core/toolsets';
const server = new ToolsetServer({
name: 'arcade', version: '1.0.0',
toolsets: {
game: {
title: 'Game', summary: 'A game against the bot', default: 'closed', instances: true,
tools: [play, pass, resign], // MCP tool definitions
open(ctx, options) { // one instance per toolsets/open
const g = newGame(options);
return {
title: 'Game',
visible: () => (g.myTurn ? ['play', 'pass', 'resign'] : ['resign']),
content: () => [{ type: 'text', text: g.board() }],
async call(tool, args) { return g.do(tool, args); }, // results carry their own hint changes
close() {},
};
},
},
},
});
await server.serve(McplConnection.fromStreams(process.stdin, process.stdout), 'local');
What it does for you:
- the toolset methods, with named instances;
- hint changes in call results, on events, or standalone, with consecutive revs per connection (hosts apply them on arrival, so a refused push is never restated);
toolsets/listas a full statement, withendedrecords (by id) and events held for a principal that was away, delivered after its list;- events attributed to the call that caused them (so a call's consequences are never also pushed);
- instances that belong to the principal and survive reconnects;
ctx.access(audience), the server side of RFC-015 (delegated access), which asks the host for a short-lived credential.
Limits to know:
- Validation. Arguments to every tool, static or in a toolset, and toolset options are checked
with a small built-in JSON Schema subset: type, enum, const, properties, required,
additionalProperties, items, the min/max keywords, pattern, oneOf/anyOf/allOf. It fills
defaults and ignores everything else ($ref,if/then/else,not,format,uniqueItems, …). Passvalidate(e.g. ajv) if your schemas use those. - Lifetime. Instances outlive a connection, so a reconnect finds them. After a principal's last
connection closes they stay open for
idleCloseMs(default 24 h), then are closed and the principal forgotten;idleCloseMs: 0keeps them until the process exits. - Grants. An MCPL host that has not granted
tools, or has disabled the server's<name>.toolsfeature set, gets no calls or toolset methods (-32002 / -32001). A plain MCP host is served as MCP.
Install
npm install @animalabs/mcpl-core
Usage
import { McplConnection, ConnectionClosedError, textContent, method } from '@animalabs/mcpl-core';
const conn = McplConnection.fromStreams(process.stdin, process.stdout);
// Stream errors (a reset, a broken pipe), malformed lines and exceptions
// thrown by 'request' or 'notification' listeners arrive here. A stream
// destroyed without an error closes the connection with no 'error'.
// Like any EventEmitter, a connection with no 'error' listener throws them.
conn.on('error', (err) => console.error('mcpl:', err.message));
// server-initiated push, at any time while the connection is open:
conn.sendNotification(method.PUSH_EVENT, { /* … */ });
try {
while (true) {
let msg;
try {
msg = await conn.nextMessage();
} catch (err) {
// The connection closed: a stream ended or failed, or close() was called.
if (err instanceof ConnectionClosedError) break;
throw err;
}
if (msg.type === 'request' && msg.request.method === 'tools/list') {
conn.sendResponse(msg.request.id, { tools: [] });
}
}
} finally {
// Releases the output (stdout here), whichever side closed the connection.
// The input stays this program's: the connection only stops reading it.
conn.close();
}
Build & test
npm install
npm run build # tsc → dist/
npm test
Node 22.12 or later is supported, as engines says. CI builds and tests on
Linux with 22.12.0, 24 and the newest Node, and builds on macOS with 22.12.0
and the newest.
License
MIT — see LICENSE.