Skip to content

Add Vortex extension scaffold + MCP stdio client (Unit E) - #15

Merged
TheValiantOne merged 1 commit into
mainfrom
feature/vortex-extension-scaffold
Aug 9, 2026
Merged

Add Vortex extension scaffold + MCP stdio client (Unit E)#15
TheValiantOne merged 1 commit into
mainfrom
feature/vortex-extension-scaffold

Conversation

@TheValiantOne

Copy link
Copy Markdown
Owner

Summary

Foundation scaffold for a Vortex (Nexus Mods) companion extension for WitcherScriptMerger, plus the shared MCP stdio client later units (tool acquisition, conflict scanning, merge panel, dashlets) will build on. This unit is scaffold + plumbing only — no actual Vortex-facing features are registered here.

  • New top-level vortex-extension/ folder: an entirely separate TypeScript/Node toolchain (npm + webpack/ts-loader + eslint flat config + vitest), fully isolated from WitcherScriptMerger.sln. Confirmed via dotnet build/dotnet format whitespace --verify-no-changes before and after this change — identical (5 pre-existing CA1823 warnings, 0 errors; format check clean).
  • src/index.ts — the extension entry point. Does only game-activity gating (context.once(...), logs whether Witcher 3 is active) — no registerAction/registerGame/etc. calls, per this unit's scope. Never calls context.registerGame('witcher3', ...) — Vortex's own built-in game-witcher3 extension already owns that registration.
  • src/gating.ts — small shared helper (isWitcher3Active, WITCHER3_GAME_ID) every later unit's own registrations should gate on.
  • src/mcpClient.ts — the hand-rolled MCP stdio client (this unit's main deliverable).
  • test/mcpClient.integration.test.ts — a real, no-mocks integration test that spawns the actual compiled WitcherScriptMerger.Headless.exe and drives a full initialize -> tools/list -> tools/call round trip.

Why a hand-rolled MCP client

Verified against @nexusmods/vortex-api's actual published lib/api.d.ts (not assumed): IRunOptions (the type of runExecutable's third argument) is { cwd?, env?, suggestDeploy?, shell?, detach?, expectSuccess?, onSpawned?, onExit? } — no stdio/pipe access at all, so it cannot carry MCP's JSON-RPC frames. src/mcpClient.ts instead uses raw child_process.spawn(exePath, ['mcp'], { stdio: 'pipe' }) and hand-rolls the framing: MCP's stdio transport is newline-delimited JSON-RPC 2.0 (confirmed against the current MCP spec — not LSP-style Content-Length framing).

mcpClient.ts API surface (for later units)

class WsmMcpClient {
  static connect(options: WsmMcpClientOptions): Promise<WsmMcpClient>;
  listTools(): Promise<McpToolDescriptor[]>;
  callTool<T = unknown>(name: string, args?: Record<string, unknown>): Promise<T>;

  // Typed convenience wrappers, one per WSM MCP tool:
  scanConflicts(): Promise<ScanConflictsResult>;
  mergeConflicts(args?: MergeConflictsArgs): Promise<MergeConflictsResult>;
  getStatus(): Promise<GetStatusResult>;
  listMerges(): Promise<ListMergesResult>;

  close(): Promise<void>;
}

interface WsmMcpClientOptions {
  exePath: string;        // absolute path to a WSM exe (.exe or .Headless.exe) capable of `mcp` mode
  args?: string[];         // default ['mcp']
  cwd?: string;
  env?: NodeJS.ProcessEnv;
  requestTimeoutMs?: number;
}

Plus ScanConflictsResult, MergeConflictsArgs/MergeConflictsResult, GetStatusResult, ListMergesResult (and their nested types), transcribed 1:1 from WitcherScriptMerger.Core/Mcp/WsmMcpTools.cs's actual anonymous-object shapes — and WsmMcpProcessError/WsmMcpToolError for error handling.

