Skip to content

Commit dced81d

Browse files
authored
Merge pull request #849 from olaservo/issue-846
Add input validation guidelines and fix the case where there are null defaults or tri-state booleans
2 parents f216d42 + 7f6a276 commit dced81d

File tree

5 files changed

+239
-2
lines changed

5 files changed

+239
-2
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,17 @@ npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --me
458458
| **Automation** | N/A | Ideal for CI/CD pipelines, batch processing, and integration with coding assistants |
459459
| **Learning MCP** | Rich visual interface helps new users understand server capabilities | Simplified commands for focused learning of specific endpoints |
460460

461+
## Tool Input Validation Guidelines
462+
463+
When implementing or modifying tool input parameter handling in the Inspector:
464+
465+
- **Omit optional fields with empty values** - When processing form inputs, omit empty strings or null values for optional parameters, UNLESS the field has an explicit default value in the schema that matches the current value
466+
- **Preserve explicit default values** - If a field schema contains an explicit default (e.g., `default: null`), and the current value matches that default, include it in the request. This is a meaningful value the tool expects
467+
- **Always include required fields** - Preserve required field values even when empty, allowing the MCP server to validate and return appropriate error messages
468+
- **Defer deep validation to the server** - Implement basic field presence checking in the Inspector client, but rely on the MCP server for parameter validation according to its schema
469+
470+
These guidelines maintain clean parameter passing and proper separation of concerns between the Inspector client and MCP servers.
471+
461472
## License
462473

463474
This project is licensed under the MIT License—see the [LICENSE](LICENSE) file for details.

client/src/components/ToolsTab.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,18 @@ const ToolsTab = ({
179179
onCheckedChange={(checked: boolean) =>
180180
setParams({
181181
...params,
182-
[key]: checked ? null : prop.default,
182+
[key]: checked
183+
? null
184+
: prop.default !== null
185+
? prop.default
186+
: prop.type === "boolean"
187+
? false
188+
: prop.type === "string"
189+
? ""
190+
: prop.type === "number" ||
191+
prop.type === "integer"
192+
? undefined
193+
: undefined,
183194
})
184195
}
185196
/>

client/src/components/__tests__/ToolsTab.test.tsx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,103 @@ describe("ToolsTab", () => {
177177
});
178178
});
179179

