From d0cacca04d24faa7f367e367b38b3cfee800c81c Mon Sep 17 00:00:00 2001 From: ci Date: Tue, 4 Aug 2026 09:26:57 +0700 Subject: [PATCH 1/2] fix(mcp): restore tools/list, broken for every client since 2.2.0 tools/list answered -32603 "Cannot read properties of undefined (reading '_zod')" instead of returning the catalog, so Claude Code, Codex, Cursor and Wayland all saw zero tools. The CLI was unaffected. strategy_sweep declared `inputs` as a one-argument z.record(). That is valid Zod 3 but invalid Zod 4, which requires an explicit key type; the one-argument form leaves the value type undefined and the SDK's schema conversion dereferences `_zod` on it. Nothing in the 512-test suite touches that conversion, because it happens only inside the SDK's tools/list handler and the CLI never calls it. The deeper cause is that zod was imported in 17 files and declared in none. It resolved transitively through @modelcontextprotocol/sdk, so the SDK's own range picked the major version. Until 2026-07-16 the SDK pinned zod ^3.23.8 and nested its own copy, and tools/list worked -- verified by rebuilding that tree from the lockfile, where the unfixed code publishes all 82 tools. SDK 1.29.0 widened to "^3.25 || ^4.0", Zod 4 hoisted, and the same source stopped working with no change here. Declaring zod at 4.3.6 stops a dependency's range from choosing our schema library again. tests/mcp_stdio.test.js is the first test that speaks MCP: it drives initialize and tools/list over real stdio from a bare environment and a foreign working directory, derives the expected tool count from the catalog rather than hardcoding it, and asserts every published tool converts to a usable JSON Schema. All five assertions fail against the unfixed source and pass against this one. --- CHANGELOG.md | 11 +++ package-lock.json | 7 +- package.json | 5 +- src/server.js | 2 +- src/tools/sweep.js | 2 +- tests/mcp_stdio.test.js | 166 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 tests/mcp_stdio.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index bc5e8d4..96952b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to TVControl are documented here. This project follows [Semantic Versioning](https://semver.org/). +## [2.2.1] - 2026-08-04 + +### Fixed + +- `tools/list` no longer fails, so MCP clients see the full tool catalog again. In 2.2.0 the request answered `-32603 Cannot read properties of undefined (reading '_zod')` and every host — Claude Code, Codex, Cursor, Wayland — reported zero tools. The cause was a one-argument `z.record()` in `strategy_sweep`: valid under Zod 3, invalid under Zod 4, which requires an explicit key type. The CLI was never affected. +- `zod` is now a declared dependency pinned to `4.3.6`. It was previously imported in 17 source files but resolved transitively through the MCP SDK, so the SDK's own dependency range decided which major version TVControl ran against — which is how a Zod major landed without a TVControl change. + +### Added + +- `tests/mcp_stdio.test.js` drives `initialize` and `tools/list` against `src/server.js` over real stdio, with an empty environment and a foreign working directory, and checks that every published tool converts to a usable JSON Schema. No previous test spoke MCP: the CLI and core paths never perform schema conversion, so the entire offline suite passed while the MCP server was unusable. + ## [2.2.0] - 2026-07-15 ### Added diff --git a/package-lock.json b/package-lock.json index 291691f..c34b578 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "@ferroxlabs/tvcontrol", - "version": "2.2.0", + "version": "2.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ferroxlabs/tvcontrol", - "version": "2.2.0", + "version": "2.2.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", - "chrome-remote-interface": "0.34.0" + "chrome-remote-interface": "0.34.0", + "zod": "4.3.6" }, "bin": { "tv": "src/cli/index.js", diff --git a/package.json b/package.json index 4e5f808..4dc1482 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ferroxlabs/tvcontrol", - "version": "2.2.0", + "version": "2.2.1", "description": "AI remote control for TradingView Desktop — 102 MCP tools driving symbols, indicators, Pine Script, snapshots, sweeps, diagnostics, and live chart vision over CDP.", "type": "module", "license": "MIT", @@ -64,7 +64,8 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", - "chrome-remote-interface": "0.34.0" + "chrome-remote-interface": "0.34.0", + "zod": "4.3.6" }, "devDependencies": { "eslint": "9.39.4" diff --git a/src/server.js b/src/server.js index 728aceb..a214830 100755 --- a/src/server.js +++ b/src/server.js @@ -26,7 +26,7 @@ import { registerSweepTools } from './tools/sweep.js'; const server = new McpServer( { name: 'tvcontrol', - version: '2.2.0', + version: '2.2.1', description: 'AI remote control for TradingView Desktop — 102 MCP tools driving symbols, indicators, Pine Script, snapshots, sweeps, diagnostics, and live chart vision over CDP.', }, { diff --git a/src/tools/sweep.js b/src/tools/sweep.js index 190f209..afaffad 100755 --- a/src/tools/sweep.js +++ b/src/tools/sweep.js @@ -10,7 +10,7 @@ export function registerSweepTools(server) { server.tool('strategy_sweep', 'Iterate a strategy across symbols × timeframes × indicator input combinations', { symbols: z.array(z.string()).min(1).describe('Symbols to sweep (e.g. ["ES1!", "NQ1!"])'), timeframes: z.array(z.string()).min(1).describe('Timeframes to sweep (e.g. ["15", "60"])'), - inputs: z.record(z.array(z.union([z.string(), z.number()]))).optional() + inputs: z.record(z.string(), z.array(z.union([z.string(), z.number()]))).optional() .describe('Input variations: { length: [20, 50], source: ["close", "hl2"] }'), entity_id: z.string().describe('Strategy study entity ID (from chart_get_state)'), max_combinations: z.coerce.number().int().min(1).max(500).optional() diff --git a/tests/mcp_stdio.test.js b/tests/mcp_stdio.test.js new file mode 100644 index 0000000..77488f0 --- /dev/null +++ b/tests/mcp_stdio.test.js @@ -0,0 +1,166 @@ +/** + * MCP protocol tests — speaks JSON-RPC to src/server.js over real stdio. + * + * Every other test in this suite exercises the CLI or the core modules + * directly, and neither path ever converts a tool's Zod schema to JSON Schema. + * That conversion happens only inside the MCP SDK's `tools/list` handler, so a + * schema the SDK cannot convert takes down every MCP client while 512 offline + * tests stay green — which is exactly what shipped in 2.2.0: a one-argument + * `z.record()` (valid in Zod v3, invalid in v4) made `tools/list` answer + * `-32603 Cannot read properties of undefined (reading '_zod')` and every + * host — Claude Code, Codex, Cursor, Wayland — saw zero tools. + * + * These tests are the only ones that would have caught it. + * + * Run: node --test tests/mcp_stdio.test.js + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, execFileSync } from 'node:child_process'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SERVER = join(__dirname, '..', 'src', 'server.js'); +const COUNT_SCRIPT = join(__dirname, '..', 'scripts', 'count_tools.js'); + +const TIMEOUT_MS = 30_000; + +/** + * The environment an MCP host actually leaves a server with: nothing on POSIX, + * and on Windows only the variables the OS itself needs — without USERPROFILE, + * `os.homedir()` has nothing to resolve and the `~/.tv-mcp` directory the + * connection module creates at import time lands somewhere unwritable. + */ +const BARE_ENV = process.platform === 'win32' + ? Object.fromEntries( + ['SystemRoot', 'SYSTEMROOT', 'USERPROFILE', 'TEMP', 'TMP'] + .filter((key) => process.env[key]) + .map((key) => [key, process.env[key]]) + ) + : {}; + +/** + * Drive one initialize -> initialized -> tools/list exchange and resolve with + * the raw JSON-RPC response to `tools/list`. + * + * Spawned bare, from the temp directory, on purpose: MCP hosts launch servers + * detached from the user's shell, so anything the server needs from PATH, the + * working directory, or an inherited variable has to fail here rather than in + * someone's editor. + */ +function toolsList({ env = {} } = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [SERVER], { + cwd: tmpdir(), + env: { ...BARE_ENV, ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + let settled = false; + + const timer = setTimeout(() => { + finish(new Error(`no tools/list response within ${TIMEOUT_MS}ms; stderr: ${stderr.slice(0, 500)}`)); + }, TIMEOUT_MS); + + function finish(err, value) { + if (settled) return; + settled = true; + clearTimeout(timer); + child.kill('SIGKILL'); + err ? reject(err) : resolve(value); + } + + function send(msg) { child.stdin.write(`${JSON.stringify(msg)}\n`); } + + child.on('error', finish); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.stdout.on('data', (chunk) => { + stdout += chunk; + let cut; + while ((cut = stdout.indexOf('\n')) >= 0) { + const line = stdout.slice(0, cut).trim(); + stdout = stdout.slice(cut + 1); + if (!line) continue; + + let msg; + try { msg = JSON.parse(line); } catch { continue; } // not framing we own + if (msg.id === 1) { + send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + } else if (msg.id === 2) { + finish(null, msg); + } + } + }); + + send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'tvcontrol-tests', version: '1.0.0' }, + }, + }); + }); +} + +describe('MCP server over stdio', () => { + it('answers tools/list without an error', async () => { + const response = await toolsList(); + assert.equal(response.error, undefined, `tools/list failed: ${JSON.stringify(response.error)}`); + assert.ok(Array.isArray(response.result?.tools), 'tools/list returned no tools array'); + }); + + it('publishes the whole catalog minus the tools gated off by default', async () => { + // The catalog is counted by regex over src/tools/; ui_evaluate is the one + // tool that stays unregistered without TV_MCP_ADVANCED=1. Deriving the + // expected number keeps this honest when tools are added or removed -- + // a hardcoded 101 would pass while half the catalog silently vanished. + const { total } = JSON.parse(execFileSync(process.execPath, [COUNT_SCRIPT], { encoding: 'utf8' })); + const response = await toolsList(); + assert.equal(response.result.tools.length, total - 1, 'published tool count does not match the catalog'); + + const names = response.result.tools.map((t) => t.name); + assert.ok(!names.includes('ui_evaluate'), 'ui_evaluate must stay gated behind TV_MCP_ADVANCED=1'); + }); + + it('registers ui_evaluate only when TV_MCP_ADVANCED=1', async () => { + // Negative control for the assertion above: without it, a server that + // published nothing at all would satisfy "ui_evaluate is absent". + const response = await toolsList({ env: { TV_MCP_ADVANCED: '1' } }); + const names = response.result.tools.map((t) => t.name); + assert.ok(names.includes('ui_evaluate'), 'TV_MCP_ADVANCED=1 did not register ui_evaluate'); + }); + + it('converts every tool schema to a usable JSON Schema', async () => { + // The Zod-to-JSON-Schema conversion is the step that broke. A tool whose + // schema degrades to `{}` or loses its properties is still counted above + // but is unusable by a model, so check the shape rather than the count. + const response = await toolsList(); + const broken = response.result.tools.filter( + (t) => !t.inputSchema || t.inputSchema.type !== 'object' || typeof t.inputSchema.properties !== 'object' + ); + assert.deepEqual(broken.map((t) => t.name), [], 'tools with an unusable inputSchema'); + }); + + it('keeps the schema for strategy_sweep, the tool whose z.record broke 2.2.0', async () => { + const response = await toolsList(); + const sweep = response.result.tools.find((t) => t.name === 'strategy_sweep'); + assert.ok(sweep, 'strategy_sweep missing from tools/list'); + + const inputs = sweep.inputSchema.properties?.inputs; + assert.ok(inputs, 'strategy_sweep lost its `inputs` property'); + // z.record(z.string(), z.array(...)) must survive as an object with typed + // values. The one-arg form left the value type undefined, which is what + // made the SDK throw on `_zod`. + assert.equal(inputs.type, 'object', '`inputs` did not convert to an object schema'); + assert.equal(inputs.additionalProperties?.type, 'array', '`inputs` lost its value type'); + }); +}); From de9141f70fdab9daf27a144fad88e5400cc03b0f Mon Sep 17 00:00:00 2001 From: ci Date: Tue, 4 Aug 2026 10:08:22 +0700 Subject: [PATCH 2/2] build: clear high-severity advisories in the dependency audit CI runs `npm audit --audit-level=high`, which has been failing on main since brace-expansion, fast-uri and ip-address advisories were published against transitive dependencies. Verified pre-existing: the audit exits 1 on unmodified main and on this branch identically. Lockfile-only. No top-level dependency changes and no resolved-version change for zod (4.3.6), the MCP SDK (1.29.0) or chrome-remote-interface (0.34.0) -- deliberately not `--force`, which would pull the SDK to 1.30.0 and repeat the kind of unreviewed SDK jump that broke tools/list. Two moderate advisories remain, both in the SDK's HTTP server stack (hono / @hono/node-server / body-parser). They sit below the CI threshold and are unreachable from this package, which only ever runs the stdio transport. --- package-lock.json | 88 +++++++++++++++++++++++++++++++---------------- 1 file changed, 59 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index c34b578..b5f0f30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -193,9 +193,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -424,20 +424,20 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -447,10 +447,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1041,9 +1054,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -1265,9 +1278,9 @@ } }, "node_modules/hono": { - "version": "4.12.30", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.30.tgz", - "integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1353,9 +1366,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -2068,17 +2081,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/unpipe": {