Process lifecycle policy (documented in the file's header comment): spawn per user-initiated workflow, close() when done — not a long-lived singleton. Every WSM MCP tool call already re-scans/re-loads state server-side, so a persistent process would only save the handshake cost, not worth the added crash/restart/orphan-process bookkeeping for v1.

mcpClient.ts has zero dependency on vortex-api — pure Node child_process, independently testable and reusable regardless of which Vortex UI surface a later unit builds.

Notable findings from actually running this (not assumed)

  • The @nexusmods/vortex-api npm package is types-only (its package.json exports map has only a "types" condition, no runtime target). Real extension code imports from the bare specifier 'vortex-api' (confirmed in the package's own README and in a real third-party extension), which Vortex's own loader injects at runtime. tsconfig.json's paths alias and webpack.config.cjs's externals (both documented in-file) exist specifically to bridge this.
  • Deviation from the literal task brief, called out explicitly: @nexusmods/vortex-api is listed under devDependencies, not dependencies — it contributes zero runtime code (marked external, never bundled) and exists purely for types + the peer-dependency list used to build the webpack externals set. This matches real precedent found during research (the package's own docs/MIGRATION.md, and a real hand-written third-party extension's package.json).
  • A raw protocol probe against the real, compiled server confirmed the actual tools/call result shape: { content: [{ type: "text", text: "<json>" }] }, with no structuredContent field at all, and isError simply omitted when false. callTool()'s dual-path handling (prefer structuredContent, fall back to parsing content[0].text) was written defensively before this was known; the fallback path is the one actually exercised against WSM's current ModelContextProtocol SDK version.
  • Entry point is confirmed to be init(context), not activate(context) — the not-yet-merged chore/vortex-extension-design-doc branch's design doc gets this wrong; this PR follows the verified name.
  • npm install needed an explicit overrides (pinning react/react-dom to 16.14.0, matching @nexusmods/vortex-api's own peer pin) rather than --legacy-peer-deps — the latter was tried first and produced a genuinely broken ajv/ajv-keywords resolution (ts-loader/webpack failed with Cannot find module 'ajv/dist/compile/codegen') that a strict, override-guided resolution avoids.

Code review

Ran the code-review skill against this diff; three real findings were fixed:

  1. child.stdin had no 'error' listener — a write racing an already-exited WSM process (e.g. Environment.Exit(1) on missing App.config) could crash the host process (Vortex) with an unhandled stream error instead of surfacing as a rejected promise. Fixed with both an 'error' listener and a try/catch around the write itself.
  2. The integration test's scratch App.config builder escaped backslashes (not XML-special) but not actual XML metacharacters (&, <, >, ", ') in the interpolated mods-directory path. Fixed with a proper escapeXmlAttribute helper.
  3. npm test ran both the fast unit test and the slow, .NET-toolchain-dependent integration test together, which would hard-fail on a Node-only machine/CI runner with no dotnet on PATH. Split into npm test (fast, Node-only, src/**/*.test.ts) and npm run test:integration (the real spawned-process test, builds WitcherScriptMerger.Headless itself if needed).

Test plan

  • cd vortex-extension && npm install && npm run build — typecheck + webpack bundle succeed.
  • npm run lint — clean.
  • npm test — 3/3 fast unit tests pass (no .NET SDK needed).
  • npm run test:integration — 2/2 tests pass, exercising the real initialize -> tools/list -> tools/call round trip against the actual compiled WitcherScriptMerger.Headless.exe (get_status, scan_conflicts, list_merges against a scratch empty mods folder).
  • dotnet build WitcherScriptMerger.sln and dotnet format whitespace WitcherScriptMerger.sln --verify-no-changes from the repo root — unaffected, identical to before this change.

AI-assisted development disclosure

This PR was substantially produced by Claude Code (an AI coding agent), per this repo's CONTRIBUTING.md disclosure convention. Commits carry Co-Authored-By/Claude-Session trailers.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah

New vortex-extension/ top-level folder: a separate TypeScript/Node toolchain
(package.json, tsconfig strict mode, webpack+ts-loader build, eslint flat
config, vitest) fully isolated from WitcherScriptMerger.sln - confirmed via
dotnet build/dotnet format whitespace before and after, unchanged.

- src/index.ts: the init(context) entry point (verified against the real
  @nexusmods/vortex-api 2.4.2 typings, not the activate(context) name an
  earlier, unmerged design doc assumed) - gating only, no feature
  registration, per this unit's scope.
- src/gating.ts: shared isWitcher3Active() helper + WITCHER3_GAME_ID, for
  every later unit's own registrations to gate on.
- src/mcpClient.ts: hand-rolled MCP stdio client (child_process.spawn +
  newline-delimited JSON-RPC 2.0 framing) - api.runExecutable's IRunOptions
  has no stdio/pipe access, confirmed against the published typings, so it
  can't carry MCP's frames. Spawn-per-workflow lifecycle, typed wrappers for
  all four WSM MCP tools (scan_conflicts/merge_conflicts/get_status/
  list_merges), no dependency on vortex-api itself (pure Node, independently
  testable).
- test/mcpClient.integration.test.ts: real, no-mocks integration test -
  spawns the actual compiled WitcherScriptMerger.Headless.exe in mcp mode
  against a scratch mods folder and drives a full initialize -> tools/list ->
  tools/call round trip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
@TheValiantOne
TheValiantOne merged commit ff93280 into main Aug 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant