@bryan-thompson/inspector-assessment-cli 1.36.1 → 1.36.3

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.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Tests for tools-with-hints module
3
+ *
4
+ * Issue #155: Verifies that hint properties (readOnlyHint, etc.) are preserved
5
+ * from raw MCP transport responses, even when the SDK's Zod validation would
6
+ * normally strip them.
7
+ */
8
+ import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals";
9
+ import { getToolsWithPreservedHints, } from "../../lib/assessment-runner/tools-with-hints.js";
10
+ describe("getToolsWithPreservedHints", () => {
11
+ let mockClient;
12
+ let mockTransport;
13
+ beforeEach(() => {
14
+ mockTransport = {
15
+ onmessage: undefined,
16
+ };
17
+ mockClient = {
18
+ transport: mockTransport,
19
+ listTools: jest.fn(),
20
+ };
21
+ });
22
+ afterEach(() => {
23
+ jest.clearAllMocks();
24
+ });
25
+ it("should preserve readOnlyHint from direct property in raw response", async () => {
26
+ // SDK returns tool without readOnlyHint (stripped by Zod)
27
+ mockClient.listTools.mockImplementation(async () => {
28
+ // Simulate raw response before SDK processes it
29
+ if (mockTransport.onmessage) {
30
+ mockTransport.onmessage({
31
+ result: {
32
+ tools: [
33
+ {
34
+ name: "browse_subreddit",
35
+ description: "Browse posts",
36
+ readOnlyHint: true, // Direct property in raw response
37
+ },
38
+ ],
39
+ },
40
+ });
41
+ }
42
+ // Return SDK-processed tools (without direct readOnlyHint)
43
+ return {
44
+ tools: [
45
+ {
46
+ name: "browse_subreddit",
47
+ description: "Browse posts",
48
+ // readOnlyHint stripped by SDK Zod validation
49
+ },
50
+ ],
51
+ };
52
+ });
53
+ const tools = await getToolsWithPreservedHints(mockClient);
54
+ expect(tools).toHaveLength(1);
55
+ expect(tools[0].name).toBe("browse_subreddit");
56
+ expect(tools[0].readOnlyHint).toBe(true);
57
+ });
58
+ it("should preserve all hint properties from raw response", async () => {
59
+ mockClient.listTools.mockImplementation(async () => {
60
+ if (mockTransport.onmessage) {
61
+ mockTransport.onmessage({
62
+ result: {
63
+ tools: [
64
+ {
65
+ name: "delete_item",
66
+ readOnlyHint: false,
67
+ destructiveHint: true,
68
+ idempotentHint: false,
69
+ openWorldHint: true,
70
+ },
71
+ ],
72
+ },
73
+ });
74
+ }
75
+ return { tools: [{ name: "delete_item" }] };
76
+ });
77
+ const tools = await getToolsWithPreservedHints(mockClient);
78
+ expect(tools[0].readOnlyHint).toBe(false);
79
+ expect(tools[0].destructiveHint).toBe(true);
80
+ expect(tools[0].idempotentHint).toBe(false);
81
+ expect(tools[0].openWorldHint).toBe(true);
82
+ });
83
+ it("should not override SDK annotations with raw direct properties", async () => {
84
+ // If SDK has annotations, they take precedence
85
+ mockClient.listTools.mockImplementation(async () => {
86
+ if (mockTransport.onmessage) {
87
+ mockTransport.onmessage({
88
+ result: {
89
+ tools: [
90
+ {
91
+ name: "read_file",
92
+ readOnlyHint: false, // Raw says false
93
+ },
94
+ ],
95
+ },
96
+ });
97
+ }
98
+ return {
99
+ tools: [
100
+ {
101
+ name: "read_file",
102
+ annotations: {
103
+ readOnlyHint: true, // SDK annotations say true
104
+ },
105
+ },
106
+ ],
107
+ };
108
+ });
109
+ const tools = await getToolsWithPreservedHints(mockClient);
110
+ // SDK annotations should take precedence
111
+ expect(tools[0].annotations?.readOnlyHint).toBe(true);
112
+ // Direct property should NOT override
113
+ expect(tools[0].readOnlyHint).toBeUndefined();
114
+ });
115
+ it("should handle tools with no raw match gracefully", async () => {
116
+ mockClient.listTools.mockImplementation(async () => {
117
+ if (mockTransport.onmessage) {
118
+ mockTransport.onmessage({
119
+ result: {
120
+ tools: [
121
+ { name: "tool_a", readOnlyHint: true },
122
+ // tool_b missing from raw
123
+ ],
124
+ },
125
+ });
126
+ }
127
+ return {
128
+ tools: [
129
+ { name: "tool_a" },
130
+ { name: "tool_b" }, // Not in raw response
131
+ ],
132
+ };
133
+ });
134
+ const tools = await getToolsWithPreservedHints(mockClient);
135
+ expect(tools).toHaveLength(2);
136
+ expect(tools[0].readOnlyHint).toBe(true);
137
+ expect(tools[1].readOnlyHint).toBeUndefined();
138
+ });
139
+ it("should fallback to SDK tools when transport not available", async () => {
140
+ mockClient.transport = null;
141
+ mockClient.listTools.mockImplementation(async () => ({
142
+ tools: [{ name: "test_tool", annotations: { readOnlyHint: true } }],
143
+ }));
144
+ const tools = await getToolsWithPreservedHints(mockClient);
145
+ expect(tools).toHaveLength(1);
146
+ expect(tools[0].name).toBe("test_tool");
147
+ });
148
+ it("should fallback to SDK tools when raw capture fails", async () => {
149
+ mockClient.listTools.mockImplementation(async () => {
150
+ // Don't trigger onmessage (simulating capture failure)
151
+ return {
152
+ tools: [{ name: "fallback_tool" }],
153
+ };
154
+ });
155
+ const tools = await getToolsWithPreservedHints(mockClient);
156
+ expect(tools).toHaveLength(1);
157
+ expect(tools[0].name).toBe("fallback_tool");
158
+ });
159
+ it("should preserve hints from metadata object", async () => {
160
+ mockClient.listTools.mockImplementation(async () => {
161
+ if (mockTransport.onmessage) {
162
+ mockTransport.onmessage({
163
+ result: {
164
+ tools: [
165
+ {
166
+ name: "query_db",
167
+ metadata: {
168
+ readOnlyHint: true,
169
+ },
170
+ },
171
+ ],
172
+ },
173
+ });
174
+ }
175
+ return { tools: [{ name: "query_db" }] };
176
+ });
177
+ const tools = await getToolsWithPreservedHints(mockClient);
178
+ expect(tools[0].readOnlyHint).toBe(true);
179
+ });
180
+ it("should preserve hints from _meta object", async () => {
181
+ mockClient.listTools.mockImplementation(async () => {
182
+ if (mockTransport.onmessage) {
183
+ mockTransport.onmessage({
184
+ result: {
185
+ tools: [
186
+ {
187
+ name: "custom_tool",
188
+ _meta: {
189
+ destructiveHint: true,
190
+ },
191
+ },
192
+ ],
193
+ },
194
+ });
195
+ }
196
+ return { tools: [{ name: "custom_tool" }] };
197
+ });
198
+ const tools = await getToolsWithPreservedHints(mockClient);
199
+ expect(tools[0].destructiveHint).toBe(true);
200
+ });
201
+ it("should restore original onmessage handler after call", async () => {
202
+ const originalHandler = jest.fn();
203
+ mockTransport.onmessage = originalHandler;
204
+ mockClient.listTools.mockImplementation(async () => {
205
+ if (mockTransport.onmessage) {
206
+ mockTransport.onmessage({ result: { tools: [] } });
207
+ }
208
+ return { tools: [] };
209
+ });
210
+ await getToolsWithPreservedHints(mockClient);
211
+ // Original handler should be restored
212
+ expect(mockTransport.onmessage).toBe(originalHandler);
213
+ });
214
+ it("should restore handler even on error", async () => {
215
+ const originalHandler = jest.fn();
216
+ mockTransport.onmessage = originalHandler;
217
+ mockClient.listTools.mockImplementation(async () => {
218
+ throw new Error("Test error");
219
+ });
220
+ await expect(getToolsWithPreservedHints(mockClient)).rejects.toThrow("Test error");
221
+ // Original handler should be restored despite error
222
+ expect(mockTransport.onmessage).toBe(originalHandler);
223
+ });
224
+ });
@@ -142,6 +142,71 @@ describe("JSONL Event Emission", () => {
142
142
  emitToolDiscovered(tool);
143
143
  expect(emittedEvents[0].annotations).toBeNull();
144
144
  });
