sublime-mcp 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/http.js ADDED
@@ -0,0 +1,43 @@
1
+ // PROOF: F5 — 2026-08-17. Per-endpoint HTTP timeouts. Quick reads stay at
2
+ // 10s so a hung backend fails fast; batch / install / search / find /
3
+ // eval_python_latest / run_build get 120s because those can legitimately
4
+ // exceed 10s while Sublime is still working.
5
+
6
+ const port = process.platform === 'win32' ? 9500 : 9501;
7
+ export const BASE = process.env.SUBLIME_MCP_BASE ?? `http://127.0.0.1:${port}`;
8
+
9
+ export const DEFAULT_TIMEOUT_MS = 10_000;
10
+ export const SLOW_TIMEOUT_MS = 120_000;
11
+ export const SLOW_ENDPOINTS = new Set([
12
+ '/batch',
13
+ '/install_package',
14
+ '/search_packages',
15
+ '/find_in_files',
16
+ '/eval_python_latest',
17
+ '/run_build',
18
+ ]);
19
+
20
+ export function timeoutMsFor(endpoint) {
21
+ return SLOW_ENDPOINTS.has(endpoint) ? SLOW_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
22
+ }
23
+
24
+ export async function get(endpoint, params = {}) {
25
+ const url = new URL(endpoint, BASE);
26
+ for (const [k, v] of Object.entries(params)) {
27
+ url.searchParams.set(k, String(v));
28
+ }
29
+ const r = await fetch(url, { signal: AbortSignal.timeout(timeoutMsFor(endpoint)) });
30
+ if (!r.ok) throw new Error(`HTTP ${r.status} from ${endpoint}`);
31
+ return r.json();
32
+ }
33
+
34
+ export async function post(endpoint, body = {}) {
35
+ const r = await fetch(new URL(endpoint, BASE), {
36
+ method: 'POST',
37
+ headers: { 'Content-Type': 'application/json' },
38
+ body: JSON.stringify(body),
39
+ signal: AbortSignal.timeout(timeoutMsFor(endpoint)),
40
+ });
41
+ if (!r.ok) throw new Error(`HTTP ${r.status} from ${endpoint}`);
42
+ return r.json();
43
+ }
package/index.js ADDED
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import { BASE, get, post } from './http.js';
6
+
7
+ import { readFileSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { dirname, join } from 'node:path';
10
+
11
+ // Fallback catalog, generated from the backend _MCP_TOOLS. Used only when
12
+ // dynamic /mcp_tools discovery fails.
13
+ const FALLBACK_TOOLS = JSON.parse(
14
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'fallback-tools.json'), 'utf8'),
15
+ ).tools;
16
+
17
+ process.stderr.write(`mcp-commander: BASE=${BASE} platform=${process.platform}\n`);
18
+
19
+ function ok(data) {
20
+ return { content: [{ type: 'text', text: JSON.stringify(data) }] };
21
+ }
22
+
23
+ const server = new McpServer({ name: 'sublime-mcp', version: '1.4.0' });
24
+ server.setToolRequestHandlers();
25
+
26
+ // ── JSON Schema → Zod (shallow) for dynamic discovery ────────────────────────
27
+ // Backend /mcp_tools returns full JSON Schemas; MCP SDK registerTool requires
28
+ // Zod shapes. Convert properties/required/defaults so clients still see real
29
+ // param docs instead of empty passthrough objects.
30
+
31
+ function jsonSchemaPropToZod(prop) {
32
+ if (!prop || typeof prop !== 'object') return z.unknown();
33
+ let t;
34
+ switch (prop.type) {
35
+ case 'string':
36
+ t = z.string();
37
+ break;
38
+ case 'integer':
39
+ t = z.number().int();
40
+ break;
41
+ case 'number':
42
+ t = z.number();
43
+ break;
44
+ case 'boolean':
45
+ t = z.boolean();
46
+ break;
47
+ case 'array':
48
+ t = z.array(prop.items ? jsonSchemaPropToZod(prop.items) : z.unknown());
49
+ break;
50
+ case 'object':
51
+ t = prop.properties ? jsonSchemaToZod(prop) : z.record(z.string(), z.unknown());
52
+ break;
53
+ default:
54
+ t = z.unknown();
55
+ }
56
+ if (prop.description) t = t.describe(prop.description);
57
+ return t;
58
+ }
59
+
60
+ function jsonSchemaToZod(schema) {
61
+ if (!schema || typeof schema !== 'object') return z.object({}).passthrough();
62
+ const props = schema.properties || {};
63
+ const required = new Set(schema.required || []);
64
+ const shape = {};
65
+ for (const [key, prop] of Object.entries(props)) {
66
+ let field = jsonSchemaPropToZod(prop);
67
+ if (!required.has(key)) {
68
+ if (prop && Object.prototype.hasOwnProperty.call(prop, 'default')) {
69
+ field = field.default(prop.default);
70
+ } else {
71
+ field = field.optional();
72
+ }
73
+ } else if (prop && Object.prototype.hasOwnProperty.call(prop, 'default')) {
74
+ field = field.default(prop.default);
75
+ }
76
+ shape[key] = field;
77
+ }
78
+ if (Object.keys(shape).length === 0) return z.object({}).passthrough();
79
+ // Allow extra keys agents sometimes send; backend is the real validator.
80
+ return z.object(shape).passthrough();
81
+ }
82
+
83
+ // ── Dynamic tool discovery from backend ──────────────────────────────────────
84
+
85
+ async function loadDynamicTools() {
86
+ const MAX_RETRIES = 3;
87
+ const RETRY_DELAY_MS = 2000;
88
+
89
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
90
+ try {
91
+ const toolsList = await get('/mcp_tools');
92
+ if (!toolsList?.tools) throw new Error('backend returned no tool list');
93
+
94
+ for (const tool of toolsList.tools) {
95
+ const inputSchema = jsonSchemaToZod(tool.inputSchema);
96
+ server.registerTool(
97
+ tool.name,
98
+ { description: tool.description, inputSchema },
99
+ async (args) => ok(await post('/' + tool.name, args ?? {})),
100
+ );
101
+ }
102
+ process.stderr.write(`mcp-commander: loaded ${toolsList.tools.length} dynamic tools from backend (attempt ${attempt})\n`);
103
+ return true;
104
+ } catch (e) {
105
+ if (attempt === MAX_RETRIES) {
106
+ process.stderr.write(`mcp-commander: dynamic tool discovery failed after ${MAX_RETRIES} attempts: ${e.message}\n`);
107
+ } else {
108
+ process.stderr.write(`mcp-commander: dynamic tool discovery attempt ${attempt} failed, retrying...\n`);
109
+ await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
110
+ }
111
+ }
112
+ }
113
+
114
+ return false;
115
+ }
116
+
117
+ // ── generated fallback catalog ────────────────────────────────────────────────
118
+
119
+ function registerFallbackTools() {
120
+ // GENERATED CATALOG. fallback-tools.json is produced from the backend's
121
+ // _MCP_TOOLS by tools/generate_fallback_catalog.py, so a discovery miss
122
+ // exposes the same tool surface the backend actually serves instead of a
123
+ // hand-maintained subset (F10). Every backend tool has a POST /{name}
124
+ // alias, so one uniform call shape works for all of them.
125
+ for (const tool of FALLBACK_TOOLS) {
126
+ server.registerTool(
127
+ tool.name,
128
+ { description: tool.description, inputSchema: jsonSchemaToZod(tool.inputSchema) },
129
+ async (args) => ok(await post('/' + tool.name, args ?? {})),
130
+ );
131
+ }
132
+ process.stderr.write(`mcp-commander: registered ${FALLBACK_TOOLS.length} generated fallback tools\n`);
133
+ }
134
+
135
+ // ── startup ───────────────────────────────────────────────────────────────────
136
+
137
+ if (!await loadDynamicTools()) {
138
+ process.stderr.write('mcp-commander: dynamic discovery failed, using generated fallback catalog\n');
139
+ registerFallbackTools();
140
+ }
141
+
142
+ const transport = new StdioServerTransport();
143
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "sublime-mcp",
3
+ "version": "1.4.1",
4
+ "description": "MCP server for Sublime Text 4 — exposes editor state and editing tools to AI assistants via the Model Context Protocol.",
5
+ "type": "module",
6
+ "bin": {
7
+ "sublime-mcp": "bin/cli.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/dpc00/sublime-mcp.git"
15
+ },
16
+ "keywords": [
17
+ "sublime-text",
18
+ "mcp",
19
+ "ai",
20
+ "claude"
21
+ ],
22
+ "author": "Donald Chitester",
23
+ "license": "MIT",
24
+ "bugs": {
25
+ "url": "https://github.com/dpc00/sublime-mcp/issues"
26
+ },
27
+ "homepage": "https://github.com/dpc00/sublime-mcp#readme",
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.29.0",
30
+ "zod": "^4.4.3",
31
+ "prompts": "^2.4.2"
32
+ }
33
+ }
package/test_batch.mjs ADDED
@@ -0,0 +1,72 @@
1
+ // Integration test for packages/node-proxy/index.js.
2
+ //
3
+ // Unlike hitting port 9500 or 9502 directly, this launches index.js as a real
4
+ // subprocess and speaks MCP over stdio — the same code path a real MCP client
5
+ // (e.g. Claude Code configured with a stdio server entry) uses. This is what
6
+ // caught the missing `_POST["/batch"]` route on the HTTP bridge: node-proxy's
7
+ // dynamic tool discovery (loadDynamicTools) picks up `batch` from /mcp_tools
8
+ // fine, but the generic passthrough posts to /batch on port 9500, which
9
+ // 404'd until that route was added to sublime_mcp.py.
10
+ //
11
+ // Prerequisites:
12
+ // - Sublime Text running with sublime_mcp.py loaded (HTTP bridge on 9500)
13
+ // - At least one file open in ST
14
+ //
15
+ // Run:
16
+ // cd packages/node-proxy
17
+ // node test_batch.mjs
18
+
19
+ import assert from 'node:assert/strict';
20
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
21
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
22
+
23
+ async function main() {
24
+ const transport = new StdioClientTransport({
25
+ command: 'node',
26
+ args: ['index.js'],
27
+ cwd: import.meta.dirname,
28
+ stderr: 'pipe',
29
+ });
30
+
31
+ const client = new Client({ name: 'test-batch', version: '1.0.0' });
32
+ await client.connect(transport);
33
+
34
+ try {
35
+ const tools = await client.listTools();
36
+ const names = tools.tools.map(t => t.name);
37
+ assert.ok(names.includes('batch'), 'batch tool not discovered from backend');
38
+ console.log('PASS: batch discovered dynamically (%d tools total)', names.length);
39
+
40
+ const result = await client.callTool({
41
+ name: 'batch',
42
+ arguments: { calls: [{ tool: 'get_line_count' }, { tool: 'get_selection' }] },
43
+ });
44
+ const data = JSON.parse(result.content[0].text);
45
+ assert.ok(Array.isArray(data.results), 'batch response missing results array');
46
+ assert.equal(data.results.length, 2, 'expected 2 results');
47
+ assert.ok('line_count' in data.results[0], 'get_line_count result missing line_count');
48
+ assert.ok('selections' in data.results[1], 'get_selection result missing selections');
49
+ console.log('PASS: batch call returned populated results via the real proxy subprocess');
50
+
51
+ const failResult = await client.callTool({
52
+ name: 'batch',
53
+ arguments: { calls: [{ tool: 'get_line_count' }, { tool: 'no_such_tool_xyz' }] },
54
+ });
55
+ const failData = JSON.parse(failResult.content[0].text);
56
+ assert.equal(failData.results.length, 2);
57
+ assert.ok('error' in failData.results[1], 'expected error for unknown tool');
58
+ console.log('PASS: batch partial failure does not abort the whole call');
59
+ } finally {
60
+ await client.close();
61
+ }
62
+ }
63
+
64
+ main()
65
+ .then(() => {
66
+ console.log('All tests passed.');
67
+ process.exit(0);
68
+ })
69
+ .catch(err => {
70
+ console.error('FAIL:', err);
71
+ process.exit(1);
72
+ });
@@ -0,0 +1,111 @@
1
+ // Integration test for the generated fallback catalog (F10).
2
+ //
3
+ // Launches index.js as a real MCP stdio subprocess against a fake backend
4
+ // that deliberately fails `/mcp_tools` discovery, forcing the fallback path.
5
+ // Before the fix that path exposed a hand-maintained 71-tool subset; it must
6
+ // now expose the full generated catalog and route calls correctly.
7
+ //
8
+ // No Sublime Text required — the fake backend stands in for the HTTP bridge.
9
+ //
10
+ // Run:
11
+ // cd packages/node-proxy
12
+ // node test_fallback_catalog.mjs
13
+
14
+ import assert from 'node:assert/strict';
15
+ import http from 'node:http';
16
+ import { readFileSync } from 'node:fs';
17
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
18
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
19
+
20
+ const CATALOG = JSON.parse(
21
+ readFileSync(new URL('./fallback-tools.json', import.meta.url), 'utf8'),
22
+ ).tools;
23
+
24
+ // Fake bridge: 404 on /mcp_tools (kills discovery), echo on POST /{tool}.
25
+ function startFakeBackend() {
26
+ const seen = [];
27
+ const server = http.createServer((req, res) => {
28
+ if (req.url.startsWith('/mcp_tools')) {
29
+ res.writeHead(404).end('no discovery for you');
30
+ return;
31
+ }
32
+ let body = '';
33
+ req.on('data', chunk => { body += chunk; });
34
+ req.on('end', () => {
35
+ seen.push({ url: req.url, method: req.method, body });
36
+ res.writeHead(200, { 'Content-Type': 'application/json' });
37
+ res.end(JSON.stringify({ echo: req.url, args: body ? JSON.parse(body) : null }));
38
+ });
39
+ });
40
+ return new Promise(resolve => {
41
+ server.listen(0, '127.0.0.1', () => resolve({ server, seen, port: server.address().port }));
42
+ });
43
+ }
44
+
45
+ async function main() {
46
+ const { server, seen, port } = await startFakeBackend();
47
+
48
+ const transport = new StdioClientTransport({
49
+ command: 'node',
50
+ args: ['index.js'],
51
+ cwd: import.meta.dirname,
52
+ env: { ...process.env, SUBLIME_MCP_BASE: `http://127.0.0.1:${port}` },
53
+ stderr: 'pipe',
54
+ });
55
+
56
+ const client = new Client({ name: 'test-fallback-catalog', version: '1.0.0' });
57
+ await client.connect(transport);
58
+
59
+ try {
60
+ const listed = await client.listTools();
61
+ const names = new Set(listed.tools.map(t => t.name));
62
+
63
+ assert.equal(
64
+ names.size,
65
+ CATALOG.length,
66
+ `fallback exposed ${names.size} tools, expected the full generated catalog (${CATALOG.length})`,
67
+ );
68
+ for (const tool of CATALOG) {
69
+ assert.ok(names.has(tool.name), `fallback is missing ${tool.name}`);
70
+ }
71
+ console.log('PASS: discovery failure still exposes all %d generated tools', names.size);
72
+
73
+ // batch was the headline F10 omission: absent from the old hand-written
74
+ // fallback, so a discovery miss removed it from the agent entirely.
75
+ assert.ok(names.has('batch'), 'batch missing from fallback catalog');
76
+ const result = await client.callTool({
77
+ name: 'batch',
78
+ arguments: { calls: [{ tool: 'get_line_count' }] },
79
+ });
80
+ const data = JSON.parse(result.content[0].text);
81
+ assert.equal(data.echo, '/batch', 'batch did not route to POST /batch');
82
+ assert.deepEqual(data.args, { calls: [{ tool: 'get_line_count' }] });
83
+ console.log('PASS: batch routes through the fallback path with its arguments intact');
84
+
85
+ // A no-parameter tool must still post cleanly.
86
+ await client.callTool({ name: 'get_line_count', arguments: {} });
87
+ assert.ok(
88
+ seen.some(r => r.url === '/get_line_count' && r.method === 'POST'),
89
+ 'get_line_count did not POST to its tool-name alias',
90
+ );
91
+ console.log('PASS: no-parameter tools post to their /{name} alias');
92
+
93
+ // Schemas must survive, not degrade to untyped passthrough.
94
+ const openFile = listed.tools.find(t => t.name === 'open_file');
95
+ assert.ok(openFile.inputSchema?.properties?.path, 'open_file lost its typed schema');
96
+ console.log('PASS: generated schemas reach the client');
97
+ } finally {
98
+ await client.close();
99
+ server.close();
100
+ }
101
+ }
102
+
103
+ main()
104
+ .then(() => {
105
+ console.log('All tests passed.');
106
+ process.exit(0);
107
+ })
108
+ .catch(err => {
109
+ console.error('FAIL:', err);
110
+ process.exit(1);
111
+ });