180+
it("should support tri-state nullable boolean (null -> false -> true -> null)", async () => {
181+
const mockCallTool = jest.fn();
182+
const toolWithNullableBoolean: Tool = {
183+
name: "testTool",
184+
description: "Tool with nullable boolean",
185+
inputSchema: {
186+
type: "object" as const,
187+
properties: {
188+
optionalBoolean: {
189+
type: ["boolean", "null"] as const,
190+
default: null,
191+
},
192+
},
193+
},
194+
};
195+
196+
renderToolsTab({
197+
tools: [toolWithNullableBoolean],
198+
selectedTool: toolWithNullableBoolean,
199+
callTool: mockCallTool,
200+
});
201+
202+
const nullCheckbox = screen.getByRole("checkbox", { name: /null/i });
203+
const runButton = screen.getByRole("button", { name: /run tool/i });
204+
205+
// State 1: Initial state should be null (input disabled)
206+
const wrapper = screen.getByRole("toolinputwrapper");
207+
expect(wrapper.classList).toContain("pointer-events-none");
208+
expect(wrapper.classList).toContain("opacity-50");
209+
210+
// Verify tool is called with null initially
211+
await act(async () => {
212+
fireEvent.click(runButton);
213+
});
214+
expect(mockCallTool).toHaveBeenCalledWith(toolWithNullableBoolean.name, {
215+
optionalBoolean: null,
216+
});
217+
218+
// State 2: Uncheck null checkbox -> should set value to false and enable input
219+
await act(async () => {
220+
fireEvent.click(nullCheckbox);
221+
});
222+
expect(wrapper.classList).not.toContain("pointer-events-none");
223+
224+
// Clear previous calls to make assertions clearer
225+
mockCallTool.mockClear();
226+
227+
// Verify tool can be called with false
228+
await act(async () => {
229+
fireEvent.click(runButton);
230+
});
231+
expect(mockCallTool).toHaveBeenLastCalledWith(
232+
toolWithNullableBoolean.name,
233+
{
234+
optionalBoolean: false,
235+
},
236+
);
237+
238+
// State 3: Check boolean checkbox -> should set value to true
239+
// Find the boolean checkbox within the input wrapper (to avoid ID conflict with null checkbox)
240+
const booleanCheckbox = within(wrapper).getByRole("checkbox");
241+
242+
mockCallTool.mockClear();
243+
244+
await act(async () => {
245+
fireEvent.click(booleanCheckbox);
246+
});
247+
248+
// Verify tool can be called with true
249+
await act(async () => {
250+
fireEvent.click(runButton);
251+
});
252+
expect(mockCallTool).toHaveBeenLastCalledWith(
253+
toolWithNullableBoolean.name,
254+
{
255+
optionalBoolean: true,
256+
},
257+
);
258+
259+
// State 4: Check null checkbox again -> should set value back to null and disable input
260+
await act(async () => {
261+
fireEvent.click(nullCheckbox);
262+
});
263+
expect(wrapper.classList).toContain("pointer-events-none");
264+
265+
// Verify tool can be called with null again
266+
await act(async () => {
267+
fireEvent.click(runButton);
268+
});
269+
expect(mockCallTool).toHaveBeenLastCalledWith(
270+
toolWithNullableBoolean.name,
271+
{
272+
optionalBoolean: null,
273+
},
274+
);
275+
});
276+
180277
it("should disable button and change text while tool is running", async () => {
181278
// Create a promise that we can resolve later
182279
let resolvePromise: ((value: unknown) => void) | undefined;

client/src/utils/__tests__/paramUtils.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,112 @@ describe("cleanParams", () => {
205205
// optionalField omitted entirely
206206
});
207207
});
208+
209+
it("should preserve null values when field has default: null", () => {
210+
const schema: JsonSchemaType = {
211+
type: "object",
212+
required: [],
213+
properties: {
214+
optionalFieldWithNullDefault: { type: "string", default: null },
215+
optionalFieldWithoutDefault: { type: "string" },
216+
},
217+
};
218+
219+
const params = {
220+
optionalFieldWithNullDefault: null,
221+
optionalFieldWithoutDefault: null,
222+
};
223+
224+
const cleaned = cleanParams(params, schema);
225+
226+
expect(cleaned).toEqual({
227+
optionalFieldWithNullDefault: null, // preserved because default: null
228+
// optionalFieldWithoutDefault omitted
229+
});
230+
});
231+
232+
it("should preserve default values that match current value", () => {
233+
const schema: JsonSchemaType = {
234+
type: "object",
235+
required: [],
236+
properties: {
237+
fieldWithDefaultString: { type: "string", default: "defaultValue" },
238+
fieldWithDefaultNumber: { type: "number", default: 42 },
239+
fieldWithDefaultNull: { type: "string", default: null },
240+
fieldWithDefaultBoolean: { type: "boolean", default: false },
241+
},
242+
};
243+
244+
const params = {
245+
fieldWithDefaultString: "defaultValue",
246+
fieldWithDefaultNumber: 42,
247+
fieldWithDefaultNull: null,
248+
fieldWithDefaultBoolean: false,
249+
};
250+
251+
const cleaned = cleanParams(params, schema);
252+
253+
expect(cleaned).toEqual({
254+
fieldWithDefaultString: "defaultValue",
255+
fieldWithDefaultNumber: 42,
256+
fieldWithDefaultNull: null,
257+
fieldWithDefaultBoolean: false,
258+
});
259+
});
260+
261+
it("should omit values that do not match their default", () => {
262+
const schema: JsonSchemaType = {
263+
type: "object",
264+
required: [],
265+
properties: {
266+
fieldWithDefault: { type: "string", default: "defaultValue" },
267+
},
268+
};
269+
270+
const params = {
271+
fieldWithDefault: null, // doesn't match default
272+
};
273+
274+
const cleaned = cleanParams(params, schema);
275+
276+
expect(cleaned).toEqual({
277+
// fieldWithDefault omitted because value (null) doesn't match default ("defaultValue")
278+
});
279+
});
280+
281+
it("should fix regression from issue #846 - tools with multiple null defaults", () => {
282+
// Reproduces the exact scenario from https://github.com/modelcontextprotocol/inspector/issues/846
283+
// In v0.17.0, the cleanParams function would remove all null values,
284+
// breaking tools that have parameters with explicit default: null
285+
const schema: JsonSchemaType = {
286+
type: "object",
287+
required: ["requiredString"],
288+
properties: {
289+
optionalString: { type: ["string", "null"], default: null },
290+
optionalNumber: { type: ["number", "null"], default: null },
291+
optionalBoolean: { type: ["boolean", "null"], default: null },
292+
requiredString: { type: "string" },
293+
},
294+
};
295+
296+
// When a user opens the tool in Inspector, fields initialize with their defaults
297+
const params = {
298+
optionalString: null, // initialized to default
299+
optionalNumber: null, // initialized to default
300+
optionalBoolean: null, // initialized to default
301+
requiredString: "test",
302+
};
303+
304+
const cleaned = cleanParams(params, schema);
305+
306+
// In v0.16, null defaults were preserved (working behavior)
307+
// In v0.17.0, they were removed (regression)
308+
// This fix restores the v0.16 behavior
309+
expect(cleaned).toEqual({
310+
optionalString: null,
311+
optionalNumber: null,
312+
optionalBoolean: null,
313+
requiredString: "test",
314+
});
315+
});
208316
});

client/src/utils/paramUtils.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { JsonSchemaType } from "./jsonUtils";
22

33
/**
44
* Cleans parameters by removing undefined, null, and empty string values for optional fields
5-
* while preserving all values for required fields.
5+
* while preserving all values for required fields and fields with explicit default values.
66
*
77
* @param params - The parameters object to clean
88
* @param schema - The JSON schema defining which fields are required
@@ -14,13 +14,23 @@ export function cleanParams(
1414
): Record<string, unknown> {
1515
const cleaned: Record<string, unknown> = {};
1616
const required = schema.required || [];
17+
const properties = schema.properties || {};
1718

1819
for (const [key, value] of Object.entries(params)) {
1920
const isFieldRequired = required.includes(key);
21+
const fieldSchema = properties[key] as JsonSchemaType | undefined;
22+
23+
// Check if the field has an explicit default value
24+
const hasDefault = fieldSchema && "default" in fieldSchema;
25+
const defaultValue = hasDefault ? fieldSchema.default : undefined;
2026

2127
if (isFieldRequired) {
2228
// Required fields: always include, even if empty string or falsy
2329
cleaned[key] = value;
30+
} else if (hasDefault && value === defaultValue) {
31+
// Field has a default value and current value matches it - preserve it
32+
// This is important for cases like default: null
33+
cleaned[key] = value;
2434
} else {
2535
// Optional fields: only include if they have meaningful values
2636
if (value !== undefined && value !== "" && value !== null) {

0 commit comments

Comments
 (0)