145
+ // Issue #155: Test that direct properties are detected
146
+ it("should detect annotations from direct properties (Issue #155)", () => {
147
+ // Simulate a tool with readOnlyHint as a direct property (not in annotations object)
148
+ // This is how some MCP SDKs serialize annotations
149
+ const tool = {
150
+ name: "browse_subreddit",
151
+ description: "Browse a subreddit",
152
+ inputSchema: { type: "object" },
153
+ readOnlyHint: true, // Direct property, not in annotations object
154
+ };
155
+ emitToolDiscovered(tool);
156
+ const annotations = emittedEvents[0].annotations;
157
+ expect(annotations).not.toBeNull();
158
+ expect(annotations?.readOnlyHint).toBe(true);
159
+ });
160
+ it("should detect annotations from metadata object (Issue #155)", () => {
161
+ // Simulate a tool with annotations in metadata object
162
+ const tool = {
163
+ name: "get_data",
164
+ description: "Get data",
165
+ inputSchema: { type: "object" },
166
+ metadata: {
167
+ readOnlyHint: true,
168
+ destructiveHint: false,
169
+ },
170
+ };
171
+ emitToolDiscovered(tool);
172
+ const annotations = emittedEvents[0].annotations;
173
+ expect(annotations).not.toBeNull();
174
+ expect(annotations?.readOnlyHint).toBe(true);
175
+ expect(annotations?.destructiveHint).toBe(false);
176
+ });
177
+ it("should detect annotations from _meta object (Issue #155)", () => {
178
+ // Simulate a tool with annotations in _meta object
179
+ const tool = {
180
+ name: "search_docs",
181
+ description: "Search documents",
182
+ inputSchema: { type: "object" },
183
+ _meta: {
184
+ idempotentHint: true,
185
+ openWorldHint: false,
186
+ },
187
+ };
188
+ emitToolDiscovered(tool);
189
+ const annotations = emittedEvents[0].annotations;
190
+ expect(annotations).not.toBeNull();
191
+ expect(annotations?.idempotentHint).toBe(true);
192
+ expect(annotations?.openWorldHint).toBe(false);
193
+ });
194
+ it("should prioritize tool.annotations over direct properties (Issue #155)", () => {
195
+ // When both exist, tool.annotations should take precedence
196
+ const tool = {
197
+ name: "conflicting_tool",
198
+ description: "Test priority",
199
+ inputSchema: { type: "object" },
200
+ annotations: {
201
+ readOnlyHint: false, // In annotations object
202
+ },
203
+ readOnlyHint: true, // Direct property (should be ignored)
204
+ };
205
+ emitToolDiscovered(tool);
206
+ const annotations = emittedEvents[0].annotations;
207
+ expect(annotations).not.toBeNull();
208
+ expect(annotations?.readOnlyHint).toBe(false); // Should use annotations object value
209
+ });
145
210
  });
146
211
  describe("emitToolsDiscoveryComplete", () => {
147
212
  it("should emit tools_discovery_complete with count", () => {
@@ -18,6 +18,8 @@ import { createCallToolWrapper } from "./tool-wrapper.js";
18
18
  import { buildConfig } from "./config-builder.js";
19
19
  // Issue #155: Import annotation debug mode setter
20
20
  import { setAnnotationDebugMode } from "../../../../client/lib/services/assessment/modules/annotations/AlignmentChecker.js";
21
+ // Issue #155: Import helper to preserve hint properties stripped by SDK
22
+ import { getToolsWithPreservedHints } from "./tools-with-hints.js";
21
23
  /**
22
24
  * Run full assessment against an MCP server
23
25
  *
@@ -65,9 +67,12 @@ export async function runFullAssessment(options) {
65
67
  if (!serverInfo && !options.jsonOnly) {
66
68
  console.log("⚠️ Server did not provide serverInfo during initialization");
67
69
  }
68
- const response = await client.listTools();
69
- const tools = response.tools || [];
70
+ // Issue #155: Use helper that preserves hint properties stripped by SDK Zod validation
71
+ // The SDK's listTools() validates against a schema that strips direct properties
72
+ // like readOnlyHint. This helper intercepts the raw transport response to preserve them.
73
+ const tools = await getToolsWithPreservedHints(client);
70
74
  // Emit JSONL tool discovery events for audit-worker parsing
75
+ // Tools now have hint properties preserved from raw response
71
76
  for (const tool of tools) {
72
77
  emitToolDiscovered(tool);
73
78
  }
@@ -283,9 +288,9 @@ export async function runFullAssessment(options) {
283
288
  serverName: options.serverName,
284
289
  tools,
285
290
  callTool: createCallToolWrapper(client),
291
+ // Issue #155: Use helper to preserve hint properties in refreshed tool lists
286
292
  listTools: async () => {
287
- const response = await client.listTools();
288
- return response.tools;
293
+ return getToolsWithPreservedHints(client);
289
294
  },
290
295
  config,
291
296
  sourceCodePath: options.sourceCodePath,
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Tools With Preserved Hints
3
+ *
4
+ * Issue #155: The MCP SDK's listTools() method validates responses against
5
+ * a Zod schema that strips properties not explicitly defined (like readOnlyHint
6
+ * as a direct property on tools). This module intercepts the raw transport
7
+ * response to preserve these properties.
8
+ *
9
+ * @module cli/lib/assessment-runner/tools-with-hints
10
+ */
11
+ // Hint property names we want to preserve
12
+ const HINT_PROPERTIES = [
13
+ "readOnlyHint",
14
+ "destructiveHint",
15
+ "idempotentHint",
16
+ "openWorldHint",
17
+ ];
18
+ /**
19
+ * Get tools from MCP server with hint properties preserved.
20
+ *
21
+ * The MCP SDK's listTools() validates against a Zod schema that may strip
22
+ * direct hint properties (readOnlyHint, etc.) that aren't in the schema.
23
+ * This function intercepts the raw transport response to preserve them.
24
+ *
25
+ * @param client - Connected MCP client
26
+ * @returns Tools with hint properties preserved from raw response
27
+ */
28
+ export async function getToolsWithPreservedHints(client) {
29
+ let rawTools = [];
30
+ // Get the underlying transport
31
+ const transport = client.transport;
32
+ if (!transport) {
33
+ // Fallback: just use SDK listTools if transport not accessible
34
+ const response = await client.listTools();
35
+ return response.tools || [];
36
+ }
37
+ // Store original message handler
38
+ const originalOnMessage = transport.onmessage;
39
+ // Intercept messages to capture raw tools/list response
40
+ transport.onmessage = (message, extra) => {
41
+ // Check if this is a tools/list response
42
+ const msg = message;
43
+ if (msg?.result?.tools && Array.isArray(msg.result.tools)) {
44
+ rawTools = msg.result.tools;
45
+ }
46
+ // Call original handler (cast to any to preserve original typing)
47
+ if (originalOnMessage) {
48
+ originalOnMessage(message, extra);
49
+ }
50
+ };
51
+ try {
52
+ // Make SDK call (triggers our interceptor)
53
+ const sdkResponse = await client.listTools();
54
+ const sdkTools = sdkResponse.tools || [];
55
+ // Restore original handler
56
+ transport.onmessage = originalOnMessage;
57
+ // If we didn't capture raw tools, just return SDK tools
58
+ if (rawTools.length === 0) {
59
+ return sdkTools;
60
+ }
61
+ // Merge preserved hint properties from raw into SDK tools
62
+ return sdkTools.map((sdkTool) => {
63
+ // Find matching raw tool by name
64
+ const rawTool = rawTools.find((rt) => rt.name === sdkTool.name);
65
+ if (!rawTool) {
66
+ return sdkTool;
67
+ }
68
+ // Start with SDK tool
69
+ const enrichedTool = { ...sdkTool };
70
+ // Preserve hint properties from raw response (priority order)
71
+ for (const hint of HINT_PROPERTIES) {
72
+ // Skip if SDK already has it via annotations
73
+ if (sdkTool.annotations?.[hint] !== undefined) {
74
+ continue;
75
+ }
76
+ // Check raw tool locations in priority order:
77
+ // 1. Direct property on raw tool
78
+ // 2. annotations object
79
+ // 3. metadata object
80
+ // 4. _meta object
81
+ let value;
82
+ if (typeof rawTool[hint] === "boolean") {
83
+ value = rawTool[hint];
84
+ }
85
+ else if (typeof rawTool.annotations?.[hint] === "boolean") {
86
+ value = rawTool.annotations[hint];
87
+ }
88
+ else if (typeof rawTool.metadata?.[hint] === "boolean") {
89
+ value = rawTool.metadata[hint];
90
+ }
91
+ else if (typeof rawTool._meta?.[hint] === "boolean") {
92
+ value = rawTool._meta[hint];
93
+ }
94
+ if (value !== undefined) {
95
+ enrichedTool[hint] = value;
96
+ }
97
+ }
98
+ return enrichedTool;
99
+ });
100
+ }
101
+ catch (error) {
102
+ // Restore original handler on error
103
+ transport.onmessage = originalOnMessage;
104
+ throw error;
105
+ }
106
+ }
@@ -60,16 +60,99 @@ export function extractToolParams(schema) {
60
60
  /**
61
61
  * Emit tool_discovered event for each tool found.
62
62
  * Includes annotations if the server provides them.
63
+ *
64
+ * Issue #155: Check multiple locations for annotations:
65
+ * 1. tool.annotations object (MCP 2024-11 spec)
66
+ * 2. Direct properties (tool.readOnlyHint, etc.)
67
+ * 3. tool.metadata object
68
+ * 4. tool._meta object
63
69
  */
64
70
  export function emitToolDiscovered(tool) {
65
71
  const params = extractToolParams(tool.inputSchema);
66
- // Extract annotations, null if not present
67
- const annotations = tool.annotations
72
+ // Issue #155: Extract annotations from multiple sources (priority order)
73
+ // NOTE: This is a simplified version of AlignmentChecker.extractAnnotations()
74
+ // that only checks *Hint-suffixed properties (readOnlyHint, destructiveHint, etc.),
75
+ // not non-suffixed variants like readOnly, destructive, idempotent, openWorld.
76
+ // See AlignmentChecker.resolveAnnotationValue() for full implementation with fallbacks.
77
+ const toolAny = tool;
78
+ // Priority 1: Check tool.annotations object (MCP spec)
79
+ let readOnlyHint;
80
+ let destructiveHint;
81
+ let idempotentHint;
82
+ let openWorldHint;
83
+ if (tool.annotations) {
84
+ readOnlyHint = tool.annotations.readOnlyHint;
85
+ destructiveHint = tool.annotations.destructiveHint;
86
+ idempotentHint = tool.annotations.idempotentHint;
87
+ openWorldHint = tool.annotations.openWorldHint;
88
+ }
89
+ // Priority 2: Check direct properties on tool object
90
+ // Only use if not already found in annotations
91
+ if (readOnlyHint === undefined && typeof toolAny.readOnlyHint === "boolean") {
92
+ readOnlyHint = toolAny.readOnlyHint;
93
+ }
94
+ if (destructiveHint === undefined &&
95
+ typeof toolAny.destructiveHint === "boolean") {
96
+ destructiveHint = toolAny.destructiveHint;
97
+ }
98
+ if (idempotentHint === undefined &&
99
+ typeof toolAny.idempotentHint === "boolean") {
100
+ idempotentHint = toolAny.idempotentHint;
101
+ }
102
+ if (openWorldHint === undefined &&
103
+ typeof toolAny.openWorldHint === "boolean") {
104
+ openWorldHint = toolAny.openWorldHint;
105
+ }
106
+ // Priority 3: Check tool.metadata object
107
+ const metadata = toolAny.metadata;
108
+ if (metadata) {
109
+ if (readOnlyHint === undefined &&
110
+ typeof metadata.readOnlyHint === "boolean") {
111
+ readOnlyHint = metadata.readOnlyHint;
112
+ }
113
+ if (destructiveHint === undefined &&
114
+ typeof metadata.destructiveHint === "boolean") {
115
+ destructiveHint = metadata.destructiveHint;
116
+ }
117
+ if (idempotentHint === undefined &&
118
+ typeof metadata.idempotentHint === "boolean") {
119
+ idempotentHint = metadata.idempotentHint;
120
+ }
121
+ if (openWorldHint === undefined &&
122
+ typeof metadata.openWorldHint === "boolean") {
123
+ openWorldHint = metadata.openWorldHint;
124
+ }
125
+ }
126
+ // Priority 4: Check tool._meta object
127
+ const _meta = toolAny._meta;
128
+ if (_meta) {
129
+ if (readOnlyHint === undefined && typeof _meta.readOnlyHint === "boolean") {
130
+ readOnlyHint = _meta.readOnlyHint;
131
+ }
132
+ if (destructiveHint === undefined &&
133
+ typeof _meta.destructiveHint === "boolean") {
134
+ destructiveHint = _meta.destructiveHint;
135
+ }
136
+ if (idempotentHint === undefined &&
137
+ typeof _meta.idempotentHint === "boolean") {
138
+ idempotentHint = _meta.idempotentHint;
139
+ }
140
+ if (openWorldHint === undefined &&
141
+ typeof _meta.openWorldHint === "boolean") {
142
+ openWorldHint = _meta.openWorldHint;
143
+ }
144
+ }
145
+ // Build annotations object if any hints were found
146
+ const hasAnnotations = readOnlyHint !== undefined ||
147
+ destructiveHint !== undefined ||
148
+ idempotentHint !== undefined ||
149
+ openWorldHint !== undefined;
150
+ const annotations = hasAnnotations
68
151
  ? {
69
- readOnlyHint: tool.annotations.readOnlyHint,
70
- destructiveHint: tool.annotations.destructiveHint,
71
- idempotentHint: tool.annotations.idempotentHint,
72
- openWorldHint: tool.annotations.openWorldHint,
152
+ readOnlyHint,
153
+ destructiveHint,
154
+ idempotentHint,
155
+ openWorldHint,
73
156
  }
74
157
  : null;
75
158
  emitJSONL({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bryan-thompson/inspector-assessment-cli",
3
- "version": "1.36.1",
3
+ "version": "1.36.3",
4
4
  "description": "CLI for the Enhanced MCP Inspector with assessment capabilities",
5
5
  "license": "MIT",
6
6
  "author": "Bryan Thompson <bryan@triepod.ai>",