fix: exclude undefined from _meta values, and cover the JSON worker
- `JsonValue` admits `undefined`, so `RequestMetadata` accepted values that do not survive serialization: `JSON.stringify` drops an `undefined` object member and rewrites an `undefined` array element to `null`. The type contradicted the one promise this payload makes. Added `StrictJsonValue` / `StrictJsonObject` and used them for the metadata path. Deliberately not a change to the repo-wide `JsonValue`: for tool *arguments* `undefined` legitimately means "not supplied". The narrowing is sound wherever it lands here, since every one of these values comes from `JSON.parse`, which cannot produce `undefined`. The CLI's `parseKeyValuePair` needed the same tightening — caught by `clients/cli`'s typecheck, which the web `tsc -b` does not cover. - Nothing asserted the JSON worker's annotations, and the wrapper's own "Not valid JSON" message comes from our parser rather than from Ace — so a broken worker URL would have left every test passing while the gutter diagnostics silently disappeared. Added a Storybook play function that waits for a real `session.getAnnotations()` error entry, and verified it fails when the worker module URL is pointed at a bad path. - `aria-describedby` referenced only the error, so a supplied `description` was never announced and would have been dropped entirely once the draft went invalid. Both ids are referenced now, with the description surviving the error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
1eaebdaf63
commit
616dd9c196
7 changed files with 151 additions and 30 deletions
|
|
@ -24,6 +24,7 @@ import {
|
|||
parseHeaderPair,
|
||||
} from "@inspector/core/mcp/node/index.js";
|
||||
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
||||
import type { StrictJsonValue } from "@inspector/core/json/jsonUtils.js";
|
||||
import {
|
||||
canonicalUrlHost,
|
||||
isAllInterfacesHost,
|
||||
|
|
@ -473,8 +474,8 @@ function buildHandoff(
|
|||
|
||||
function parseKeyValuePair(
|
||||
value: string,
|
||||
previous: Record<string, JsonValue> = {},
|
||||
): Record<string, JsonValue> {
|
||||
previous: Record<string, StrictJsonValue> = {},
|
||||
): Record<string, StrictJsonValue> {
|
||||
const parts = value.split("=");
|
||||
const key = parts[0];
|
||||
const val = parts.slice(1).join("=");
|
||||
|
|
@ -485,9 +486,11 @@ function parseKeyValuePair(
|
|||
);
|
||||
}
|
||||
|
||||
let parsedValue: JsonValue;
|
||||
// `StrictJsonValue`: `JSON.parse` cannot produce `undefined`, and these values
|
||||
// become `_meta`, which must reach the wire exactly as written (#1910).
|
||||
let parsedValue: StrictJsonValue;
|
||||
try {
|
||||
parsedValue = JSON.parse(val) as JsonValue;
|
||||
parsedValue = JSON.parse(val) as StrictJsonValue;
|
||||
} catch {
|
||||
parsedValue = val;
|
||||
}
|
||||
|
|
@ -735,8 +738,8 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
|
|||
promptName?: string;
|
||||
promptArgs?: Record<string, JsonValue>;
|
||||
logLevel?: LoggingLevel;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
toolMetadata?: Record<string, JsonValue>;
|
||||
metadata?: Record<string, StrictJsonValue>;
|
||||
toolMetadata?: Record<string, StrictJsonValue>;
|
||||
cwd?: string;
|
||||
transport?: "sse" | "http" | "stdio";
|
||||
serverUrl?: string;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import type { JsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||
import { expect, userEvent, waitFor, within } from "storybook/test";
|
||||
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||
import { JsonObjectInput } from "./JsonObjectInput";
|
||||
|
||||
const LABEL = "Request metadata JSON";
|
||||
|
|
@ -15,8 +15,8 @@ const LABEL = "Request metadata JSON";
|
|||
* place Ace's keyboard behavior can be tested: the unit suite runs in
|
||||
* happy-dom, where Ace's hidden-textarea input path does not work at all.
|
||||
*/
|
||||
function ControlledJsonObjectInput({ initial }: { initial: JsonObject }) {
|
||||
const [value, setValue] = useState<JsonObject>(initial);
|
||||
function ControlledJsonObjectInput({ initial }: { initial: StrictJsonObject }) {
|
||||
const [value, setValue] = useState<StrictJsonObject>(initial);
|
||||
return (
|
||||
<JsonObjectInput
|
||||
label="Request metadata"
|
||||
|
|
@ -40,6 +40,7 @@ function aceEditor(canvasElement: HTMLElement): {
|
|||
focus(): void;
|
||||
selectAll(): void;
|
||||
getValue(): string;
|
||||
session: { getAnnotations(): { row: number; type: string }[] };
|
||||
} {
|
||||
const container = canvasElement.querySelector(".ace_editor");
|
||||
const editor = (
|
||||
|
|
@ -50,6 +51,7 @@ function aceEditor(canvasElement: HTMLElement): {
|
|||
focus(): void;
|
||||
selectAll(): void;
|
||||
getValue(): string;
|
||||
session: { getAnnotations(): { row: number; type: string }[] };
|
||||
};
|
||||
};
|
||||
})
|
||||
|
|
@ -182,3 +184,34 @@ export const NotAnObject: Story = {
|
|||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The JSON worker marks the offending line in the gutter — the reason this
|
||||
* component registers `worker-json` through `ace.config.setModuleUrl` with a
|
||||
* Vite `?url` asset.
|
||||
*
|
||||
* Asserted because nothing else covers it: the worker runs out-of-process, so
|
||||
* if its URL registration breaks, Ace fails to start it and silently falls back
|
||||
* to no annotations. Every other test here — including the wrapper's own
|
||||
* "Not valid JSON" message, which comes from our parser, not from Ace — would
|
||||
* still pass while the gutter diagnostics quietly disappeared.
|
||||
*/
|
||||
export const AnnotatesTheOffendingLine: Story = {
|
||||
args: { initial: {} },
|
||||
play: async ({ canvasElement }) => {
|
||||
await focusAndClear(canvasElement);
|
||||
// A trailing comma on line 2: valid-looking until the closing brace, so the
|
||||
// worker has to locate it rather than just reject the document.
|
||||
await userEvent.keyboard('{{"a": 1,{Enter}}');
|
||||
await waitFor(
|
||||
async () => {
|
||||
const annotations = aceEditor(canvasElement).session.getAnnotations();
|
||||
await expect(annotations.length).toBeGreaterThan(0);
|
||||
await expect(annotations.some((a) => a.type === "error")).toBe(true);
|
||||
},
|
||||
// The worker is asynchronous and debounced, so this needs a real wait
|
||||
// rather than a tick.
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { act, screen } from "@testing-library/react";
|
||||
import type { JsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||
import { renderWithMantine } from "../../../test/renderWithMantine";
|
||||
import { aceLabel, getAceText, setAceText } from "../../../test/aceEditor";
|
||||
import { JsonObjectInput } from "./JsonObjectInput";
|
||||
|
|
@ -13,10 +13,10 @@ function Harness({
|
|||
initial,
|
||||
onChange,
|
||||
}: {
|
||||
initial: JsonObject;
|
||||
onChange?: (v: JsonObject) => void;
|
||||
initial: StrictJsonObject;
|
||||
onChange?: (v: StrictJsonObject) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState<JsonObject>(initial);
|
||||
const [value, setValue] = useState<StrictJsonObject>(initial);
|
||||
return (
|
||||
<>
|
||||
<JsonObjectInput
|
||||
|
|
@ -109,7 +109,7 @@ describe("JsonObjectInput", () => {
|
|||
// An external reset (switching servers, a reloaded catalog) must reach the
|
||||
// editor; an in-progress edit must not be overwritten by what it emitted.
|
||||
function Externally() {
|
||||
const [value, setValue] = useState<JsonObject>({ a: 1 });
|
||||
const [value, setValue] = useState<StrictJsonObject>({ a: 1 });
|
||||
return (
|
||||
<>
|
||||
<JsonObjectInput
|
||||
|
|
@ -132,7 +132,7 @@ describe("JsonObjectInput", () => {
|
|||
// Regression: `echoed` used to hold only what this component last emitted,
|
||||
// so returning to a previously-shown value matched the stale entry and was
|
||||
// dismissed as our own echo, stranding the editor on B.
|
||||
function Externally({ value }: { value: JsonObject }) {
|
||||
function Externally({ value }: { value: StrictJsonObject }) {
|
||||
return (
|
||||
<JsonObjectInput ariaLabel={LABEL} value={value} onChange={() => {}} />
|
||||
);
|
||||
|
|
@ -208,6 +208,38 @@ describe("JsonObjectInput", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("announces a supplied description, and keeps it when the draft breaks", async () => {
|
||||
function WithDescription() {
|
||||
const [value, setValue] = useState<StrictJsonObject>({});
|
||||
return (
|
||||
<JsonObjectInput
|
||||
ariaLabel={LABEL}
|
||||
description="Sent with every request"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
renderWithMantine(<WithDescription />);
|
||||
|
||||
const described = () =>
|
||||
(textarea().getAttribute("aria-describedby") ?? "")
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((id) => document.getElementById(id)?.textContent);
|
||||
|
||||
// Valid draft: described by the description alone.
|
||||
expect(described()).toEqual(["Sent with every request"]);
|
||||
|
||||
// Invalid draft: the error joins it rather than replacing it — the
|
||||
// description is still true, and dropping it would silently un-describe
|
||||
// the control at the moment it needs explaining most.
|
||||
await setAceText("[1]");
|
||||
expect(described()).toHaveLength(2);
|
||||
expect(described()[0]).toBe("Sent with every request");
|
||||
expect(described()[1]).toMatch(/Must be a JSON object/);
|
||||
});
|
||||
|
||||
it("clears the invalid marking once the text parses again", async () => {
|
||||
renderWithMantine(<Harness initial={{}} />);
|
||||
await setAceText("[1]");
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import "ace-builds/src-noconflict/theme-github_dark";
|
|||
// Without it Ace fetches `worker-json.js` from a path that does not exist in a
|
||||
// bundled app, silently loses its annotations, and logs a fetch error.
|
||||
import jsonWorkerUrl from "ace-builds/src-noconflict/worker-json.js?url";
|
||||
import type { JsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||
import { useValueChange } from "../../../hooks/useValueChange";
|
||||
import { parseJsonObjectDraft } from "../../../utils/jsonObjectDraft";
|
||||
|
||||
|
|
@ -19,13 +19,13 @@ import { parseJsonObjectDraft } from "../../../utils/jsonObjectDraft";
|
|||
ace.config.setModuleUrl("ace/mode/json_worker", jsonWorkerUrl);
|
||||
|
||||
/** Two-space canonical form — the same shape the catalog file is written in. */
|
||||
function serialize(value: JsonObject): string {
|
||||
function serialize(value: StrictJsonObject): string {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
export interface JsonObjectInputProps {
|
||||
value: JsonObject;
|
||||
onChange: (value: JsonObject) => void;
|
||||
value: StrictJsonObject;
|
||||
onChange: (value: StrictJsonObject) => void;
|
||||
label?: string;
|
||||
description?: string;
|
||||
/**
|
||||
|
|
@ -167,14 +167,29 @@ export function JsonObjectInput({
|
|||
synchronously on mount, so this effect always sees an element. */
|
||||
if (!element) return;
|
||||
element.id = wrapperId;
|
||||
|
||||
// Both the description and the error are rendered by the wrapper and both
|
||||
// describe this control, so both ids belong here — a supplied description
|
||||
// is otherwise never announced, and it must not be dropped just because the
|
||||
// draft went invalid. Mantine renders each only when its prop is set, so
|
||||
// referencing an absent one would point at nothing.
|
||||
const describedBy = [
|
||||
description === undefined ? null : `${wrapperId}-description`,
|
||||
parsed.ok ? null : `${wrapperId}-error`,
|
||||
].filter((id) => id !== null);
|
||||
|
||||
if (describedBy.length > 0) {
|
||||
element.setAttribute("aria-describedby", describedBy.join(" "));
|
||||
} else {
|
||||
element.removeAttribute("aria-describedby");
|
||||
}
|
||||
|
||||
if (parsed.ok) {
|
||||
element.removeAttribute("aria-invalid");
|
||||
element.removeAttribute("aria-describedby");
|
||||
} else {
|
||||
element.setAttribute("aria-invalid", "true");
|
||||
element.setAttribute("aria-describedby", `${wrapperId}-error`);
|
||||
}
|
||||
}, [wrapperId, parsed.ok]);
|
||||
}, [wrapperId, parsed.ok, description]);
|
||||
|
||||
return (
|
||||
<Input.Wrapper
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
import type { JsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||
|
||||
/**
|
||||
* What a JSON-object editor's draft text currently means: the object to emit,
|
||||
* or why it cannot be emitted.
|
||||
*/
|
||||
export type JsonObjectDraft =
|
||||
| { ok: true; value: JsonObject }
|
||||
| { ok: true; value: StrictJsonObject }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** A plain JSON object, i.e. not an array and not a scalar. */
|
||||
export function isJsonObject(value: unknown): value is JsonObject {
|
||||
/**
|
||||
* A plain JSON object, i.e. not an array and not a scalar.
|
||||
*
|
||||
* The `StrictJsonObject` narrowing is sound because the only caller parses with
|
||||
* `JSON.parse`, whose output cannot contain `undefined` — the value that
|
||||
* separates the strict type from `JsonValue`.
|
||||
*/
|
||||
export function isJsonObject(value: unknown): value is StrictJsonObject {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,29 @@ export type JsonValue =
|
|||
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
|
||||
/**
|
||||
* A JSON value that survives serialization unchanged — {@link JsonValue}
|
||||
* without `undefined`.
|
||||
*
|
||||
* `JsonValue` admits `undefined` because it is also used for *arguments*, where
|
||||
* it usefully means "this field was not supplied". That meaning does not carry
|
||||
* to a payload the Inspector promises to transmit verbatim: `JSON.stringify`
|
||||
* drops an `undefined` object member entirely and rewrites an `undefined` array
|
||||
* element to `null`, so a value the type accepted is not the value that arrives.
|
||||
*
|
||||
* Used for `_meta` (#1910), whose whole contract is that any JSON the user
|
||||
* writes reaches the wire as written.
|
||||
*/
|
||||
export type StrictJsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| StrictJsonValue[]
|
||||
| { [key: string]: StrictJsonValue };
|
||||
|
||||
export type StrictJsonObject = { [key: string]: StrictJsonValue };
|
||||
|
||||
/**
|
||||
* Widen a typed object to a generic string-keyed record so its keys can be
|
||||
* iterated or read/written generically. Many of the project's config/SDK types
|
||||
|
|
|
|||
|
|
@ -24,7 +24,11 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/client";
|
|||
import type { Transport } from "@modelcontextprotocol/client";
|
||||
import type { InspectorLogger } from "../logging/logger.js";
|
||||
import type { AppElicitationRenderer } from "./appElicitation.js";
|
||||
import type { JsonObject, JsonValue } from "../json/jsonUtils.js";
|
||||
import type {
|
||||
JsonValue,
|
||||
StrictJsonObject,
|
||||
StrictJsonValue,
|
||||
} from "../json/jsonUtils.js";
|
||||
import type {
|
||||
ClientConfig,
|
||||
EnterpriseManagedAuthIdpConfig,
|
||||
|
|
@ -112,7 +116,7 @@ export type StoredMCPServer = MCPServerConfig & {
|
|||
* existing `mcp.json` keeps working, but it is never written back — a
|
||||
* round-trip through the Inspector rewrites the field as an object.
|
||||
*/
|
||||
metadata?: JsonObject;
|
||||
metadata?: StrictJsonObject;
|
||||
/**
|
||||
* Protocol era to negotiate with this server (`"legacy" | "auto" | "modern"`),
|
||||
* orthogonal to the transport `type`. Inspector-specific (no analog in the
|
||||
|
|
@ -374,6 +378,11 @@ export interface ServerState {
|
|||
* A `_meta` payload: a JSON object whose values may be any JSON, not just
|
||||
* strings (#1910).
|
||||
*
|
||||
* {@link StrictJsonValue}, not `JsonValue`: the latter admits `undefined`,
|
||||
* which `JSON.stringify` drops from an object and turns into `null` inside an
|
||||
* array — so a value the type accepted would not be the value that reaches the
|
||||
* server, contradicting the one promise this payload makes.
|
||||
*
|
||||
* The MCP spec places no restriction on `_meta` value types and the SDK models
|
||||
* the field as a passthrough object, so the Inspector must be able to send —
|
||||
* and to record — nested objects, arrays, numbers, booleans and `null`. Used
|
||||
|
|
@ -381,7 +390,7 @@ export interface ServerState {
|
|||
* `InspectorClientOptions.defaultMetadata`) and the per-call metadata every
|
||||
* request verb accepts.
|
||||
*/
|
||||
export type RequestMetadata = Record<string, JsonValue>;
|
||||
export type RequestMetadata = Record<string, StrictJsonValue>;
|
||||
|
||||
/**
|
||||
* Represents a complete resource read invocation, including request parameters,
|
||||
|
|
|
|||
Loading…
Reference in a new issue