Mnemonica运行时类型热替换MCP服务

为运行中的Mnemonica运行时提供实时调试与类型管理能力,通过CDP连接目标进程并注入WebSocket构造通道,使AI代理无需重启服务即可检查类型树、定义新类型、构造实例和热替换构造函数,适用于不停机调试、在线修复以及Tactica类型校验与对比。

  • by mythographica
  • JavaScript
  • 本地部署
  • 更新于
在 GitHub 查看 mythographica/strategy

使用说明

@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:

  1. Your app runs as usual (a debug-enabled child copy via infer-debug works too).
  2. Strategy attaches over CDP — once. CDP is the delivery truck, not the road.
  3. A dependency-free WebSocket server is injected into the runtime; all construction traffic (define / instantiate / swap) moves there.
  4. New types are born shimmed: their constructor is a stable shell whose handler lives in the session's closure, so ws_swap can replace the implementation in flight — existing constructors and instances are untouched.
  5. When a shape is proven, Tactica crystallizes it into .tactica type 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 execute
  • message (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 context
  • command (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=N suffix, so DevTools lists each reload separately and the embedded map still shows the TS.
  • During that evaluation define is 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.exports object 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 destructured const { fn } = require(...) bindings keep the old function — that is what liveEdit is 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)

  1. Watch the failing request in the secondary (marked requests, traces).
  2. At the failing code, read the scope — the REAL third-party data.
  3. Edit the source locally, run tsc, deliver through the channel: mnemonica types go through patch — or reload when the type lives in a reloaded module; plain code goes through reload; liveEdit only when stale destructured bindings must be reached. No redeployment.
  4. Replay the request until the reply is right.
  5. 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

  1. 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
    
  2. Connect to the debugger:

    execute {
      context: "RPC",
      command: "rpc_connection",
      message: "{ \"action\": \"connect\" }"
    }
    
  3. Analyze runtime types:

    execute {
      context: "RPC",
      command: "rpc_analyze_type_hierarchy",
      message: "{}"
    }
    
  4. 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_swap refuses any type not born via ws_define in this session — pre-existing types are never re-defined.
  • Subtypes construct from parent instances: for nested paths, ws_instantiate walks the chain, taking intermediate constructor args from chainArgs (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:

  1. MCP command reads the script file
  2. (create-type only) injects var args = {...} at the top with command arguments
  3. Sends it to the target via client.Runtime.evaluate({ expression: script, awaitPromise: true })
  4. Script executes inside the target process
  5. 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 in cdp-scripts/ with the canonical prelude.
  • ctx.require resolves relative paths from lib/server.js, not from your command file — require lib modules by absolute path (path.join(__dirname, '../../lib/...')), the same idiom used for cdp-scripts/.

License

MIT