@ezmodo/mcp-server 0.13.4 → 0.13.5

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 CHANGED
@@ -101,7 +101,7 @@ export function protectedResourceMetadata() {
101
101
  * server without being told out of band.
102
102
  */
103
103
  function authenticateChallenge(error, description) {
104
- const parts = [`Bearer realm="ezmodo-mcp"`, `resource_metadata="${METADATA_URL}"`];
104
+ const parts = ['Bearer realm="ezmodo-mcp"', `resource_metadata="${METADATA_URL}"`];
105
105
  if (error) parts.push(`error="${error}"`);
106
106
  if (description) parts.push(`error_description="${description}"`);
107
107
  return parts.join(', ');
@@ -169,7 +169,10 @@ async function handleMcpPost(req, res, requestId) {
169
169
  }
170
170
 
171
171
  // One server and transport per request — see the stateless note at the top.
172
- const server = createServer();
172
+ // 'remote': excludes tools that operate on a local checkout, which do not
173
+ // exist here and whose git helpers shell out with caller-supplied arguments
174
+ // (#2614).
175
+ const server = createServer({ surface: 'remote' });
173
176
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
174
177
 
175
178
  // Closing on response end matters: without it every request leaks a transport
@@ -25,21 +25,50 @@ import { HANDLERS } from '../handlers/index.js';
25
25
  import { PROMPTS, getPromptContent } from '../prompts/index.js';
26
26
  import { MCP_VERSION } from './version.js';
27
27
  import { getLogger } from './logger.js';
28
+ import { isRemoteSafe } from './remote-tools.js';
28
29
 
29
- export function createServer() {
30
+ /**
31
+ * @param {object} [options]
32
+ * @param {'local'|'remote'} [options.surface] Which tool surface to serve.
33
+ * 'local' (the default) is the full set, for stdio on a user's machine.
34
+ * 'remote' excludes tools that operate on the local filesystem or git — see
35
+ * lib/remote-tools.js for why that is an allowlist and not a denylist.
36
+ */
37
+ export function createServer({ surface = 'local' } = {}) {
30
38
  const log = getLogger();
31
39
 
40
+ // Filtered ONCE here rather than at each call site, so listing and dispatch
41
+ // cannot disagree. They must agree: filtering only tools/list would leave
42
+ // every excluded handler dispatchable by a client that guesses the name,
43
+ // which is the failure this whole module exists to prevent.
44
+ const tools = surface === 'remote' ? TOOLS.filter((tool) => isRemoteSafe(tool.name)) : TOOLS;
45
+ const available = new Set(tools.map((tool) => tool.name));
46
+
32
47
  const server = new Server(
33
48
  { name: 'ezmodo-mcp-server', version: MCP_VERSION },
34
49
  { capabilities: { tools: {}, prompts: {} } }
35
50
  );
36
51
 
37
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
52
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
38
53
 
39
54
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
40
55
  const { name, arguments: args } = request.params;
41
- const handler = HANDLERS[name];
42
56
 
57
+ // Checked before the handler lookup: a tool excluded from this surface is
58
+ // refused even though HANDLERS still contains it.
59
+ if (!available.has(name)) {
60
+ const handler = HANDLERS[name];
61
+ if (handler) {
62
+ log.warn('Refused a tool not served on this surface', { tool: name, surface });
63
+ throw new Error(
64
+ `Tool "${name}" is not available over this connection. It operates on a local ` +
65
+ 'checkout and is served only by the local (stdio) MCP server.'
66
+ );
67
+ }
68
+ throw new Error(`Unknown tool: ${name}`);
69
+ }
70
+
71
+ const handler = HANDLERS[name];
43
72
  if (!handler) {
44
73
  throw new Error(`Unknown tool: ${name}`);
45
74
  }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Which tools may be served over the REMOTE transport (#2614).
3
+ *
4
+ * The HTTP entry point and the stdio entry point deliberately build the same
5
+ * server (lib/create-server.js) so their tool surfaces cannot drift. That is
6
+ * right for almost everything — and wrong for the handful of tools that are
7
+ * not network operations at all.
8
+ *
9
+ * `detect_git_repository`, `manage_worktree`, `rebuild_manifest` and friends
10
+ * read and write the LOCAL machine: the working directory, `.ezmodo/`, the git
11
+ * repository. Over stdio that is the whole point — the server runs inside the
12
+ * user's checkout, at their request, as them. On a hosted server there is no
13
+ * checkout, so at best they fail confusingly.
14
+ *
15
+ * At worst they are dangerous. lib/git-helpers.js `execGit` passes its command
16
+ * to `execSync` — a shell — and callers interpolate tool arguments into it:
17
+ *
18
+ * execGit(`git branch ${branchName} ${baseBranch}`, repoPath)
19
+ *
20
+ * On a user's own machine the blast radius is their own shell, which is why
21
+ * this has been tolerable. Reachable over the internet it is arbitrary command
22
+ * execution in the container. So these tools are not hardened for remote use —
23
+ * they are not served remotely at all, because they have no meaning there and
24
+ * hardening would leave a shell-executing surface exposed for no benefit.
25
+ *
26
+ * ── The list is an ALLOWLIST, deliberately ───────────────────────────────────
27
+ *
28
+ * A denylist would mean every tool added from now on is exposed remotely by
29
+ * default, and the mistake would be invisible: the tool simply works, until one
30
+ * of them turns out to touch the filesystem. Listing what is safe means a new
31
+ * tool is refused remotely until someone decides otherwise, and
32
+ * remote-tools.test.js fails loudly when a tool is unclassified rather than
33
+ * letting it through.
34
+ *
35
+ * Note some tools do local work as a SIDE EFFECT and are still fine here:
36
+ * manage_task writes `.ezmodo/active-session.json`, and lib/active-session.js
37
+ * already skips that when no config directory exists — which is exactly the
38
+ * case in a container. Likewise get_context and resolve_concepts read a local
39
+ * manifest when there is one and fall back to the API when there is not.
40
+ */
41
+
42
+ /** Tools that operate on the local machine and are never served remotely. */
43
+ export const LOCAL_ONLY_TOOLS = Object.freeze([
44
+ 'detect_git_repository',
45
+ 'get_current_project_context',
46
+ 'initialize_project_context',
47
+ 'list_project_worktrees',
48
+ 'manage_worktree',
49
+ 'rebuild_manifest',
50
+ ]);
51
+
52
+ /** Tools safe to serve over the remote transport. */
53
+ export const REMOTE_SAFE_TOOLS = Object.freeze([
54
+ 'accept_agent_suggestion',
55
+ 'configure_agent',
56
+ 'create_tasks',
57
+ 'delete_attachment',
58
+ 'estimate_task',
59
+ 'evaluate_feature_flag',
60
+ 'get_access',
61
+ 'get_ai_insights',
62
+ 'get_attachment_url',
63
+ 'get_catalog',
64
+ 'get_catalog_diff',
65
+ 'get_context',
66
+ 'get_decision',
67
+ 'get_design',
68
+ 'get_design_system',
69
+ 'get_document',
70
+ 'get_document_template',
71
+ 'get_epic',
72
+ 'get_feature',
73
+ 'get_feature_flag',
74
+ 'get_goal',
75
+ 'get_graph',
76
+ 'get_manifest_schema',
77
+ 'get_milestone',
78
+ 'get_org_areas',
79
+ 'get_organization',
80
+ 'get_project',
81
+ 'get_project_changes',
82
+ 'get_project_story',
83
+ 'get_task',
84
+ 'get_testing_summary',
85
+ 'infer_dependencies',
86
+ 'list_agent_suggestions',
87
+ 'list_attachments',
88
+ 'list_catalog_items',
89
+ 'list_catalogs',
90
+ 'list_components',
91
+ 'list_designs',
92
+ 'list_epics',
93
+ 'list_facts',
94
+ 'list_feature_flags',
95
+ 'list_folders',
96
+ 'list_links',
97
+ 'list_notifications',
98
+ 'list_org_documents',
99
+ 'list_repositories',
100
+ 'list_tags',
101
+ 'list_test_cases',
102
+ 'list_test_suites',
103
+ 'list_todos',
104
+ 'list_watched',
105
+ 'manage_access',
106
+ 'manage_catalog',
107
+ 'manage_component',
108
+ 'manage_decision',
109
+ 'manage_design',
110
+ 'manage_document',
111
+ 'manage_document_template',
112
+ 'manage_environment',
113
+ 'manage_epic',
114
+ 'manage_fact',
115
+ 'manage_feature',
116
+ 'manage_feature_flag',
117
+ 'manage_folder',
118
+ 'manage_goal',
119
+ 'manage_link',
120
+ 'manage_milestone',
121
+ 'manage_project',
122
+ 'manage_pull_request',
123
+ 'manage_recurring_task',
124
+ 'manage_tag',
125
+ 'manage_task',
126
+ 'manage_team',
127
+ 'manage_test_case',
128
+ 'manage_test_suite',
129
+ 'manage_todo',
130
+ 'manage_watch',
131
+ 'manage_work_template',
132
+ 'preview_links',
133
+ 'reject_agent_suggestion',
134
+ 'report_untracked_work',
135
+ 'resolve_concepts',
136
+ 'resolve_link_suggestions',
137
+ 'resolve_links',
138
+ 'run_agent_now',
139
+ 'search_epics',
140
+ 'search_features',
141
+ 'search_tasks',
142
+ 'update_manifest_entries',
143
+ 'validate_manifest',
144
+ ]);
145
+
146
+ const remoteSafe = new Set(REMOTE_SAFE_TOOLS);
147
+
148
+ /** Whether a tool may be served over the remote transport. */
149
+ export function isRemoteSafe(toolName) {
150
+ return remoteSafe.has(toolName);
151
+ }
package/lib/version.js CHANGED
@@ -7,4 +7,4 @@
7
7
  *
8
8
  * Update this when bumping the version in package.json.
9
9
  */
10
- export const MCP_VERSION = '0.13.4';
10
+ export const MCP_VERSION = '0.13.5';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ezmodo/mcp-server",
3
- "version": "0.13.4",
3
+ "version": "0.13.5",
4
4
  "description": "MCP server for ezmodo - AI-first project management",
5
5
  "main": "index.js",
6
6
  "type": "module",