Skip to content

Commit 7c30102

Browse files
authored
fix(mcp): improve read-only classification and approval prompt (#179)
* fix(mcp): improve read-only classification and approval prompt Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> * fix(test): remove unused MCP test import Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> * style(test): format tests/mcp.test.ts with Prettier The previous commit shipped tests/mcp.test.ts unformatted, failing the CI fmt:check (Lint & Test job). Run Prettier to bring it into line: normalise quote style and expression wrapping. No behavioural change. Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> --------- Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
1 parent cf37986 commit 7c30102

5 files changed

Lines changed: 96 additions & 5 deletions

File tree

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/agent/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,8 @@ const mcpWriteSafetyGate: WriteSafetyGate = async (
10131013
? C.err("⚠️ MCP DESTRUCTIVE operation")
10141014
: C.warn("⚠️ MCP write operation");
10151015

1016+
spinner.stop();
1017+
10161018
console.log();
10171019
console.log(` ${label}: ${C.label(serverName)}.${C.label(toolName)}`);
10181020
if (argSummary) {

src/agent/mcp/plugin-adapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export function createMCPPluginAdapter(
9898
// Write-safety gate: check tools that are not known or inferred
9999
// read-only. The guest VM is paused during this check, so it is
100100
// safe to prompt the user.
101-
if (gate && !isReadOnlyMCPTool(tool)) {
101+
if (gate && !isReadOnlyMCPTool(tool, toolArgs)) {
102102
const allowed = await gate(
103103
conn.name,
104104
tool.name,

src/agent/mcp/tool-utils.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,15 +127,54 @@ export function selectMCPTools(
127127
};
128128
}
129129

130-
export function isReadOnlyMCPTool(tool: MCPToolSchema): boolean {
130+
export function isReadOnlyMCPTool(
131+
tool: MCPToolSchema,
132+
args?: Record<string, unknown>,
133+
): boolean {
131134
if (tool.annotations?.readOnlyHint === true) return true;
132135
if (tool.annotations?.destructiveHint === true) return false;
133136

134137
const name = normaliseToolName(tool.name);
135138
if (WRITE_TOOL_PREFIXES.some((prefix) => name.startsWith(prefix))) {
136139
return false;
137140
}
138-
return READ_ONLY_TOOL_PREFIXES.some((prefix) => name.startsWith(prefix));
141+
if (READ_ONLY_TOOL_PREFIXES.some((prefix) => name.startsWith(prefix))) {
142+
return true;
143+
}
144+
145+
const operationHint = inferReadOnlyFromArgs(args);
146+
if (operationHint !== null) return operationHint;
147+
148+
return false;
149+
}
150+
151+
function inferReadOnlyFromArgs(
152+
args: Record<string, unknown> | undefined,
153+
): boolean | null {
154+
if (!args || typeof args !== "object") return null;
155+
156+
const candidates = [
157+
args.operation,
158+
args.action,
159+
args.command,
160+
args.method,
161+
args.kind,
162+
args.type,
163+
];
164+
165+
for (const candidate of candidates) {
166+
if (typeof candidate !== "string") continue;
167+
168+
const value = normaliseToolName(candidate);
169+
if (WRITE_TOOL_PREFIXES.some((prefix) => value.startsWith(prefix))) {
170+
return false;
171+
}
172+
if (READ_ONLY_TOOL_PREFIXES.some((prefix) => value.startsWith(prefix))) {
173+
return true;
174+
}
175+
}
176+
177+
return null;
139178
}
140179

141180
export function getMCPToolSafety(tool: MCPToolSchema): MCPToolInfo["safety"] {

tests/mcp.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// ── MCP integration tests ────────────────────────────────────────────
22

33
import { describe, it, expect, beforeEach, vi } from "vitest";
4+
import { readFileSync } from "node:fs";
5+
import { join } from "node:path";
46
import {
57
parseMCPConfig,
68
computeMCPConfigHash,
@@ -730,7 +732,6 @@ import {
730732
hasCachedTokens,
731733
} from "../src/agent/mcp/auth/token-cache.js";
732734
import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
733-
import { join } from "node:path";
734735
import { homedir } from "node:os";
735736
import { tmpdir } from "node:os";
736737

@@ -1050,6 +1051,55 @@ describe("MCP tool utility helpers", () => {
10501051
expect(isReadOnlyMCPTool(tools[1])).toBe(true);
10511052
expect(isReadOnlyMCPTool(tools[2])).toBe(false);
10521053
});
1054+
1055+
it("keeps the module_info functionName schema valid for array inputs", () => {
1056+
const source = readFileSync(
1057+
join(import.meta.dirname, "..", "src", "agent", "module-info-schema.ts"),
1058+
"utf-8",
1059+
);
1060+
1061+
expect(source).toContain("anyOf: [");
1062+
expect(source).toContain('type: "array", items: { type: "string" }');
1063+
});
1064+
1065+
it("treats Power BI-style list operations as read-only from the request payload", () => {
1066+
const powerBiTool: MCPToolSchema = {
1067+
name: "connection_operations",
1068+
originalName: "connection_operations",
1069+
description: "Connect to Power BI models",
1070+
inputSchema: {
1071+
type: "object",
1072+
properties: {
1073+
operation: { type: "string" },
1074+
},
1075+
},
1076+
} as MCPToolSchema;
1077+
1078+
expect(
1079+
isReadOnlyMCPTool(powerBiTool, { operation: "ListLocalInstances" }),
1080+
).toBe(true);
1081+
expect(isReadOnlyMCPTool(powerBiTool, { operation: "CreateModel" })).toBe(
1082+
false,
1083+
);
1084+
});
1085+
1086+
it("keeps write-prefixed tool names as write operations even with read-like payload hints", () => {
1087+
const deleteTool: MCPToolSchema = {
1088+
name: "delete_model",
1089+
originalName: "delete_model",
1090+
description: "Delete a Power BI model",
1091+
inputSchema: {
1092+
type: "object",
1093+
properties: {
1094+
operation: { type: "string" },
1095+
},
1096+
},
1097+
} as MCPToolSchema;
1098+
1099+
expect(
1100+
isReadOnlyMCPTool(deleteTool, { operation: "ListLocalInstances" }),
1101+
).toBe(false);
1102+
});
10531103
});
10541104

10551105
describe("normaliseToolResult", () => {

0 commit comments

Comments
 (0)