chore: close quality-gate coverage gaps (#1689) (#1766)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled

Closes #1689. Typecheck cli/tui src, add a core/ format+lint gate, and gate core/json + core/client coverage. See PR #1766 for the full review history.
This commit is contained in:
Cliff Hall 2026-07-24 17:20:12 -04:00 • committed by GitHub
commit 373f346e5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2210 additions and 467 deletions

View file

@ -16,7 +16,8 @@
],
"scripts": {
"build": "tsup",
"validate": "npm run format:check && npm run lint && npm run test",
"typecheck": "tsc --noEmit -p tsconfig.json",
"validate": "npm run format:check && npm run lint && npm run typecheck && npm run test",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "npm run test-servers:build && npm run build && vitest run --coverage",

View file

@ -120,6 +120,20 @@ type MethodOutcome =
| { kind: "result"; result: McpResponse; appInfo?: CliAppInfo }
| { kind: "emitted" };
/**
* Tear down a managed list state if it was created for this method call.
*
* The `managed*State` locals below are assigned inside the `runMethod` closure,
* so TypeScript's control-flow analysis keeps them narrowed to `null` at the
* outer `finally` block (a closure "might not have run"). Routing the teardown
* through this helper — whose parameter type is the shared `destroy()` shape —
* both documents that and calls `destroy()` on whatever the closure actually
* assigned at runtime, with no `as` cast.
*/
function destroyManagedState(state: { destroy(): void } | null): void {
state?.destroy();
}
async function callMethod(
serverConfig: MCPServerConfig,
serverSettings: InspectorServerSettings | undefined,
@ -370,10 +384,10 @@ async function callMethod(
await emitResult(outcome.result, outcome.appInfo, args);
}
} finally {
managedToolsState?.destroy();
managedResourcesState?.destroy();
managedResourceTemplatesState?.destroy();
managedPromptsState?.destroy();
destroyManagedState(managedToolsState);
destroyManagedState(managedResourcesState);
destroyManagedState(managedResourceTemplatesState);
destroyManagedState(managedPromptsState);
await inspectorClient.disconnect();
}
}

View file

@ -2,10 +2,24 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
// Match clients/web/tsconfig.app.json's module/lib *resolution* options so
// the imported `core/` sources are validated the same way web's own gate
// validates them: DOM lib + bundler resolution are what core/ (browser-side
// auth, DOM types) expects, and `noUncheckedIndexedAccess` is off because
// web's app config does not extend tsconfig.base — re-enabling it here would
// flag core/ issues web never gates. This does NOT mirror web's extra
// strictness flags (noUnusedLocals, verbatimModuleSyntax, ES2023 target,
// …), so cli/tui's own src is checked slightly more loosely than web's src.
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["node"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"module": "ESNext",
"noUncheckedIndexedAccess": false,
"paths": {
"@inspector/core/*": ["../../core/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts", "build"]
"exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx", "build"]
}

View file

