使用说明
@mnemonica/strategy
MCP (Model Context Protocol) server that lets an AI agent work on a running Mnemonica runtime — inspect its type tree, define new types, construct instances, and swap constructor handlers in flight — without stopping the server you are developing.
Overview
Strategy is the live bridge between a running Mnemonica runtime and the
tools around it. It attaches to a target Node.js process via the Chrome
Debug Protocol (CDP) — zero instrumentation of the target — and then moves
the real work onto a fast WebSocket construction channel injected into the
process. It is the central point of the topology: agents drive it over MCP;
Mnemographica connects as
the monitoring client — through Strategy's own trace channel, or DIRECTLY to
an app's self-hosted channel (startStrategyClient + traceSubscribe, the
App Channel tab — no CDP in between).
The development loop this enables — the main server never stops:
- Your app runs as usual (a debug-enabled child copy via infer-debug works too).
- Strategy attaches over CDP — once. CDP is the delivery truck, not the road.
- A dependency-free WebSocket server is injected into the runtime; all
construction traffic (
define/instantiate/swap) moves there. - New types are born shimmed: their constructor is a stable shell whose
handler lives in the session's closure, so
ws_swapcan replace the implementation in flight — existing constructors and instances are untouched. - When a shape is proven, Tactica
crystallizes it into
.tacticatype definitions.
Strategy can also compare the runtime type tree against Tactica-generated types to validate static analysis — its original purpose, still available.
Installation
npm install @mnemonica/strategy
From source instead:
git clone https://github.com/mythographica/strategy.git
cd strategy
npm install
npm run build
Usage
Prerequisites
Your target application must be running with the debug flag:
# For NestJS
nest start --debug --watch
# For regular Node.js
node --inspect=9229 your-app.js
Don't want --inspect on the main process? infer-debug
can spawn a debug-enabled child copy of the app on demand and tunnel CDP
through the app's own HTTP port — strategy attaches to that child the same
way.
As MCP Server
# installed from npm
npx @mnemonica/strategy
# from a source checkout
node /path/to/strategy/lib/cli.js
MCP Configuration
Add to your agent framework's MCP config:
{
"mcpServers": {
"mnemonica-strategy": {
"command": "npx",
"args": ["-y", "@mnemonica/strategy"]
}
}
}
or, from a source checkout:
{
"mcpServers": {
"mnemonica-strategy": {
"command": "node",
"args": ["/path/to/strategy/lib/cli.js"]
}
}
}
MCP Tools Provided
The Strategy MCP server exposes 3 bundled tools:
1. execute
Execute any command from the 3 context folders (MCP, RPC, RUN).
Input:
context(string, required): Execution context - "MCP", "RPC", or "RUN"command(string, required): Command name to executemessage(string, optional): JSON string containing command arguments
Example:
// Connect to Node.js debugger
execute {
context: "RPC",
command: "rpc_connection",
message: "{ \"action\": \"connect\", \"host\": \"localhost\", \"port\": 9229 }"
}
// Check connection status
execute {
context: "RPC",
command: "rpc_connection",
message: "{ \"action\": \"status\" }"
}
// Analyze the runtime type hierarchy
execute {
context: "RPC",
command: "rpc_analyze_type_hierarchy",
message: "{}"
}
2. list
List available commands by context.
Input:
context(string, required): "MCP", "RPC", "RUN", or "ALL"
Example:
list { context: "ALL" }
3. help
Get detailed help for any command.
Input:
context(string, required): Command contextcommand(string, required): Command name
Example:
help { context: "RPC", command: "rpc_connection" }
Args Passing Mechanism (IMPORTANT)
Due to MCP protocol limitations, command arguments must be passed as a JSON string in the message field, not as direct object properties.
Correct format:
execute {
context: "RPC",
command: "rpc_connection",
message: "{ \"action\": \"connect\", \"host\": \"localhost\", \"port\": 9229 }"
}
Incorrect format (will not work):
// DON'T DO THIS
execute {
context: "RPC",
command: "rpc_connection",
args: { action: "connect" } // This won't work!
}
Common Commands
Connection Management
// Connect to Node.js debugger
execute {
context: "RPC",
command: "rpc_connection",
message: "{ \"action\": \"connect\", \"host\": \"localhost\", \"port\": 9229 }"
}
// Check connection status
execute {
context: "RPC",
command: "rpc_connection",
message: "{ \"action\": \"status\" }"
}
// Disconnect from runtime
execute {
context: "RPC",
command: "rpc_connection",
message: "{ \"action\": \"disconnect\" }"
}
Type Analysis
// Analyze the complete type hierarchy (recursive subtype tree from the target)
execute {
context: "RPC",
command: "rpc_analyze_type_hierarchy",
message: "{}"
}
// Create type in the target runtime via CDP
execute {
context: "RPC",
command: "rpc_create_type",
message: "{ \"typeName\": \"MyType\" }"
}
// Load Tactica-generated types
execute {
context: "MCP",
command: "mcp_load_remote_tactica_types",
message: "{ \"projectPath\": \"/path/to/project\" }"
}
// Compare runtime vs Tactica types
execute {
context: "MCP",
command: "mcp_compare_with_tactica",
message: "{ \"projectPath\": \"/path/to/project\" }"
}
Live development on a running app
Strategy can change a running app in flight — no restart, no redeploy. Two modes share one mechanism: the app (or its debug child) self-hosts the channel, and every change reaches the type registry or the module system behind the same constructor and module identities the app already holds.
The channel as a path on the app's own server
startStrategyClient({ server, path, role, attach? }) mounts the channel
on an EXISTING http.Server as a WebSocket upgrade path (default
/strategy) — no separate port. Two roles split the surface:
| role | serves | refuses |
|---|---|---|
observer (the main process) |
traces (traceSubscribe), reads (ping, list, patched) |
every write op — patch, rollback, reload, liveEdit, eval, swap, define, instantiate — with a readable error |
debug (the secondary) |
the hot-swap tools: patch, rollback, patched, reload, liveEdit, eval |
trace ops |
Omit role for the full surface (development default). attach: false
skips self-attaching and returns handle.upgradeHandler for the app to
hand to its own upgrade router (see infer-debug below). With no options at
all the channel keeps its standalone listener for apps without a router.
Usage with infer-debug
With infer-debug, one pod exposes one port, and infer-debug is the single upgrade decision point on the app's server:
const core = new InferDebugCore({
childPortEnvVar : 'APP_PORT',
wsRelay : ['/strategy'],
appUpgradeHandler : (req, socket) => strategyUpgrade(req, socket),
});
// …
const channel = await startStrategyClient({
server : httpServer,
path : '/strategy',
role : isMain ? 'observer' : 'debug',
attach : false,
});
strategyUpgrade = channel.upgradeHandler;
core.attachServer(httpServer);
Routing: the uuid-shaped inspector path tunnels to the secondary's
inspector (DevTools); an upgrade carrying the infer-debug header on a
wsRelay path is relayed wholesale to the secondary (header consumed); an
upgrade carrying the header on any other path is served by the main app
with one log line; unmarked upgrades go to the main app. Marked relay
upgrades get a clean 503 while the secondary is down. The agent's client
sends the mark on its own connection:
const session = await WSSession.connect(host, port, token, '/strategy', {
'infer-debug' : '1',
});
Mode 1 — live patch session (mnemonica types)
patch {path, body} replaces the construct handler of an EXISTING type
in place: the constructor identity never changes — captured references and
previously built instances keep working, the next construction runs the
new handler. rollback {path} pops one step (two patches, one rollback →
the first patch is active again); rollback {path, all:true} or
{all:true} restores the original exactly; patched lists paths with
stack depth and sources. Every patch gets an automatic, unique
//# sourceURL=strategy-patch/<Type>@<n>.js so DevTools lists and
searches each version separately. Unmarked requests never see a patch; the
released behaviour is always one rollback away.
Mode 2 — module reload with compiled JS
Only JS runs: the app is its built package, compiled with
inlineSourceMap + inlineSources so every built file carries its own
map. The agent edits the TypeScript source LOCALLY, compiles that one
module with the app's tsconfig, and sends the result over the channel.
reload {module, code} — primary mechanism:
- The code is evaluated under the module's real filename with a
?v=Nsuffix, so DevTools lists each reload separately and the embedded map still shows the TS. - During that evaluation
defineis intercepted: types the file declares again keep the SAME type object with only the handler swapped (instances,lookup(), subtypes stay connected); new types define normally; a type removed from the file stays declared (types cannot be undeclared). - Hooks are journaled per module: exactly what the file registered before
is removed, what it registers now is kept — two hooks of the same type
both stay, other modules' hooks on the same type survive. Two limits:
the FIRST reload of a module attributes its original hooks by
source-text match against the original script (the original load was
never watched; when the debugger cannot surface the source, originals
linger instead of risking foreign removals — the result says
hookSeeded:false); hooks a module adds to types it does NOT define are not tracked. - Handler swaps are staged and applied only after the module evaluates cleanly: a file that throws leaves everything as it was and consumes no version. The ENTRY module (the process entry point) is refused — that is a full restart. CommonJS only; an ESM module fails with a readable compilation error.
- The old
module.exportsobject is then replaced IN PLACE: in-flight requests finish on the old code, the next request runs the new one. Held references: call sites that read through the exports object (all tsc-compiled imports) see the new code; true destructuredconst { fn } = require(...)bindings keep the old function — that is whatliveEditis for.
liveEdit {module, code} — explicit fallback: Debugger.setScriptSource
edits the running script in place and reaches even those captured
bindings. V8's rules apply: the edit must keep the script's structural
positions (a one-function body or signature change — structural rewrites
are reload's job), and V8 refuses while a request is suspended inside
the old function: the status (BlockedByActiveGenerator) is returned
verbatim — wait for the request to finish and retry. After a reload, the
newest ?v=N script is targeted.
The agent's workflow (target scenario)
- Watch the failing request in the secondary (marked requests, traces).
- At the failing code, read the scope — the REAL third-party data.
- Edit the source locally, run
tsc, deliver through the channel: mnemonica types go throughpatch— orreloadwhen the type lives in a reloaded module; plain code goes throughreload;liveEditonly when stale destructured bindings must be reached. No redeployment. - Replay the request until the reply is right.
- Report "debugged — commit this" with a regression test built from the payload captured at step 2; or, after repeated rounds, report why not and what the scope showed.
Data note. This workflow exposes production data: scopes, traces and captured payloads are real user input. Who may open a session and where captured data may end up is a policy decision for the team running the app; treat every capture as sensitive.
Example Workflow
-
Start your Mnemonica application with debug mode:
# any Mnemonica app, e.g. a NestJS service nest start --debug --watch # or plain Node.js node --inspect=9229 your-app.js -
Connect to the debugger:
execute { context: "RPC", command: "rpc_connection", message: "{ \"action\": \"connect\" }" } -
Analyze runtime types:
execute { context: "RPC", command: "rpc_analyze_type_hierarchy", message: "{}" } -
Compare with Tactica-generated types:
execute { context: "MCP", command: "mcp_compare_with_tactica", message: "{ \"projectPath\": \"/path/to/project\" }" }
The construction channel (ws_ commands)
After rpc_connection is up, one call injects the WS channel into the
target; everything after that is fast WS traffic, not CDP:
// 1. Bootstrap: inject the WS server into the target (one CDP evaluate)
execute { context: "RPC", command: "ws_bootstrap", message: "{}" }
// 2. Define a type — born shimmed (swappable later)
execute {
context: "MCP",
command: "ws_define",
message: "{ \"name\": \"TempProbe\", \"body\": \"function (data) { this.value = data.value; }\" }"
}
// 3. Construct an instance of it, right now, in the running process
execute {
context: "MCP",
command: "ws_instantiate",
message: "{ \"path\": \"TempProbe\", \"args\": [{ \"value\": 42 }] }"
}
// → { chain: ["Mnemonica", "TempProbe"], props: { value: 42 } }
// 4. Swap the handler in flight — the constructor identity never changes
execute {
context: "MCP",
command: "ws_swap",
message: "{ \"path\": \"TempProbe\", \"body\": \"function (data) { this.value = data.value * 2; }\" }"
}
// 5. Next instance uses the NEW implementation
execute {
context: "MCP",
command: "ws_instantiate",
message: "{ \"path\": \"TempProbe\", \"args\": [{ \"value\": 42 }] }"
}
// → { props: { value: 84 } }
// Session state: which types are shimmed/swappable
execute { context: "MCP", command: "ws_session", message: "{ \"action\": \"list\" }" }
Rules the channel enforces (they are mnemonica semantics, not policy):
ws_swaprefuses any type not born viaws_definein this session — pre-existing types are never re-defined.- Subtypes construct from parent instances: for nested paths,
ws_instantiatewalks the chain, taking intermediate constructor args fromchainArgs(e.g.{ "TempProbe": [{ "value": 7 }] }). - Async constructor handlers must
return this(mnemonica enforces this).
The in-target server is development-only instrumentation: it binds
127.0.0.1, requires a per-session token at the WebSocket handshake, and
disappears with the process. Do not expose it on production runtimes.
Command Contexts
| Context | Folder | Execution Environment |
|---|---|---|
| MCP | commands-mcp/ |
Local MCP server process |
| RPC | commands-rpc/ |
Local orchestration; effects in the target via CDP |
| RUN | commands-run/ |
Local side effects (files, utilities) |
Command names carry their site as a prefix (mcp_, rpc_, run_), so
the place of execution is visible in the name itself. The ws_ prefix
marks the construction channel: ws_bootstrap lives in commands-rpc/
(it needs CDP to get in), every other ws_* command lives in
commands-mcp/ and talks to the stored WS session.
How strategy's own types are preserved
Strategy's live state (runtime, command contexts, connections, channels)
is typed as mnemonica types in src/strategy-types.ts, using builder
mode on the default collection: a mnemonica.define(...) chain whose
value carries a LOCAL registry derived from the handlers. The exported
API is the builder value (StrategyTypes) plus the looked-up
constructors (StrategyTypes.lookup('StrategyRuntime') and the
children) — never the raw define() results — so declaration emit
stays portable on TypeScript 6 (mnemonica >= 1.3.6; see mnemonica
docs/typed-lookup.md, "Declaration emit on TypeScript 6").
hand-written TypeRegistry merge |
builder mode (this repo) | |
|---|---|---|
| registry scope | global (module augmentation) | local, carried by the exported value |
| derived from | hand-written declarations | the handlers in this file |
| drift | can drift from the handlers | cannot drift |
| code change | minimal (one merge block) | chain value + lookups |
Development
# Install dependencies
npm install
# Build
npm run build
# Watch mode
npm run watch
# Test
npm run test
CDP Scripts Architecture
The cdp-scripts/ folder contains scripts that execute inside the target Node.js runtime via Chrome Debug Protocol:
cdp-scripts/
├── create-type.js # Creates mnemonica types in the target
├── analyze-hierarchy.js # Retrieves complete type hierarchy
└── ws-server.js # Phase 3: the injected WS construction server
How it works:
- MCP command reads the script file
- (create-type only) injects
var args = {...}at the top with command arguments - Sends it to the target via
client.Runtime.evaluate({ expression: script, awaitPromise: true }) - Script executes inside the target process
- Return value is sent back to the MCP process
Key pattern — the canonical prelude. Scripts must never use a bare
require (there is none in evaluated code) and never rely on
process.mainModule.require alone (it is undefined in ESM-entry
processes, and import() in evaluated code throws
ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING). Every cdp-script loads the
target's own mnemonica through this exact three-tier prelude:
var mnemonica;
if (process.mainModule && process.mainModule.require) {
mnemonica = process.mainModule.require('mnemonica');
} else if (typeof process.getBuiltinModule === 'function') {
var nodeModule = process.getBuiltinModule('node:module');
var cwdRequire = nodeModule.createRequire(process.cwd() + '/__strategy_cwd__.js');
mnemonica = cwdRequire('mnemonica');
} else {
var mnemonicaNs = await import('mnemonica');
mnemonica = mnemonicaNs.default || mnemonicaNs;
}
Scripts using it must be async IIFEs. ws-server.js generalizes the same
three tiers into a targetRequire factory because it also needs
node:http/node:crypto.
// Access types via the defaultCollection Map (avoids proxy enumeration issues)
mnemonica.defaultCollection.forEach(function (Type, name) {
// Process each type
});
// Recursive traversal for subtype hierarchy
function getSubtypes (Type) {
var subtypes = [];
Type.subtypes.forEach(function (SubType, name) {
subtypes.push({
name: name,
subtypes: getSubtypes(SubType) // Recursive
});
});
return subtypes;
}
Creating Commands
Commands are JavaScript files in the commands-*/ folders with MCP Tool Metadata.
Two shapes are supported; module.exports.run is the pattern current
commands use:
/**
* MCP Tool Metadata:
* {
* "name": "mcp_my_command",
* "description": "What this command does",
* "inputSchema": {
* "type": "object",
* "properties": {
* "argName": { "type": "string" }
* }
* }
* }
*/
async function run (ctx) {
const { require, args, store } = ctx;
// Parse message if present (args arrive as a JSON string in `message`)
let commandArgs = args;
if (args.message && typeof args.message === 'string') {
try {
commandArgs = JSON.parse(args.message);
} catch (e) {
return { success: false, error: 'Invalid JSON: ' + e.message };
}
}
return { success: true, data: { got: commandArgs.argName } };
}
module.exports = { run };
Files without a run export are instead wrapped in an async IIFE with
ctx in scope. Two gotchas, both learned the hard way:
- Never
ctx.require('mnemonica')— that resolves in the MCP process, not the target. Target-side mnemonica work belongs incdp-scripts/with the canonical prelude. ctx.requireresolves relative paths fromlib/server.js, not from your command file — require lib modules by absolute path (path.join(__dirname, '../../lib/...')), the same idiom used forcdp-scripts/.
License
MIT
