sublime-mcp 1.2.4

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +405 -0
  3. package/index.js +393 -0
  4. package/package.json +27 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Donald Chitester
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,405 @@
1
+ # sublime-mcp
2
+
3
+ <!-- mcp-name: io.github.dpc00/sublime-mcp -->
4
+
5
+ The most complete Sublime Text / AI integration available. sublime-mcp lets
6
+ Claude Code (or any MCP client) do anything in ST that a human can do from the
7
+ keyboard — read files, navigate, edit with full undo history, search across the
8
+ project, run builds, send commands to a Terminus terminal, evaluate arbitrary
9
+ Python in ST's main thread, and more. If you use ST as your primary editor,
10
+ this closes the gap between "AI that edits files" and "AI that works in your
11
+ editor."
12
+
13
+ 63 tools covering reading, navigation, editing, searching, build, Terminus
14
+ integration, settings, layout, menus, console log, and live Python scripting.
15
+
16
+ ## Architecture
17
+
18
+ ```
19
+ Claude Code (MCP client)
20
+ │ stdio / MCP protocol
21
+
22
+ mcp_server.py ← Python process you run outside ST
23
+ │ HTTP 127.0.0.1:9500
24
+
25
+ sublime_mcp.py ← ST plugin, HTTP server on ST's main thread
26
+ │ sublime API
27
+
28
+ Sublime Text 4
29
+ ```
30
+
31
+ | File | Role |
32
+ |------|------|
33
+ | `sublime_mcp.py` | ST plugin — runs an HTTP server inside Sublime Text |
34
+ | `mcp_server.py` | MCP server — wraps the HTTP API for MCP clients |
35
+
36
+ ## Installation
37
+
38
+ ### 1. Install the ST plugin via Package Control
39
+
40
+ 1. Open the Command Palette (`Ctrl+Shift+P`)
41
+ 2. Run **Package Control: Install Package**
42
+ 3. Search for **sublime-mcp** and install
43
+
44
+ ST loads it automatically. You should see:
45
+
46
+ ```
47
+ sublime-mcp: listening on 127.0.0.1:9500
48
+ ```
49
+
50
+ in the ST console (`View › Show Console`).
51
+
52
+ > **Manual install (alternative):** copy `sublime_mcp.py` and `sublime_mcp_browse.py`
53
+ > from the repo into your `Packages/User/` folder.
54
+
55
+ ### 2. Install the MCP server
56
+
57
+ ```
58
+ pip install sublime-mcp
59
+ ```
60
+
61
+ ### 3. Register with Claude Code
62
+
63
+ Add to `~/.claude/mcp.json` (or `~/.claude/settings.json`):
64
+
65
+ ```json
66
+ {
67
+ "mcpServers": {
68
+ "sublime-mcp": {
69
+ "command": "sublime-mcp"
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ Then restart Claude Code. Tools will appear with the `mcp__sublime-mcp__` prefix.
76
+
77
+ ## Windows + WSL setup
78
+
79
+ If you run Sublime Text on **both** Windows and WSL, you can give Claude Code
80
+ in each environment its own MCP entry pointing at the local ST instance, plus
81
+ an optional cross-side entry for the other one.
82
+
83
+ **Requirements:** WSL2 with [mirrored networking](https://learn.microsoft.com/en-us/windows/wsl/networking#mirrored-mode-networking)
84
+ (Windows 11 default). This makes `127.0.0.1` on the WSL side reach Windows
85
+ ports, and vice versa.
86
+
87
+ ### How it works
88
+
89
+ | ST instance | Port | Reached from |
90
+ |---|---|---|
91
+ | Windows ST | `9500` | Windows `127.0.0.1:9500` or WSL `127.0.0.1:9500` (mirrored) |
92
+ | WSL ST | `9501` | WSL `127.0.0.1:9501` |
93
+
94
+ Each ST instance runs its own copy of `sublime_mcp.py` on its own port. The
95
+ MCP server process always runs on the same side as Claude Code — it just points
96
+ at the right port via `SUBLIME_MCP_BASE`.
97
+
98
+ ### Step 1 — Install the plugin in both ST instances
99
+
100
+ Install via Package Control in each ST instance (Windows and WSL ST separately).
101
+
102
+ **WSL ST** — after installing, edit the port in the installed plugin:
103
+
104
+ ```
105
+ ~/.config/sublime-text/Packages/sublime-mcp/sublime_mcp.py
106
+ ```
107
+
108
+ ```python
109
+ _PORT = 9501 # line 22 of sublime_mcp.py
110
+ ```
111
+
112
+ ### Step 2 — Install the MCP server on both sides
113
+
114
+ **Windows:**
115
+ ```
116
+ pip install sublime-mcp
117
+ ```
118
+
119
+ **WSL:**
120
+ ```
121
+ pip3 install sublime-mcp
122
+ ```
123
+
124
+ ### Step 3 — Windows `~/.claude/mcp.json`
125
+
126
+ ```json
127
+ {
128
+ "mcpServers": {
129
+ "sublime-mcp": {
130
+ "command": "sublime-mcp"
131
+ },
132
+ "sublime-mcp-wsl": {
133
+ "command": "wsl",
134
+ "args": ["sublime-mcp"],
135
+ "env": { "SUBLIME_MCP_BASE": "http://127.0.0.1:9501" }
136
+ }
137
+ }
138
+ }
139
+ ```
140
+
141
+ - `sublime-mcp` runs as a Windows process → auto-detects port `9500` → Windows ST
142
+ - `sublime-mcp-wsl` runs inside WSL via the `wsl` command → `127.0.0.1:9501` is WSL's loopback → WSL ST
143
+
144
+ ### Step 4 — WSL `~/.claude/mcp.json`
145
+
146
+ ```json
147
+ {
148
+ "mcpServers": {
149
+ "sublime-mcp": {
150
+ "command": "sublime-mcp",
151
+ "env": { "SUBLIME_MCP_BASE": "http://127.0.0.1:9501" }
152
+ },
153
+ "sublime-mcp-win": {
154
+ "command": "sublime-mcp",
155
+ "env": { "SUBLIME_MCP_BASE": "http://127.0.0.1:9500" }
156
+ }
157
+ }
158
+ }
159
+ ```
160
+
161
+ - `sublime-mcp` runs in WSL → `127.0.0.1:9501` → WSL ST
162
+ - `sublime-mcp-win` runs in WSL → `127.0.0.1:9500` reaches Windows ST via mirrored networking
163
+
164
+ ### Tool prefixes
165
+
166
+ | Claude Code session | Tool prefix for WSL ST | Tool prefix for Windows ST |
167
+ |---|---|---|
168
+ | Windows | `mcp__sublime-mcp-wsl__` | `mcp__sublime-mcp__` |
169
+ | WSL | `mcp__sublime-mcp__` | `mcp__sublime-mcp-win__` |
170
+
171
+ ## Tab and Sheet Indexing
172
+
173
+ **IMPORTANT:** Users refer to tabs by 1-based numbering (tab 1, tab 2, etc.), but
174
+ `get_sheets()` returns 0-based indexes. Always convert user tab references before using:
175
+ - User **tab 1** = index 0
176
+ - User **tab 2** = index 1
177
+ - User **tab 3** = index 2
178
+ - etc.
179
+
180
+ When closing or targeting a specific tab, always verify the index by calling `get_sheets()`
181
+ first, and close by path (preferred) or by focusing then closing the active file.
182
+ **Never change focus without user awareness.**
183
+
184
+ ## Tools
185
+
186
+ ### Read / Introspect
187
+
188
+ | Tool | Description |
189
+ |------|-------------|
190
+ | `get_active_file` | Path, full content, cursor line/col, dirty flag, and syntax name |
191
+ | `get_selection` | Current selection(s): text and begin/end line+col for each |
192
+ | `get_cursor_context` | `lines` lines above and below cursor, with 1-based line numbers prepended |
193
+ | `get_open_files` | All files open in the current window (path, name, is_dirty) |
194
+ | `get_sheets` | All sheets (tabs) in the window by index — includes images and untitled buffers. Returns index, type, path, name, is_dirty |
195
+ | `get_sheet_content` | Content of any tab by sheet index (from `get_sheets`). Works for text, untitled, and Terminus tabs; returns path only for image tabs |
196
+ | `get_project_folders` | Project root folder paths |
197
+ | `get_file_content` | Full content of any already-open file by path |
198
+ | `get_view_content` | Full content of any open tab by name (partial match). Works for Terminus tabs and nameless views |
199
+ | `get_view_size` | Total character count of any open tab. Use to compute offsets before `get_view_chars` |
200
+ | `get_view_chars` | Text at character offsets begin..end (0-based, end exclusive). Clamps to buffer bounds |
201
+ | `get_view_phantoms` | Phantom HTML and extracted plain text from a named view; filters by phantom key |
202
+ | `get_output_panel` | Text content of a named output panel. Omit name for the active panel; `name='exec'` for build output |
203
+ | `get_active_panel` | Active panel id and, if it is an output panel, its content |
204
+ | `get_symbols` | All symbols (functions, classes, etc.) in the active file with line numbers |
205
+ | `lookup_symbol` | Find where a symbol is defined across all open files |
206
+ | `get_project_data` | Raw `.sublime-project` JSON for the current project |
207
+ | `get_variables` | ST build variables: `$file`, `$project_path`, `$platform`, etc. |
208
+ | `get_command_palette` | Command Palette entries from installed `*.sublime-commands` resources; filterable by package, command, or caption |
209
+ | `get_commands` | Runnable command ids from loaded command classes, optionally merged with palette metadata |
210
+ | `get_menu_items` | Menu items from `*.sublime-menu` resources; filterable by menu filename, caption, or command |
211
+ | `get_syntaxes` | All syntax definitions available in ST (name + path) |
212
+ | `get_scope_at_cursor` | Full syntax scope string at the cursor position |
213
+ | `get_word_at_cursor` | Word under the cursor and its line/col |
214
+ | `get_bookmarks` | All bookmarked positions in the active file |
215
+ | `get_line_count` | Total number of lines in the active file |
216
+ | `get_encoding` | Character encoding of the active file |
217
+ | `get_setting` | A ST setting by key. `scope='view'` (default) or `'window'` |
218
+ | `get_layout` | Current window layout (groups, cells) and which files are in each group |
219
+
220
+ ### Navigate
221
+
222
+ | Tool | Description |
223
+ |------|-------------|
224
+ | `open_file` | Open a file, optionally jumping to a specific line and column |
225
+ | `goto_line` | Move cursor to line (and optional column) in the active file |
226
+ | `show_panel` | Bring an output panel to the front. Default `name='exec'` for the build panel |
227
+ | `focus_group` | Move focus to a pane group by 0-based index |
228
+
229
+ ### Edit
230
+
231
+ | Tool | Description |
232
+ |------|-------------|
233
+ | `str_replace_based_edit_tool` | ST-native editor: `str_replace` (unique match), `insert` (after line N), `create` (new file), `view` (numbered content). Full undo, gutter diff, 30s highlight. Auto-opens file if needed |
234
+ | `replace_selection` | Replace the current selection(s) with text |
235
+ | `replace_lines` | Replace lines begin..end (inclusive, 1-based) in the active file |
236
+ | `insert_snippet` | Insert at the cursor using ST snippet syntax (`$1` for tab stops, etc.) |
237
+ | `duplicate_line` | Duplicate the current line(s) |
238
+ | `toggle_comment` | Toggle line comment, or block comment if `block=True` |
239
+ | `sort_lines` | Sort selected lines, or all lines if nothing is selected |
240
+ | `select_lines` | Select lines begin..end (1-based, inclusive) |
241
+ | `fold_lines` | Fold (collapse) lines begin..end in the active file |
242
+ | `undo` | Undo the last edit |
243
+ | `redo` | Redo the last undone edit |
244
+ | `run_command` | Run any ST command with optional args. `scope='window'` (default) or `'view'` |
245
+
246
+ ### Search
247
+
248
+ | Tool | Description |
249
+ |------|-------------|
250
+ | `find_in_file` | Find all occurrences of a pattern in the active file. Returns `{line, col, text}` list |
251
+ | `find_in_files` | Search across project folders (or a supplied list). Skips `.git`, `__pycache__`, `node_modules`, `.venv`. Returns `{path, line, match}` list, capped at `max_results` (default 200) |
252
+
253
+ ### File / Project
254
+
255
+ | Tool | Description |
256
+ |------|-------------|
257
+ | `save_file` | Save the active file |
258
+ | `save_all` | Save all open files |
259
+ | `close_file` | Close a file by path, or the active file if path is omitted |
260
+ | `revert_file` | Revert the active file to its last saved state |
261
+ | `add_folder` | Add a folder to the current project (no-op if already present) |
262
+ | `remove_folder` | Remove a folder from the current project by path |
263
+
264
+ ### Syntax / Encoding
265
+
266
+ | Tool | Description |
267
+ |------|-------------|
268
+ | `set_syntax` | Set the syntax of the active file by name (case-insensitive partial match) |
269
+ | `set_encoding` | Set the character encoding of the active file (e.g. `'UTF-8'`, `'Western (Windows 1252)'`) |
270
+
271
+ ### Settings / Window
272
+
273
+ | Tool | Description |
274
+ |------|-------------|
275
+ | `set_setting` | Set a ST setting by key. `scope='view'` (default) or `'window'` |
276
+ | `toggle_sidebar` | Show or hide the sidebar |
277
+ | `set_layout` | Set the window pane layout. Accepts a ST layout dict with `cols`, `rows`, `cells` |
278
+ | `set_status` | Write a message to ST's status bar |
279
+
280
+ ### Build
281
+
282
+ | Tool | Description |
283
+ |------|-------------|
284
+ | `run_build` | Trigger the current build system, or pass `cmd`/`shell_cmd` + `working_dir` for a custom command |
285
+
286
+ ### Terminus Integration
287
+
288
+ [Terminus](https://github.com/randy3k/Terminus) is a popular ST terminal package.
289
+ `send_to_view` is Terminus-aware: when targeting a Terminus tab it uses
290
+ `terminus_send_string` to type text into the terminal session rather than
291
+ inserting into a buffer.
292
+
293
+ | Tool | Description |
294
+ |------|-------------|
295
+ | `send_to_view` | Send a string to any open tab by name. For Terminus tabs, types the text as if the user typed it. Include a trailing `\n` to execute a command |
296
+
297
+ ### Scripting
298
+
299
+ | Tool | Description |
300
+ |------|-------------|
301
+ | `eval_python` | Execute arbitrary Python in ST's main thread. Locals available: `sublime`, `window`, `view`, `print`. Returns captured stdout in `output` |
302
+ | `get_console_log` | Recent ST console output (plugin log messages and stdout). `tail=N` limits to last N entries; `tail=0` returns all |
303
+
304
+ ## Configuration
305
+
306
+ ### Port
307
+
308
+ The plugin listens on `9500` by default. The MCP server auto-detects the port:
309
+ - Windows → `9500`
310
+ - WSL / Linux → `9501`
311
+
312
+ Override with the `SUBLIME_MCP_BASE` environment variable:
313
+
314
+ ```json
315
+ {
316
+ "mcpServers": {
317
+ "sublime-mcp": {
318
+ "command": "sublime-mcp",
319
+ "env": { "SUBLIME_MCP_BASE": "http://127.0.0.1:9500" }
320
+ }
321
+ }
322
+ }
323
+ ```
324
+
325
+ To change the plugin's port, edit `_PORT` near the top of `sublime_mcp.py` in your ST `Packages/sublime-mcp/` folder.
326
+
327
+ ### Timeout
328
+
329
+ The MCP server waits up to 10 seconds for each HTTP response. Edit `TIMEOUT` in
330
+ `mcp_server.py` if you need longer (e.g. for slow `eval_python` calls).
331
+
332
+ ## Security note
333
+
334
+ The HTTP server binds to `127.0.0.1` only and accepts any request without
335
+ authentication. Do not expose port 9500 to a network interface.
336
+
337
+ ## Requirements
338
+
339
+ - Sublime Text 4
340
+ - Python 3.10+ (for the MCP server process)
341
+ - `pip install mcp httpx`
342
+ - Terminus package (optional, required only for `send_to_view` on terminal tabs)
343
+
344
+ ## Getting Claude to use the tools well
345
+
346
+ Add a section like this to your project's `CLAUDE.md` (or `~/.claude/CLAUDE.md`
347
+ for global use):
348
+
349
+ ```markdown
350
+ ## Sublime Text MCP tools
351
+
352
+ sublime-mcp is connected. Prefer it over standard file tools when working in ST:
353
+
354
+ - Read files with `get_active_file` or `get_file_content` rather than Read tool
355
+ - Edit with `str_replace_based_edit_tool` — edits appear live with gutter diff and undo
356
+ - Use `find_in_files` for project-wide search
357
+ - Use `send_to_view` to run commands in a Terminus terminal tab
358
+ - Use `eval_python` for one-off ST scripting (no plugin file needed)
359
+ - Check `get_console_log` when a plugin isn't behaving as expected
360
+
361
+ Tab indexing: `get_sheets()` returns 0-based indexes; users refer to tabs
362
+ 1-based. Always call `get_sheets()` first when targeting a specific tab.
363
+ ```
364
+
365
+ ## Known limitations / Roadmap
366
+
367
+ - **No multi-window support** — tools target the most recently focused ST window only
368
+ - **No image editing** — `get_sheet_content` returns the path for image tabs, not pixel data
369
+ - **Terminus dependency is optional** — `send_to_view` degrades gracefully if Terminus isn't installed, but terminal interaction requires it
370
+ - **No ST3 support** — the plugin uses ST4 APIs throughout
371
+
372
+ Contributions welcome in any of these areas. See below.
373
+
374
+ ## Testing
375
+
376
+ The test suite requires Sublime Text to be running with `sublime_mcp.py` loaded.
377
+
378
+ ```
379
+ cd C:\Users\donal\projects\sublime-mcp
380
+ pip install httpx pytest
381
+ pytest tests/test_http_api.py -v
382
+ ```
383
+
384
+ 111 tests covering all 60 HTTP API endpoints. Expected result: 109 passed, 2 skipped
385
+ (the skips are environment-dependent: one requires an open saved file, one requires
386
+ a Terminus tab). Zero failures on a clean ST session.
387
+
388
+ ## Contributing
389
+
390
+ The project has two independent pieces — the ST plugin (`sublime_mcp.py`) and
391
+ the MCP server (`mcp_server.py`) — which makes it easy to contribute to either
392
+ without touching the other.
393
+
394
+ **Adding a tool:**
395
+ 1. Add an HTTP handler in `sublime_mcp.py` (runs on ST's main thread via `_on_main`)
396
+ 2. Add the corresponding `@mcp.tool()` function in `mcp_server.py`
397
+ 3. Add a row to the Tools table in `README.md`
398
+
399
+ **Good first issues:**
400
+ - Multi-window support (`sublime.windows()` instead of `sublime.active_window()`)
401
+ - `get_diagnostics` — expose LSP error/warning annotations
402
+ - `set_bookmark` / `clear_bookmarks` — write counterparts to `get_bookmarks`
403
+ - Any gap identified in the [JetBrains MCP feature set](https://github.com/JetBrains/mcp-jetbrains)
404
+
405
+ Open an issue or PR on [GitHub](https://github.com/dpc00/sublime-mcp).
package/index.js ADDED
@@ -0,0 +1,393 @@
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
+
6
+ const port = process.platform === 'win32' ? 9500 : 9501;
7
+ const BASE = process.env.SUBLIME_MCP_BASE ?? `http://127.0.0.1:${port}`;
8
+ const TIMEOUT = 10_000;
9
+
10
+ process.stderr.write(`mcp-commander: BASE=${BASE} platform=${process.platform}\n`);
11
+
12
+ async function get(endpoint, params = {}) {
13
+ const url = new URL(endpoint, BASE);
14
+ for (const [k, v] of Object.entries(params)) {
15
+ url.searchParams.set(k, String(v));
16
+ }
17
+ const r = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });
18
+ if (!r.ok) throw new Error(`HTTP ${r.status} from ${endpoint}`);
19
+ return r.json();
20
+ }
21
+
22
+ async function post(endpoint, body = {}) {
23
+ const r = await fetch(new URL(endpoint, BASE), {
24
+ method: 'POST',
25
+ headers: { 'Content-Type': 'application/json' },
26
+ body: JSON.stringify(body),
27
+ signal: AbortSignal.timeout(TIMEOUT),
28
+ });
29
+ if (!r.ok) throw new Error(`HTTP ${r.status} from ${endpoint}`);
30
+ return r.json();
31
+ }
32
+
33
+ function ok(data) {
34
+ return { content: [{ type: 'text', text: JSON.stringify(data) }] };
35
+ }
36
+
37
+ const server = new McpServer({ name: 'sublime-mcp', version: '1.2.4' });
38
+
39
+ // ── no-parameter passthrough tools ────────────────────────────────────────────
40
+
41
+ const PASSTHROUGH = [
42
+ ['get_active_file', 'GET', '/active_file', "Return the active file's path, full content, cursor line/col, dirty flag, and syntax name."],
43
+ ['get_selection', 'GET', '/selection', 'Return the current selection(s): text and begin/end line+col for each.'],
44
+ ['get_open_files', 'GET', '/open_files', 'List all files open in the current window (path, name, is_dirty).'],
45
+ ['get_sheets', 'GET', '/sheets', 'List ALL sheets (tabs) in the current window by index, including images and untitled buffers.\nReturns index, type (TextSheet/ImageSheet), path, name, is_dirty for each.\nUse index with get_sheet_content to read a specific tab.'],
46
+ ['get_project_folders', 'GET', '/project_folders', "Return the project's root folder paths."],
47
+ ['get_symbols', 'GET', '/symbols', 'Return all symbols (functions, classes, etc.) in the active file with line numbers.'],
48
+ ['get_project_data', 'GET', '/project_data', 'Return the raw .sublime-project JSON data for the current project.'],
49
+ ['get_variables', 'GET', '/variables', "Return Sublime Text's build variables: $file, $project_path, $platform, etc."],
50
+ ['get_active_panel', 'GET', '/active_panel', 'Return the active panel id and, if it is an output panel, its content.'],
51
+ ['get_syntaxes', 'GET', '/syntaxes', 'List all syntax definitions available in Sublime Text (name + path).'],
52
+ ['get_encoding', 'GET', '/encoding', 'Return the character encoding of the active file.'],
53
+ ['get_scope_at_cursor', 'GET', '/scope_at_cursor', 'Return the full syntax scope string at the cursor position.'],
54
+ ['get_word_at_cursor', 'GET', '/word_at_cursor', 'Return the word under the cursor and its line/col.'],
55
+ ['get_bookmarks', 'GET', '/bookmarks', 'Return all bookmarked positions in the active file.'],
56
+ ['get_line_count', 'GET', '/line_count', 'Return the total number of lines in the active file.'],
57
+ ['get_layout', 'GET', '/layout', 'Return the current window layout (groups, cells) and which files are in each group.'],
58
+ ['save_all', 'POST', '/save_all', 'Save all open files.'],
59
+ ['revert_file', 'POST', '/revert_file', 'Revert the active file to its last saved state, discarding unsaved changes.'],
60
+ ['undo', 'POST', '/undo', 'Undo the last edit in the active file.'],
61
+ ['redo', 'POST', '/redo', 'Redo the last undone edit in the active file.'],
62
+ ['duplicate_line', 'POST', '/duplicate_line', 'Duplicate the current line(s) in the active file.'],
63
+ ['toggle_sidebar', 'POST', '/toggle_sidebar', 'Show or hide the Sublime Text sidebar.'],
64
+ ];
65
+
66
+ for (const [name, method, endpoint, description] of PASSTHROUGH) {
67
+ server.registerTool(name, { description }, async () =>
68
+ ok(await (method === 'GET' ? get(endpoint) : post(endpoint)))
69
+ );
70
+ }
71
+
72
+ // ── parameterised tools ───────────────────────────────────────────────────────
73
+
74
+ server.registerTool('get_cursor_context', {
75
+ description: 'Return `lines` lines above and below the cursor with 1-based line numbers prepended.',
76
+ inputSchema: { lines: z.number().int().default(10) },
77
+ }, async ({ lines }) => ok(await get('/cursor_context', { lines })));
78
+
79
+ server.registerTool('get_sheet_content', {
80
+ description: 'Return the content of any tab by its sheet index (from get_sheets).\nWorks for text tabs including untitled buffers and Terminus tabs.\nFor image tabs returns the file path only.',
81
+ inputSchema: { index: z.number().int() },
82
+ }, async ({ index }) => ok(await get('/sheet_content', { index })));
83
+
84
+ server.registerTool('get_file_content', {
85
+ description: 'Return the full content of an already-open file by its path.',
86
+ inputSchema: { path: z.string() },
87
+ }, async ({ path }) => ok(await get('/file_content', { path })));
88
+
89
+ server.registerTool('get_view_content', {
90
+ description: 'Return the full content of any open tab by name (partial match, case-insensitive).\nWorks for Terminus tabs and other nameless views that have no file path.\nUse index (0-based, from get_open_files) to target a tab by position instead of name.\nOmit both to read the active view.',
91
+ inputSchema: { name: z.string().default(''), index: z.number().int().default(-1) },
92
+ }, async ({ name, index }) => ok(await get('/view_content', { name, index })));
93
+
94
+ server.registerTool('get_view_size', {
95
+ description: 'Return the total character count of any open tab by name (partial match, case-insensitive).\nUse before get_view_chars to compute offsets — e.g. begin=size-5000, end=size for the tail.\nOmit name for the active view.',
96
+ inputSchema: { name: z.string().default('') },
97
+ }, async ({ name }) => ok(await get('/view_size', { name })));
98
+
99
+ server.registerTool('get_view_chars', {
100
+ description: 'Return text at character offsets begin..end (0-based, end exclusive) from any open tab.\nWorks for Terminus tabs and any other view. Clamps to buffer bounds automatically.\nUse get_view_size first, then e.g. begin=size-5000, end=size to read the last 5000 chars.\nOmit name for the active view.',
101
+ inputSchema: { begin: z.number().int(), end: z.number().int(), name: z.string().default('') },
102
+ }, async ({ begin, end, name }) => ok(await get('/view_chars', { name, begin, end })));
103
+
104
+ server.registerTool('get_view_phantoms', {
105
+ description: "Return phantom HTML and extracted text from a view by name.\nIf key is omitted, defaults to the common 'pybackup' phantom key.",
106
+ inputSchema: { name: z.string().default(''), key: z.string().default('') },
107
+ }, async ({ name, key }) => ok(await get('/view_phantoms', { name, key })));
108
+
109
+ server.registerTool('get_output_panel', {
110
+ description: "Return the text content of an output panel.\nIf name is omitted, read the active output panel. Use name='exec' for build output.",
111
+ inputSchema: { name: z.string().default('') },
112
+ }, async ({ name }) => ok(await get('/output_panel', { name })));
113
+
114
+ server.registerTool('lookup_symbol', {
115
+ description: 'Find where a symbol is defined across all open files.',
116
+ inputSchema: { symbol: z.string() },
117
+ }, async ({ symbol }) => ok(await get('/lookup_symbol', { symbol })));
118
+
119
+ server.registerTool('add_folder', {
120
+ description: 'Add a folder to the current project.',
121
+ inputSchema: { path: z.string() },
122
+ }, async ({ path }) => {
123
+ const data = (await get('/project_data')).project_data ?? {};
124
+ const folders = data.folders ?? [];
125
+ if (folders.some(f => f.path === path)) return ok({ ok: true, note: 'already present' });
126
+ folders.push({ path });
127
+ data.folders = folders;
128
+ return ok(await post('/set_project_data', { data }));
129
+ });
130
+
131
+ server.registerTool('remove_folder', {
132
+ description: 'Remove a folder from the current project by path.',
133
+ inputSchema: { path: z.string() },
134
+ }, async ({ path }) => {
135
+ const data = (await get('/project_data')).project_data ?? {};
136
+ const folders = data.folders ?? [];
137
+ const newFolders = folders.filter(f => f.path !== path);
138
+ if (newFolders.length === folders.length) return ok({ ok: false, note: 'folder not found' });
139
+ data.folders = newFolders;
140
+ return ok(await post('/set_project_data', { data }));
141
+ });
142
+
143
+ server.registerTool('send_to_view', {
144
+ description: 'Send a string to any open tab by name (partial match, case-insensitive).\nFor Terminus tabs this types the text into the terminal as if the user typed it.\nInclude a trailing newline (\\n) to execute a command.\nUse index (0-based, from get_open_files) to target a tab by position instead of name.\nOmit both name and index to target the active view.',
145
+ inputSchema: { text: z.string(), name: z.string().default(''), index: z.number().int().default(-1) },
146
+ }, async ({ text, name, index }) => ok(await post('/send_to_view', { text, name, index })));
147
+
148
+ server.registerTool('open_file', {
149
+ description: 'Open a file in Sublime Text, optionally jumping to a specific line and column.',
150
+ inputSchema: { path: z.string(), line: z.number().int().default(0), col: z.number().int().default(0) },
151
+ }, async ({ path, line, col }) => ok(await post('/open_file', { path, line, col })));
152
+
153
+ server.registerTool('goto_line', {
154
+ description: 'Move the cursor to a line (and optional column) in the active file.',
155
+ inputSchema: { line: z.number().int(), col: z.number().int().default(1) },
156
+ }, async ({ line, col }) => ok(await post('/goto_line', { line, col })));
157
+
158
+ server.registerTool('show_panel', {
159
+ description: "Bring an output panel to the front. Use name='exec' for the build panel.",
160
+ inputSchema: { name: z.string().default('exec') },
161
+ }, async ({ name }) => ok(await post('/show_panel', { name })));
162
+
163
+ server.registerTool('replace_selection', {
164
+ description: 'Replace the current selection(s) with text.',
165
+ inputSchema: { text: z.string() },
166
+ }, async ({ text }) => ok(await post('/replace_selection', { text })));
167
+
168
+ server.registerTool('replace_lines', {
169
+ description: 'Replace lines begin through end (inclusive, 1-based) in the active file with text.\nPass path to target a specific open file regardless of which tab is focused.\nUse index (0-based, from get_open_files) to target a nameless tab by position.',
170
+ inputSchema: {
171
+ begin: z.number().int(),
172
+ end: z.number().int(),
173
+ text: z.string(),
174
+ path: z.string().default(''),
175
+ index: z.number().int().default(-1),
176
+ },
177
+ }, async ({ begin, end, text, path, index }) =>
178
+ ok(await post('/replace_lines', { begin, end, text, path, index })));
179
+
180
+ server.registerTool('run_command', {
181
+ description: "Run any Sublime Text command. scope='window' (default) or 'view'.",
182
+ inputSchema: {
183
+ command: z.string(),
184
+ args: z.record(z.unknown()).optional(),
185
+ scope: z.string().default('window'),
186
+ },
187
+ }, async ({ command, args, scope }) =>
188
+ ok(await post('/run_command', { command, args: args ?? {}, scope })));
189
+
190
+ server.registerTool('run_build', {
191
+ description: 'Trigger the current build system, or pass cmd/shell_cmd to run a specific command.',
192
+ inputSchema: {
193
+ cmd: z.array(z.string()).optional(),
194
+ shell_cmd: z.string().optional(),
195
+ working_dir: z.string().default(''),
196
+ },
197
+ }, async ({ cmd, shell_cmd, working_dir }) => {
198
+ const body = {};
199
+ if (cmd) body.cmd = cmd;
200
+ if (shell_cmd) body.shell_cmd = shell_cmd;
201
+ if (working_dir) body.working_dir = working_dir;
202
+ return ok(await post('/run_build', body));
203
+ });
204
+
205
+ server.registerTool('set_status', {
206
+ description: "Write a message to Sublime Text's status bar.",
207
+ inputSchema: { value: z.string(), key: z.string().default('sublime_mcp') },
208
+ }, async ({ value, key }) => ok(await post('/set_status', { key, value })));
209
+
210
+ server.registerTool('save_file', {
211
+ description: 'Save a file. Pass path to save a specific open file; omit path to save the active file.',
212
+ inputSchema: { path: z.string().default('') },
213
+ }, async ({ path }) => ok(await post('/save_file', path ? { path } : {})));
214
+
215
+ server.registerTool('close_file', {
216
+ description: 'Close a file by path, or close the active file if path is omitted.',
217
+ inputSchema: { path: z.string().default('') },
218
+ }, async ({ path }) => ok(await post('/close_file', { path })));
219
+
220
+ server.registerTool('toggle_comment', {
221
+ description: 'Toggle line comment (or block comment if block=true) on the current selection.',
222
+ inputSchema: { block: z.boolean().default(false) },
223
+ }, async ({ block }) => ok(await post('/toggle_comment', { block })));
224
+
225
+ server.registerTool('sort_lines', {
226
+ description: 'Sort the selected lines (or all lines if nothing is selected).',
227
+ inputSchema: { case_sensitive: z.boolean().default(false) },
228
+ }, async ({ case_sensitive }) => ok(await post('/sort_lines', { case_sensitive })));
229
+
230
+ server.registerTool('select_lines', {
231
+ description: 'Select lines begin through end (1-based, inclusive). end defaults to begin.',
232
+ inputSchema: { begin: z.number().int(), end: z.number().int().default(0) },
233
+ }, async ({ begin, end }) => ok(await post('/select_lines', { begin, end: end || begin })));
234
+
235
+ server.registerTool('fold_lines', {
236
+ description: 'Fold (collapse) lines begin through end (1-based) in the active file.',
237
+ inputSchema: { begin: z.number().int(), end: z.number().int() },
238
+ }, async ({ begin, end }) => ok(await post('/fold_lines', { begin, end })));
239
+
240
+ server.registerTool('insert_snippet', {
241
+ description: "Insert a snippet at the cursor using Sublime Text's snippet syntax (e.g. $1 for tab stops).",
242
+ inputSchema: { contents: z.string() },
243
+ }, async ({ contents }) => ok(await post('/insert_snippet', { contents })));
244
+
245
+ server.registerTool('find_in_file', {
246
+ description: 'Find all occurrences of pattern in the active file. Returns list of {line, col, text}.',
247
+ inputSchema: {
248
+ pattern: z.string(),
249
+ case_sensitive: z.boolean().default(false),
250
+ regex: z.boolean().default(false),
251
+ },
252
+ }, async ({ pattern, case_sensitive, regex }) =>
253
+ ok(await post('/find_in_file', { pattern, case_sensitive, regex })));
254
+
255
+ server.registerTool('find_in_files', {
256
+ description: 'Search for pattern across project folders (or the supplied folder list).\nSkips .git, __pycache__, node_modules, .venv. Returns list of {path, line, match}.',
257
+ inputSchema: {
258
+ pattern: z.string(),
259
+ folders: z.array(z.string()).optional(),
260
+ case_sensitive: z.boolean().default(false),
261
+ regex: z.boolean().default(false),
262
+ max_results: z.number().int().default(200),
263
+ },
264
+ }, async ({ pattern, folders, case_sensitive, regex, max_results }) => {
265
+ const body = { pattern, case_sensitive, regex, max_results };
266
+ if (folders) body.folders = folders;
267
+ return ok(await post('/find_in_files', body));
268
+ });
269
+
270
+ server.registerTool('get_command_palette', {
271
+ description: 'List Command Palette entries from installed *.sublime-commands resources.\nOptional filters: package, command id, or caption substring.',
272
+ inputSchema: {
273
+ package: z.string().default(''),
274
+ command: z.string().default(''),
275
+ caption: z.string().default(''),
276
+ },
277
+ }, async ({ package: pkg, command, caption }) =>
278
+ ok(await get('/command_palette', { package: pkg, command, caption })));
279
+
280
+ server.registerTool('get_commands', {
281
+ description: 'List runnable Sublime command ids from loaded command classes, optionally enriched\nwith matching Command Palette entries from installed packages.',
282
+ inputSchema: {
283
+ package: z.string().default(''),
284
+ command: z.string().default(''),
285
+ include_palette: z.boolean().default(true),
286
+ },
287
+ }, async ({ package: pkg, command, include_palette }) =>
288
+ ok(await get('/commands', { package: pkg, command, include_palette: String(include_palette) })));
289
+
290
+ server.registerTool('get_menu_items', {
291
+ description: 'List installed menu items from *.sublime-menu resources.\nOptional filters: menu filename, caption substring, or command id substring.',
292
+ inputSchema: {
293
+ menu: z.string().default(''),
294
+ caption: z.string().default(''),
295
+ command: z.string().default(''),
296
+ },
297
+ }, async ({ menu, caption, command }) =>
298
+ ok(await get('/menu_items', { menu, caption, command })));
299
+
300
+ server.registerTool('set_syntax', {
301
+ description: 'Set the syntax of the active file by name (case-insensitive partial match is fine).',
302
+ inputSchema: { name: z.string() },
303
+ }, async ({ name }) => ok(await post('/set_syntax', { name })));
304
+
305
+ server.registerTool('set_encoding', {
306
+ description: "Set the character encoding of the active file (e.g. 'UTF-8', 'Western (Windows 1252)').",
307
+ inputSchema: { encoding: z.string() },
308
+ }, async ({ encoding }) => ok(await post('/set_encoding', { encoding })));
309
+
310
+ server.registerTool('get_setting', {
311
+ description: "Get a Sublime Text setting by key. scope='view' (default) or 'window'.",
312
+ inputSchema: { key: z.string(), scope: z.string().default('view') },
313
+ }, async ({ key, scope }) => ok(await post('/get_setting', { key, scope })));
314
+
315
+ server.registerTool('set_setting', {
316
+ description: "Set a Sublime Text setting by key. scope='view' (default) or 'window'.",
317
+ inputSchema: { key: z.string(), value: z.unknown(), scope: z.string().default('view') },
318
+ }, async ({ key, value, scope }) => ok(await post('/set_setting', { key, value, scope })));
319
+
320
+ server.registerTool('focus_group', {
321
+ description: 'Move focus to a pane group by 0-based index.',
322
+ inputSchema: { group: z.number().int() },
323
+ }, async ({ group }) => ok(await post('/focus_group', { group })));
324
+
325
+ server.registerTool('set_layout', {
326
+ description: 'Set the window pane layout. layout must be a ST layout dict with cols, rows, cells keys.',
327
+ inputSchema: { layout: z.record(z.unknown()) },
328
+ }, async ({ layout }) => ok(await post('/set_layout', { layout })));
329
+
330
+ server.registerTool('str_replace_based_edit_tool', {
331
+ description: `ST-native file editor implementing the standard str_replace_based_edit_tool interface.
332
+ Edits appear live in Sublime Text with full undo (Ctrl+Z), gutter diff markers,
333
+ and 30-second highlight annotations showing what changed.
334
+
335
+ command='str_replace': replace old_str with new_str in path.
336
+ old_str must match exactly once (whitespace-sensitive).
337
+ Returns error if 0 or 2+ matches, listing ambiguous line numbers.
338
+
339
+ command='insert': insert insert_text after line insert_line (1-based).
340
+ insert_line=0 inserts at the very start of the file.
341
+
342
+ command='create': create a new file at path with file_text content.
343
+ Syntax is auto-detected from the file extension. Errors if path exists.
344
+
345
+ command='view': return file content with 1-based line numbers prepended.
346
+ Optional view_range=[start, end] to read a slice (end=-1 for EOF).
347
+
348
+ All commands auto-open the file in ST if not already open.`,
349
+ inputSchema: {
350
+ command: z.string(),
351
+ path: z.string().default(''),
352
+ old_str: z.string().optional(),
353
+ new_str: z.string().optional(),
354
+ insert_line: z.number().int().optional(),
355
+ insert_text: z.string().optional(),
356
+ file_text: z.string().optional(),
357
+ view_range: z.array(z.number().int()).length(2).optional(),
358
+ },
359
+ }, async ({ command, path, old_str, new_str, insert_line, insert_text, file_text, view_range }) => {
360
+ const body = { command, path };
361
+ if (old_str !== undefined) body.old_str = old_str;
362
+ if (new_str !== undefined) body.new_str = new_str;
363
+ if (insert_line !== undefined) body.insert_line = insert_line;
364
+ if (insert_text !== undefined) body.insert_text = insert_text;
365
+ if (file_text !== undefined) body.file_text = file_text;
366
+ if (view_range !== undefined) body.view_range = view_range;
367
+ return ok(await post('/edit_file', body));
368
+ });
369
+
370
+ server.registerTool('eval_python', {
371
+ description: "Execute arbitrary Python in Sublime Text's main thread.\nLocals: sublime, window, view, print. Returns captured stdout in 'output'.",
372
+ inputSchema: { code: z.string() },
373
+ }, async ({ code }) => ok(await post('/eval_python', { code })));
374
+
375
+ server.registerTool('get_console_log', {
376
+ description: 'Return recent Sublime Text console output (plugin log messages and stdout).\ntail=N limits to the last N entries. tail=0 returns all captured entries.',
377
+ inputSchema: { tail: z.number().int().default(100) },
378
+ }, async ({ tail }) => ok(await get('/console_log', { tail })));
379
+
380
+ server.registerTool('get_console_full', {
381
+ description: 'Capture the FULL Sublime Text Python console (entire session history) by simulating Ctrl+A, Ctrl+C in the console output panel and reading the clipboard.\nReturns the complete text including startup messages, plugin load events, and all errors.\nNote: briefly takes keyboard focus from ST to perform the macro.',
382
+ inputSchema: {},
383
+ }, async () => ok(await get('/console_full')));
384
+
385
+ server.registerTool('eval_python_latest', {
386
+ description: "Execute Python code using the system Python 3.12 interpreter (via 'py -3.12') outside Sublime Text's embedded Python 3.8 sandbox.\nUseful for code that requires Python 3.9+ syntax, newer stdlib features, or third-party packages not available in ST.\nReturns stdout, stderr, and returncode.",
387
+ inputSchema: { code: z.string() },
388
+ }, async ({ code }) => ok(await post('/eval_python_latest', { code })));
389
+
390
+ // ── startup ───────────────────────────────────────────────────────────────────
391
+
392
+ const transport = new StdioServerTransport();
393
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "sublime-mcp",
3
+ "version": "1.2.4",
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": "./index.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": ["sublime-text", "mcp", "ai", "claude"],
17
+ "author": "Donald Chitester",
18
+ "license": "MIT",
19
+ "bugs": {
20
+ "url": "https://github.com/dpc00/sublime-mcp/issues"
21
+ },
22
+ "homepage": "https://github.com/dpc00/sublime-mcp#readme",
23
+ "dependencies": {
24
+ "@modelcontextprotocol/sdk": "^1.29.0",
25
+ "zod": "^4.4.3"
26
+ }
27
+ }