@ -11,6 +11,7 @@ import type {
MCPServerConfig,
ServerState,
} from "@inspector/core/mcp/index.js";
import { headersToServerSettings } from "@inspector/core/mcp/node/servers.js";
import { InfoTab } from "../src/components/InfoTab.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write
@ -127,14 +128,11 @@ describe("InfoTab", () => {
type: "sse",
url: "https://example.com/sse",
};
const withHeaders = {
...config,
headers: { Authorization: "Bearer x" },
} as unknown as MCPServerConfig;
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={withHeaders}
serverConfig={config}
serverSettings={headersToServerSettings({ Authorization: "Bearer x" })}
serverState={baseState}
width={80}
height={20}
@ -167,15 +165,15 @@ describe("InfoTab", () => {
});
it("renders a streamable-http config with headers", () => {
const config = {
const config: MCPServerConfig = {
type: "streamable-http",
url: "https://example.com/mcp",
headers: { "X-Key": "abc" },
} as unknown as MCPServerConfig;
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={config}
serverSettings={headersToServerSettings({ "X-Key": "abc" })}
serverState={baseState}
width={80}
height={20}

View file

@ -13,6 +13,23 @@ describe("schemaToForm", () => {
expect(form.sections[0]?.fields).toEqual([]);
});
it("treats a non-object property value as an empty schema instead of throwing", () => {
// A malformed server schema whose property value is null/primitive must not
// crash — the field degrades to a plain string input labelled by its key.
const form = schemaToForm(
{ properties: { bad: null, worse: 42 } },
"malformed",
);
const fields = form.sections[0]?.fields ?? [];
expect(fields).toHaveLength(2);
expect(
fields.map((f) => ({ name: f.name, type: f.type, label: f.label })),
).toEqual([
{ name: "bad", type: "string", label: "bad" },
{ name: "worse", type: "string", label: "worse" },
]);
});
it("maps each JSON Schema type to the matching ink-form field type", () => {
const form = schemaToForm(
{

View file

@ -18,7 +18,8 @@
"scripts": {
"dev": "vite-node --config vitest.config.ts dev.ts",
"build": "tsup",
"validate": "npm run format:check && npm run lint && npm run build && npm run test",
"typecheck": "tsc --noEmit -p tsconfig.json",
"validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint .",

View file

@ -1660,6 +1660,7 @@ function App({
<InfoTab
serverName={selectedServer}
serverConfig={selectedServerConfig}
serverSettings={selectedServerEntry?.settings ?? null}
serverState={currentServerState}
width={contentWidth}
height={contentHeight}

View file

@ -5,10 +5,16 @@ import type {
MCPServerConfig,
ServerState,
} from "@inspector/core/mcp/index.js";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
interface InfoTabProps {
serverName: string | null;
serverConfig: MCPServerConfig | null;
// HTTP headers live on the per-server settings (pair-array form), not on the
// transport `serverConfig` — `mcpConfigToServerEntries` lifts the on-disk
// `headers` map into `InspectorServerSettings.headers`. Read them here so the
// SSE / streamable-http header display reflects the real source.
serverSettings?: InspectorServerSettings | null;
serverState: ServerState | null;
width: number;
height: number;
@ -18,11 +24,24 @@ interface InfoTabProps {
export function InfoTab({
serverName,
serverConfig,
serverSettings = null,
serverState,
width,
height,
focused = false,
}: InfoTabProps) {
const headerPairs = serverSettings?.headers ?? [];
// Shared header display for the sse / streamable-http branches (identical for
// both transports — the header source is `serverSettings`, not the transport).
const headersBlock =
headerPairs.length > 0 ? (
<Box marginTop={1}>
<Text dimColor>
Headers:{" "}
{headerPairs.map(({ key, value }) => `${key}=${value}`).join(", ")}
</Text>
</Box>
) : null;
const scrollViewRef = useRef<ScrollViewRef>(null);
// Handle keyboard input for scrolling
@ -111,33 +130,13 @@ export function InfoTab({
<>
<Text dimColor>Type: sse</Text>
<Text dimColor>URL: {serverConfig.url}</Text>
{serverConfig.headers &&
Object.keys(serverConfig.headers).length > 0 && (
<Box marginTop={1}>
<Text dimColor>
Headers:{" "}
{Object.entries(serverConfig.headers)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}
</Text>
</Box>
)}
{headersBlock}
</>
) : serverConfig.type === "streamable-http" ? (
<>
<Text dimColor>Type: streamable-http</Text>
<Text dimColor>URL: {serverConfig.url}</Text>
{serverConfig.headers &&
Object.keys(serverConfig.headers).length > 0 && (
<Box marginTop={1}>
<Text dimColor>
Headers:{" "}
{Object.entries(serverConfig.headers)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}
</Text>
</Box>
)}
{headersBlock}
</>
) : null}
</Box>

View file

@ -34,9 +34,14 @@ function toSelectOptions(
}));
}
/** Minimal JSON Schema object shape (properties + required) */
/**
* Minimal JSON Schema object shape (properties + required). Property values are
* `unknown` so the SDK's broadly-typed `Tool["inputSchema"]` (whose `properties`
* values are the recursive JSON type) is assignable here; each value is narrowed
* to {@link JsonSchemaProperty} at the point of use below.
*/
interface JsonSchemaObject {
properties?: Record<string, JsonSchemaProperty>;
properties?: Record<string, unknown>;
required?: string[];
}
@ -60,7 +65,12 @@ export function schemaToForm(
const required = schema.required || [];
for (const [key, prop] of Object.entries(properties)) {
const property = prop as JsonSchemaProperty;
// `properties` values are `unknown` (the SDK schema admits anything), so
// guard before treating a value as a schema object — a malformed server
// schema with e.g. `properties: { foo: null }` must not throw on `.title`.
const property = (
typeof prop === "object" && prop !== null ? prop : {}
) as JsonSchemaProperty;
const baseField = {
name: key,
label: property.title || key,

View file

@ -1,13 +1,30 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"jsx": "react-jsx",
"outDir": "./build",
"rootDir": ".",
// Match clients/web/tsconfig.app.json's module/lib *resolution* options so
// the imported `core/` sources are validated the same way web's own gate
// validates them: DOM lib + bundler resolution are what core/ (browser-side
// auth, DOM types) expects, `noUncheckedIndexedAccess` is off because web's
// app config does not extend tsconfig.base (re-enabling it would flag core/
// issues web never gates), and the `react` path redirect lets core/ source
// resolve its bare `react` import from this client's node_modules. This does
// NOT mirror web's extra strictness flags (noUnusedLocals, verbatimModule-
// Syntax, ES2023 target, …), so tui's own src is checked slightly more
// loosely than web's src.
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["node"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"module": "ESNext",
"noUncheckedIndexedAccess": false,
"paths": {
"@inspector/core/*": ["../../core/*"]
"@inspector/core/*": ["../../core/*"],
"react": ["./node_modules/@types/react"],
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime"]
}
},
"include": ["index.ts", "tui.tsx", "src/**/*"],
"exclude": ["node_modules", "**/*.test.ts", "build"]
"exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx", "build"]
}

View file

@ -69,7 +69,7 @@
"globals": "^17.4.0",
"happy-dom": "^20.9.0",
"playwright": "^1.58.2",
"prettier": "^3.8.1",
"prettier": "^3.8.4",
"storybook": "^10.2.19",
"tsup": "^8.5.1",
"typescript": "~5.9.3",
@ -8192,9 +8192,9 @@
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"version": "3.8.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
"integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
"dev": true,
"license": "MIT",
"bin": {

View file

@ -10,16 +10,23 @@ import {
import {
CLIENT_KEYCHAIN_ID,
extractSecretsFromClientConfig,
hasClientPlaintextSecret,
mergeSecretsIntoClientConfig,
} from "@inspector/core/client/secrets.js";
import {
getClientConfigFilePath,
loadClientConfig,
parseClientConfig,
saveClientConfig,
} from "@inspector/core/client/config.js";
import {
CIMD_METADATA_URL_HTTPS_ERROR,
CIMD_METADATA_URL_INVALID_ERROR,
CIMD_METADATA_URL_PATH_ERROR,
formatClientConfigLoadError,
getCimdClientMetadataUrlError,
isAbsoluteHttpUrl,
serializeClientConfig,
} from "@inspector/core/client/config-parse.js";
import {
getActiveCimdClientMetadataUrl,
@ -210,6 +217,13 @@ describe("client config", () => {
).toThrow(/path/);
});
it("getClientConfigFilePath honors a custom path and defaults to the storage dir", () => {
expect(getClientConfigFilePath("/custom/client.json")).toBe(
"/custom/client.json",
);
expect(getClientConfigFilePath()).toMatch(/client\.json$/);
});
it("loadClientConfig returns {} when file is absent", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "client-config-"));
const filePath = path.join(tmpDir, "client.json");
@ -217,6 +231,76 @@ describe("client config", () => {
expect(config).toEqual({});
});
it("saveClientConfig/loadClientConfig round-trip without a secretStore", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "client-config-"));
const filePath = path.join(tmpDir, "client.json");
const input = {
cimd: {
enabled: true,
clientMetadataUrl: "https://example.com/oauth/client.json",
},
};
await saveClientConfig(input, { filePath });
expect(await loadClientConfig({ filePath })).toEqual(input);
});
it("hasClientPlaintextSecret reflects the presence of an IdP clientSecret", () => {
expect(
hasClientPlaintextSecret({
enterpriseManagedAuth: {
idp: { issuer: "https://idp.example.com", clientId: "c" },
},
}),
).toBe(false);
expect(
hasClientPlaintextSecret({
enterpriseManagedAuth: {
idp: {
issuer: "https://idp.example.com",
clientId: "c",
clientSecret: "s",
},
},
}),
).toBe(true);
});
it("getActiveCimdClientMetadataUrl returns undefined for a whitespace-only URL", () => {
expect(
getActiveCimdClientMetadataUrl({
cimd: { enabled: true, clientMetadataUrl: " " },
}),
).toBeUndefined();
});
it("serializeClientConfig emits pretty-printed JSON", () => {
expect(serializeClientConfig({ cimd: { clientMetadataUrl: "x" } })).toBe(
JSON.stringify({ cimd: { clientMetadataUrl: "x" } }, null, 2),
);
});
describe("getCimdClientMetadataUrlError", () => {
it("returns undefined for an empty value or a valid https URL with a path", () => {
expect(getCimdClientMetadataUrlError("")).toBeUndefined();
expect(getCimdClientMetadataUrlError(" ")).toBeUndefined();
expect(
getCimdClientMetadataUrlError("https://example.com/oauth/client.json"),
).toBeUndefined();
});
it("flags unparseable, non-https, and pathless URLs distinctly", () => {
expect(getCimdClientMetadataUrlError("not-a-url")).toBe(
CIMD_METADATA_URL_INVALID_ERROR,
);
expect(
getCimdClientMetadataUrlError("http://example.com/oauth/client.json"),
).toBe(CIMD_METADATA_URL_HTTPS_ERROR);
expect(getCimdClientMetadataUrlError("https://example.com")).toBe(
CIMD_METADATA_URL_PATH_ERROR,
);
});
});
it("formatClientConfigLoadError summarizes Zod validation failures", () => {
try {
parseClientConfig({

View file

@ -0,0 +1,179 @@
import { describe, it, expect, afterEach } from "vitest";
import * as fs from "node:fs/promises";
import { existsSync, readFileSync } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
InMemorySecretStore,
KeychainUnavailableError,
SECRET_FIELD_IDP_CLIENT_SECRET,
type SecretStore,
} from "@inspector/core/auth/node/secret-store.js";
import { CLIENT_KEYCHAIN_ID } from "@inspector/core/client/secrets.js";
import {
deleteClientConfigStore,
readClientConfigStore,
writeClientConfigStore,
} from "@inspector/core/client/node-persistence.js";
const configWithPlaintextSecret = {
enterpriseManagedAuth: {
idp: {
issuer: "https://idp.example.com",
clientId: "cid",
clientSecret: "plain",
},
},
};
describe("client node-persistence", () => {
let tmpDir: string;
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
async function makeTmpFile(contents?: string): Promise<string> {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "client-persist-"));
const filePath = path.join(tmpDir, "client.json");
if (contents !== undefined) {
await fs.writeFile(filePath, contents, "utf-8");
}
return filePath;
}
it("migrates a plaintext secret to an empty keychain and strips it from disk", async () => {
const filePath = await makeTmpFile(
JSON.stringify(configWithPlaintextSecret),
);
const secretStore = new InMemorySecretStore();
const loaded = await readClientConfigStore(filePath, secretStore);
// Rehydrated result still carries the secret (read back from the keychain).
expect(loaded.enterpriseManagedAuth?.idp.clientSecret).toBe("plain");
// On-disk copy is stripped.
expect(readFileSync(filePath, "utf-8")).not.toContain("plain");
// Keychain now holds it.
expect(
await secretStore.get(CLIENT_KEYCHAIN_ID, SECRET_FIELD_IDP_CLIENT_SECRET),
).toBe("plain");
});
it("does not overwrite an existing keychain secret during migration", async () => {
const filePath = await makeTmpFile(
JSON.stringify(configWithPlaintextSecret),
);
const secretStore = new InMemorySecretStore();
await secretStore.set(
CLIENT_KEYCHAIN_ID,
SECRET_FIELD_IDP_CLIENT_SECRET,
"existing",
);
const loaded = await readClientConfigStore(filePath, secretStore);
// The keychain value wins over the disk plaintext.
expect(loaded.enterpriseManagedAuth?.idp.clientSecret).toBe("existing");
expect(
await secretStore.get(CLIENT_KEYCHAIN_ID, SECRET_FIELD_IDP_CLIENT_SECRET),
).toBe("existing");
// Disk is still stripped.
expect(readFileSync(filePath, "utf-8")).not.toContain("plain");
});
it("keeps the plaintext secret on disk when the keychain is unavailable", async () => {
const filePath = await makeTmpFile(
JSON.stringify(configWithPlaintextSecret),
);
// A store whose writes always fail as if libsecret were missing.
const unavailable: SecretStore = {
async get() {
return null;
},
async set() {
throw new KeychainUnavailableError(new Error("no libsecret"));
},
async delete() {},
async deleteAllForServer() {},
};
const loaded = await readClientConfigStore(filePath, unavailable);
// Migration bailed → original config (with the secret) is returned and the
// on-disk copy is left untouched (still contains the plaintext).
expect(loaded.enterpriseManagedAuth?.idp.clientSecret).toBe("plain");
expect(readFileSync(filePath, "utf-8")).toContain("plain");
});
it("rethrows a non-keychain error raised during migration", async () => {
const filePath = await makeTmpFile(
JSON.stringify(configWithPlaintextSecret),
);
const boom: SecretStore = {
async get() {
return null;
},
async set() {
throw new Error("disk on fire");
},
async delete() {},
async deleteAllForServer() {},
};
await expect(readClientConfigStore(filePath, boom)).rejects.toThrow(
/disk on fire/,
);
});
it("returns {} when the client.json file is absent", async () => {
const filePath = await makeTmpFile();
expect(
await readClientConfigStore(filePath, new InMemorySecretStore()),
).toEqual({});
});
it("deletes the keychain secret when writing a config without one", async () => {
const filePath = await makeTmpFile();
const secretStore = new InMemorySecretStore();
await secretStore.set(
CLIENT_KEYCHAIN_ID,
SECRET_FIELD_IDP_CLIENT_SECRET,
"stale",
);
await writeClientConfigStore(
filePath,
{
cimd: { enabled: true, clientMetadataUrl: "https://x.example/c.json" },
},
secretStore,
);
expect(
await secretStore.get(CLIENT_KEYCHAIN_ID, SECRET_FIELD_IDP_CLIENT_SECRET),
).toBeNull();
expect(readFileSync(filePath, "utf-8")).toContain("clientMetadataUrl");
});
it("deleteClientConfigStore removes both the file and the keychain secret", async () => {
const filePath = await makeTmpFile(
JSON.stringify({ cimd: { enabled: false, clientMetadataUrl: "" } }),
);
const secretStore = new InMemorySecretStore();
await secretStore.set(
CLIENT_KEYCHAIN_ID,
SECRET_FIELD_IDP_CLIENT_SECRET,
"gone",
);
await deleteClientConfigStore(filePath, secretStore);
expect(existsSync(filePath)).toBe(false);
expect(
await secretStore.get(CLIENT_KEYCHAIN_ID, SECRET_FIELD_IDP_CLIENT_SECRET),
).toBeNull();
});
});

View file

@ -0,0 +1,176 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
loadClientConfigRemote,
saveClientConfigRemote,
} from "@inspector/core/client/remote.js";
import type { ClientConfig } from "@inspector/core/client/types.js";
afterEach(() => {
vi.unstubAllGlobals();
});
const validConfig: ClientConfig = {
enterpriseManagedAuth: {
idp: { issuer: "https://idp.example.com", clientId: "cid" },
},
};
/**
* Build a minimal `Response`-like stub for the mocked fetch. The code under
* test only reads `.ok`, `.status`, and `.json()`, so the stub implements just
* those. The double cast is unavoidable here: `Response` has ~20 required
* members (`headers`, `body`, `arrayBuffer`, `blob`, `clone`, …), so a direct
* `as Response` is a TS2352 error and there is no lighter alternative than
* asserting through `unknown` for a deliberately-partial test double.
*/
function fakeResponse(init: {
ok: boolean;
status: number;
json?: () => Promise<unknown>;
}): Response {
return {
ok: init.ok,
status: init.status,
json: init.json ?? (async () => ({})),
} as unknown as Response;
}
describe("client remote config", () => {
describe("loadClientConfigRemote", () => {
it("parses a populated store and strips the trailing slash from baseUrl", async () => {
const fetchFn = vi.fn(async () =>
fakeResponse({ ok: true, status: 200, json: async () => validConfig }),
);
const config = await loadClientConfigRemote({
baseUrl: "http://localhost:3000/",
fetchFn,
});
expect(config).toEqual(validConfig);
expect(fetchFn).toHaveBeenCalledWith(
"http://localhost:3000/api/storage/client",
expect.objectContaining({ method: "GET" }),
);
});
it("sends the bearer auth header when a token is provided", async () => {
const fetchFn = vi.fn(async () =>
fakeResponse({ ok: true, status: 200, json: async () => ({}) }),
);
await loadClientConfigRemote({
baseUrl: "http://localhost:3000",
authToken: "tok",
fetchFn,
});
expect(fetchFn).toHaveBeenCalledWith(
"http://localhost:3000/api/storage/client",
expect.objectContaining({
headers: { "x-mcp-remote-auth": "Bearer tok" },
}),
);
});
it("returns {} on a 404", async () => {
const fetchFn = vi.fn(async () =>
fakeResponse({ ok: false, status: 404 }),
);
expect(
await loadClientConfigRemote({ baseUrl: "http://x", fetchFn }),
).toEqual({});
});
it("throws on a non-404 error status", async () => {
const fetchFn = vi.fn(async () =>
fakeResponse({ ok: false, status: 500 }),
);
await expect(
loadClientConfigRemote({ baseUrl: "http://x", fetchFn }),
).rejects.toThrow(/Failed to read client config: 500/);
});
it("returns {} when the store is empty or not an object", async () => {
const emptyObj = vi.fn(async () =>
fakeResponse({ ok: true, status: 200, json: async () => ({}) }),
);
expect(
await loadClientConfigRemote({
baseUrl: "http://x",
fetchFn: emptyObj,
}),
).toEqual({});
const nullJson = vi.fn(async () =>
fakeResponse({ ok: true, status: 200, json: async () => null }),
);
expect(
await loadClientConfigRemote({
baseUrl: "http://x",
fetchFn: nullJson,
}),
).toEqual({});
});
});
describe("saveClientConfigRemote", () => {
it("POSTs the validated, serialized config with content-type and auth headers", async () => {
const fetchFn = vi.fn(async () =>
fakeResponse({ ok: true, status: 200 }),
);
await saveClientConfigRemote(validConfig, {
baseUrl: "http://localhost:3000/",
authToken: "tok",
fetchFn,
});
expect(fetchFn).toHaveBeenCalledWith(
"http://localhost:3000/api/storage/client",
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
"x-mcp-remote-auth": "Bearer tok",
},
body: JSON.stringify(validConfig, null, 2),
}),
);
});
it("omits the auth header when no token is given", async () => {
const fetchFn = vi.fn(async () =>
fakeResponse({ ok: true, status: 200 }),
);
await saveClientConfigRemote(validConfig, {
baseUrl: "http://x",
fetchFn,
});
expect(fetchFn).toHaveBeenCalledWith(
"http://x/api/storage/client",
expect.objectContaining({
headers: { "Content-Type": "application/json" },
}),
);
});
it("throws when the write fails", async () => {
const fetchFn = vi.fn(async () =>
fakeResponse({ ok: false, status: 503 }),
);
await expect(
saveClientConfigRemote(validConfig, { baseUrl: "http://x", fetchFn }),
).rejects.toThrow(/Failed to write client config: 503/);
});
});
describe("global fetch fallback", () => {
it("uses globalThis.fetch when no fetchFn is supplied", async () => {
const globalFetch = vi.fn(async () =>
fakeResponse({ ok: true, status: 200, json: async () => validConfig }),
);
vi.stubGlobal("fetch", globalFetch);
const loaded = await loadClientConfigRemote({ baseUrl: "http://x" });
expect(loaded).toEqual(validConfig);
await saveClientConfigRemote(validConfig, { baseUrl: "http://x" });
expect(globalFetch).toHaveBeenCalledTimes(2);
});
});
});

View file

@ -1,7 +1,12 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, afterEach, vi } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { InMemorySecretStore } from "@inspector/core/auth/node/secret-store.js";
import {
buildRunnerClientAuthOptions,
isOAuthCapableServerConfig,
loadRunnerClientConfig,
} from "@inspector/core/client/runner.js";
import type { ClientConfig } from "@inspector/core/client/types.js";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
@ -80,4 +85,82 @@ describe("runner client auth options", () => {
expect(opts.oauth?.enterpriseManaged).toBe(true);
expect(opts.oauth?.clientId).toBe("resource-client");
});
it("buildRunnerClientAuthOptions returns no oauth when nothing supplies it", () => {
expect(buildRunnerClientAuthOptions({})).toEqual({});
});
it("buildRunnerClientAuthOptions wires CLI client id/secret and marks directAuthRecovery", () => {
const opts = buildRunnerClientAuthOptions({}, undefined, {
clientId: "cli-id",
clientSecret: "cli-secret",
});
expect(opts.oauth?.clientId).toBe("cli-id");
expect(opts.oauth?.clientSecret).toBe("cli-secret");
expect(opts.directAuthRecovery).toBe(true);
});
it("buildRunnerClientAuthOptions carries oauth scopes and client secret from server settings", () => {
const settings: InspectorServerSettings = {
oauthClientSecret: "resource-secret",
oauthScopes: "read write",
requestTimeout: 0,
connectionTimeout: 0,
taskTtl: 60000,
maxFetchRequests: 10,
autoRefreshOnListChanged: false,
metadata: [],
headers: [],
env: [],
roots: [],
};
const opts = buildRunnerClientAuthOptions({}, settings);
expect(opts.oauth?.clientSecret).toBe("resource-secret");
expect(opts.oauth?.scope).toBe("read write");
});
});
describe("loadRunnerClientConfig", () => {
let tmpDir: string;
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
}
vi.unstubAllEnvs();
});
it("reads client.json from an explicit path (empty when absent)", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "runner-client-"));
const filePath = path.join(tmpDir, "client.json");
// No injected store here → exercises the default `new KeyringSecretStore()`
// path (its get() tolerates keychain unavailability, returning {}).
expect(
await loadRunnerClientConfig({ clientConfigPath: filePath }),
).toEqual({});
});
it("falls back to MCP_CLIENT_CONFIG_PATH and parses a stored config", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "runner-client-"));
const filePath = path.join(tmpDir, "client.json");
await fs.writeFile(
filePath,
JSON.stringify({
cimd: {
enabled: true,
clientMetadataUrl: "https://example.com/oauth/client.json",
},
}),
"utf-8",
);
vi.stubEnv("MCP_CLIENT_CONFIG_PATH", filePath);
// Inject an in-memory store so the result never depends on the developer's
// real OS keychain.
const config = await loadRunnerClientConfig({
secretStore: new InMemorySecretStore(),
});
expect(config.cimd?.clientMetadataUrl).toBe(
"https://example.com/oauth/client.json",
);
});
});

View file

@ -3,15 +3,38 @@ import {
convertParameterValue,
convertToolParameters,
convertPromptArguments,
toRecord,
} from "@inspector/core/json/jsonUtils.js";
import type { Tool } from "@modelcontextprotocol/client";
describe("JSON Utils", () => {
describe("toRecord", () => {
it("returns the same object widened to a string-keyed record", () => {
const source = { a: 1, b: "two" };
const widened = toRecord(source);
expect(widened).toBe(source);
expect(widened.a).toBe(1);
expect(Object.keys(widened)).toEqual(["a", "b"]);
});
});
describe("convertParameterValue", () => {
it("should convert string to string", () => {
expect(convertParameterValue("hello", { type: "string" })).toBe("hello");
});
it("returns the raw value unchanged when it is empty", () => {
// Empty string short-circuits before any type coercion.
expect(convertParameterValue("", { type: "number" })).toBe("");
});
it("falls back to the raw string when JSON parsing fails", () => {
expect(convertParameterValue("{not json", { type: "object" })).toBe(
"{not json",
);
expect(convertParameterValue("[oops", { type: "array" })).toBe("[oops");
});
it("should convert string to number", () => {
expect(convertParameterValue("42", { type: "number" })).toBe(42);
expect(convertParameterValue("3.14", { type: "number" })).toBe(3.14);

View file

@ -105,7 +105,8 @@ export default defineConfig(({ command }) => {
'src/lib/**/*.{ts,tsx}',
'clients/web/server/**/*.{ts,tsx}',
path.join(repoRoot, 'core/mcp/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/json/xMcpHeader.ts'),
path.join(repoRoot, 'core/json/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/client/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/react/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/auth/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/storage/**/*.{ts,tsx}'),