test(core): backfill coverage on inspectorClient + remote server (#1317)
* test(core): backfill coverage on inspectorClient + remote server Resolves #1310. Drops the two TODO(#1310) exclusions from clients/web/vite.config.ts (coverage.exclude); both files now pass the per-file gate (lines >=90, statements >=85, functions >=80, branches >=50). core/mcp/remote/node/server.ts (88.15 / 78.37 -> 97.29 / 100): - Extract createTokenAuthProvider to its own file (core/mcp/remote/node/tokenAuthProvider.ts) so the 7 no-op OAuthClientProvider stubs can be unit-tested directly. - Add focused integration tests covering the remaining gaps: * createTransportNode failure path on POST /api/mcp/connect * /api/mcp/send when transport is dead * /api/log non-JSON body (catch fallback) * forwardLogEvent unknown-level / no-messages / string-first-message fallbacks (lines 232, 240-242, 257-265 in the original) * /api/fetch missing-url + upstream-throw paths * /api/storage POST invalid storeId + invalid JSON body * /api/storage GET when the on-disk store is unparseable core/mcp/inspectorClient.ts (73.48 / 72.09 -> 91.16 / 89.92): Added a single "Coverage backfill (#1310)" describe block to the existing integration test, plus a few unit-style cases: - OAuth getters / setOAuthConfig / authenticate when oauthManager is unset. - Simple session, roots, subscription, and instructions accessors. - subscribe / unsubscribe error paths against a server without the subscribe capability; happy path against one with it. - getPrompt + readResourceFromTemplate happy path + expansion-failure. - setLoggingLevel "Server does not support logging" guard. - getAppRendererClient (null before connect, cached proxy after). - OAuth + stdio: authenticate() throws via getServerUrl()'s stdio check. - listChanged notifications: tools/resources/prompts handlers via the fixture add_tool / add_resource / add_prompt tools. - Misc tiny branches: re-connect no-op, elicit form mode, getTaskCapabilities on a tasks-less server, receiver-task TTL cleanup + cancel via the private surface (the focused-unit pass the issue describes). - defensive `if (!this.client) throw` guards: one nullify-and-call test exercises 16 of them via a single instance, plus the connect() entry guard. The receiver-task internals test uses (instance as unknown as { private fields... }) to drive createReceiverTask / cancelReceiverTask / TTL cleanup directly; per the issue, refactoring private helpers to be testable from a unit test was authorized. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(core): address #1317 PR review feedback - Rename the misnamed "listChanged notifications" describe in inspectorClient.test.ts to "capability detection after connect", since the test asserts only on getCapabilities() round-tripping and never triggers or observes a notification dispatch. Real dispatch coverage lives in the adjacent "list_changed + resourceUpdated notifications" block. Kept the resources/prompts fixtures with a note explaining why they are required for the server to advertise the matching capabilities. - Expand the comment on the 250ms wait in remote-transport.test.ts' /api/mcp/send dead-transport test so it is clear the >= 500 assertion is intentionally permissive across both the dead-transport short- circuit and the downstream send-failure branches. - Add a "keep this in sync" pointer on the nullify() helper so a future cleanup that removes any of the defensive `if (!this.client) throw` guards in inspectorClient.ts is reminded to drop the matching call here rather than silently under-covering. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
82269e78cb
commit
02182e3b6c
6 changed files with 958 additions and 53 deletions
|
|
@ -39,6 +39,9 @@ import {
|
||||||
createSamplingTaskTool,
|
createSamplingTaskTool,
|
||||||
createProgressTaskTool,
|
createProgressTaskTool,
|
||||||
createTaskTool,
|
createTaskTool,
|
||||||
|
createAddResourceTool,
|
||||||
|
createAddToolTool,
|
||||||
|
createAddPromptTool,
|
||||||
} from "@modelcontextprotocol/inspector-test-server";
|
} from "@modelcontextprotocol/inspector-test-server";
|
||||||
import type {
|
import type {
|
||||||
MessageEntry,
|
MessageEntry,
|
||||||
|
|
@ -4013,4 +4016,591 @@ describe("InspectorClient", () => {
|
||||||
expect(parsed.input).toBe("E2E elicitation input");
|
expect(parsed.input).toBe("E2E elicitation input");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Coverage backfill (#1310)", () => {
|
||||||
|
describe("guard methods without server / OAuth", () => {
|
||||||
|
it("returns no-op defaults from OAuth getters when oauthManager is unset", async () => {
|
||||||
|
const c = new InspectorClient(
|
||||||
|
{
|
||||||
|
type: "stdio",
|
||||||
|
command: serverCommand.command,
|
||||||
|
args: serverCommand.args,
|
||||||
|
},
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
await expect(c.getOAuthTokens()).resolves.toBeUndefined();
|
||||||
|
await expect(c.isOAuthAuthorized()).resolves.toBe(false);
|
||||||
|
expect(c.getOAuthStep()).toBeUndefined();
|
||||||
|
expect(c.getOAuthState()).toBeUndefined();
|
||||||
|
// clearOAuthTokens is a no-op when there is no manager
|
||||||
|
expect(() => c.clearOAuthTokens()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setOAuthConfig throws when oauthManager is unset", () => {
|
||||||
|
const c = new InspectorClient(
|
||||||
|
{
|
||||||
|
type: "stdio",
|
||||||
|
command: serverCommand.command,
|
||||||
|
args: serverCommand.args,
|
||||||
|
},
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
expect(() => c.setOAuthConfig({ clientId: "x" })).toThrow(
|
||||||
|
/OAuth config must be set at creation/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("authenticate / runGuidedAuth / proceedOAuthStep throw via ensureOAuthManager when oauthManager is unset", async () => {
|
||||||
|
const c = new InspectorClient(
|
||||||
|
{
|
||||||
|
type: "stdio",
|
||||||
|
command: serverCommand.command,
|
||||||
|
args: serverCommand.args,
|
||||||
|
},
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
await expect(c.authenticate()).rejects.toThrow(/OAuth not configured/);
|
||||||
|
await expect(c.runGuidedAuth()).rejects.toThrow(/OAuth not configured/);
|
||||||
|
await expect(c.proceedOAuthStep()).rejects.toThrow(
|
||||||
|
/OAuth not configured/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("simple session/roots/subscription accessors return empty defaults before connect", () => {
|
||||||
|
const c = new InspectorClient(
|
||||||
|
{
|
||||||
|
type: "stdio",
|
||||||
|
command: serverCommand.command,
|
||||||
|
args: serverCommand.args,
|
||||||
|
},
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
expect(c.getSessionId()).toBeUndefined();
|
||||||
|
// saveSession is a no-op when there is no sessionId
|
||||||
|
expect(() => c.saveSession()).not.toThrow();
|
||||||
|
c.setSessionId("session-123");
|
||||||
|
expect(c.getSessionId()).toBe("session-123");
|
||||||
|
|
||||||
|
expect(c.getSubscribedResources()).toEqual([]);
|
||||||
|
expect(c.isSubscribedToResource("file:///nope")).toBe(false);
|
||||||
|
// No server capabilities loaded yet -> subscriptions unsupported
|
||||||
|
expect(c.supportsResourceSubscriptions()).toBe(false);
|
||||||
|
// No instructions loaded before connect
|
||||||
|
expect(c.getInstructions()).toBeUndefined();
|
||||||
|
// Roots default to [] when undefined
|
||||||
|
expect(c.getRoots()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribe / unsubscribe error when server doesn't advertise subscribe capability", async () => {
|
||||||
|
// Stdio test server doesn't advertise resources.subscribe by default
|
||||||
|
server = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
resources: createNumberedResources(2),
|
||||||
|
});
|
||||||
|
await server.start();
|
||||||
|
client = new InspectorClient(
|
||||||
|
{ type: "streamable-http", url: server.url },
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.subscribeToResource("test://resource_1"),
|
||||||
|
).rejects.toThrow(/does not support resource subscriptions/);
|
||||||
|
// unsubscribe path doesn't guard on capability; it calls the SDK which
|
||||||
|
// will reject because the server has no unsubscribe handler.
|
||||||
|
await expect(
|
||||||
|
client.unsubscribeFromResource("test://resource_1"),
|
||||||
|
).rejects.toThrow(
|
||||||
|
/Failed to unsubscribe to resource|Failed to unsubscribe from resource|Method not found/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("subscribe / unsubscribe happy path", () => {
|
||||||
|
it("subscribes, reports state, then unsubscribes, when server advertises subscribe", async () => {
|
||||||
|
server = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
resources: createNumberedResources(2),
|
||||||
|
subscriptions: true,
|
||||||
|
});
|
||||||
|
await server.start();
|
||||||
|
client = new InspectorClient(
|
||||||
|
{ type: "streamable-http", url: server.url },
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
expect(client.supportsResourceSubscriptions()).toBe(true);
|
||||||
|
|
||||||
|
await client.subscribeToResource("test://resource_1");
|
||||||
|
expect(client.getSubscribedResources()).toContain("test://resource_1");
|
||||||
|
expect(client.isSubscribedToResource("test://resource_1")).toBe(true);
|
||||||
|
|
||||||
|
await client.unsubscribeFromResource("test://resource_1");
|
||||||
|
expect(client.getSubscribedResources()).not.toContain(
|
||||||
|
"test://resource_1",
|
||||||
|
);
|
||||||
|
expect(client.isSubscribedToResource("test://resource_1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPrompt + readResourceFromTemplate", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
server = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
resourceTemplates: [createFileResourceTemplate()],
|
||||||
|
prompts: [createArgsPrompt()],
|
||||||
|
});
|
||||||
|
await server.start();
|
||||||
|
client = new InspectorClient(
|
||||||
|
{ type: "streamable-http", url: server.url },
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
await client.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getPrompt fetches and returns an invocation, dispatching no extra error", async () => {
|
||||||
|
const invocation = await client!.getPrompt("args_prompt", {
|
||||||
|
city: "Hartford",
|
||||||
|
state: "Connecticut",
|
||||||
|
});
|
||||||
|
expect(invocation.name).toBe("args_prompt");
|
||||||
|
expect(invocation.params).toEqual({
|
||||||
|
city: "Hartford",
|
||||||
|
state: "Connecticut",
|
||||||
|
});
|
||||||
|
expect(invocation.result).toBeDefined();
|
||||||
|
expect(invocation.timestamp).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readResourceFromTemplate expands and reads the resource", async () => {
|
||||||
|
const invocation = await client!.readResourceFromTemplate(
|
||||||
|
"file:///{path}",
|
||||||
|
{ path: "report.txt" },
|
||||||
|
);
|
||||||
|
expect(invocation.uriTemplate).toBe("file:///{path}");
|
||||||
|
expect(invocation.expandedUri).toBe("file:///report.txt");
|
||||||
|
expect(invocation.params).toEqual({ path: "report.txt" });
|
||||||
|
expect(invocation.result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readResourceFromTemplate throws when expansion fails", async () => {
|
||||||
|
// Pass a syntactically invalid template; UriTemplate ctor throws
|
||||||
|
await expect(
|
||||||
|
client!.readResourceFromTemplate("file:///{unclosed", {
|
||||||
|
unclosed: "x",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Failed to expand URI template/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("capability detection after connect", () => {
|
||||||
|
it("round-trips listChanged + subscribe flags via getCapabilities()", async () => {
|
||||||
|
// The handler-registration arrows in InspectorClient fire during
|
||||||
|
// connect only when the matching server capability is advertised.
|
||||||
|
// Exercise all four conditional branches in one connect by enabling
|
||||||
|
// tools/resources/prompts listChanged + resource subscriptions.
|
||||||
|
// The resources/prompts arrays are required for the test server to
|
||||||
|
// actually emit those capability blocks (an empty list omits the
|
||||||
|
// capability rather than advertising an empty one).
|
||||||
|
server = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
tools: [createEchoTool()],
|
||||||
|
resources: createNumberedResources(1),
|
||||||
|
prompts: [createArgsPrompt()],
|
||||||
|
listChanged: { tools: true, resources: true, prompts: true },
|
||||||
|
subscriptions: true,
|
||||||
|
});
|
||||||
|
await server.start();
|
||||||
|
client = new InspectorClient(
|
||||||
|
{ type: "streamable-http", url: server.url },
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const caps = client.getCapabilities();
|
||||||
|
expect(caps?.tools?.listChanged).toBe(true);
|
||||||
|
expect(caps?.resources?.listChanged).toBe(true);
|
||||||
|
expect(caps?.prompts?.listChanged).toBe(true);
|
||||||
|
expect(caps?.resources?.subscribe).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setLoggingLevel guards", () => {
|
||||||
|
it("throws when the server does not advertise logging support", async () => {
|
||||||
|
server = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
tools: [createEchoTool()],
|
||||||
|
});
|
||||||
|
await server.start();
|
||||||
|
client = new InspectorClient(
|
||||||
|
{ type: "streamable-http", url: server.url },
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
await client.connect();
|
||||||
|
await expect(client.setLoggingLevel("info")).rejects.toThrow(
|
||||||
|
/Server does not support logging/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAppRendererClient", () => {
|
||||||
|
it("returns null before connect, and a proxy after connect that forwards setNotificationHandler", async () => {
|
||||||
|
server = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
tools: [createEchoTool()],
|
||||||
|
});
|
||||||
|
await server.start();
|
||||||
|
const c = new InspectorClient(
|
||||||
|
{ type: "streamable-http", url: server.url },
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
// Disconnected => null
|
||||||
|
expect(c.getAppRendererClient()).toBeNull();
|
||||||
|
|
||||||
|
client = c;
|
||||||
|
await c.connect();
|
||||||
|
|
||||||
|
const proxy1 = c.getAppRendererClient();
|
||||||
|
expect(proxy1).not.toBeNull();
|
||||||
|
// Second call returns the cached proxy
|
||||||
|
expect(c.getAppRendererClient()).toBe(proxy1);
|
||||||
|
// setNotificationHandler on the proxy delegates to the underlying client
|
||||||
|
expect(
|
||||||
|
typeof (proxy1 as unknown as { setNotificationHandler?: unknown })
|
||||||
|
.setNotificationHandler,
|
||||||
|
).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("list_changed + resourceUpdated notifications", () => {
|
||||||
|
it("dispatches toolsListChanged when the server emits notifications/tools/list_changed", async () => {
|
||||||
|
server = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
tools: [createAddToolTool()],
|
||||||
|
listChanged: { tools: true },
|
||||||
|
});
|
||||||
|
await server.start();
|
||||||
|
client = new InspectorClient(
|
||||||
|
{ type: "streamable-http", url: server.url },
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const fired = waitForEvent(client, "toolsListChanged", {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
const addToolTool = await getTool(client, "add_tool");
|
||||||
|
await client.callTool(addToolTool, {
|
||||||
|
name: "newly_added",
|
||||||
|
description: "added at runtime",
|
||||||
|
});
|
||||||
|
await fired;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches resourcesListChanged when the server emits resources/list_changed", async () => {
|
||||||
|
server = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
tools: [createAddResourceTool()],
|
||||||
|
resources: createNumberedResources(1),
|
||||||
|
listChanged: { resources: true },
|
||||||
|
});
|
||||||
|
await server.start();
|
||||||
|
client = new InspectorClient(
|
||||||
|
{ type: "streamable-http", url: server.url },
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const fired = waitForEvent(client, "resourcesListChanged", {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
const addResourceTool = await getTool(client, "add_resource");
|
||||||
|
await client.callTool(addResourceTool, {
|
||||||
|
uri: "res://new",
|
||||||
|
name: "new",
|
||||||
|
text: "hi",
|
||||||
|
});
|
||||||
|
await fired;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches promptsListChanged when the server emits prompts/list_changed", async () => {
|
||||||
|
server = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
tools: [createAddPromptTool()],
|
||||||
|
prompts: [createArgsPrompt()],
|
||||||
|
listChanged: { prompts: true },
|
||||||
|
});
|
||||||
|
await server.start();
|
||||||
|
client = new InspectorClient(
|
||||||
|
{ type: "streamable-http", url: server.url },
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const fired = waitForEvent(client, "promptsListChanged", {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
const addPromptTool = await getTool(client, "add_prompt");
|
||||||
|
await client.callTool(addPromptTool, {
|
||||||
|
name: "new_prompt",
|
||||||
|
description: "added at runtime",
|
||||||
|
promptString: "hello",
|
||||||
|
});
|
||||||
|
await fired;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("misc tiny branches", () => {
|
||||||
|
it("connect() is a no-op when status is already 'connected'", async () => {
|
||||||
|
server = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
tools: [createEchoTool()],
|
||||||
|
});
|
||||||
|
await server.start();
|
||||||
|
client = new InspectorClient(
|
||||||
|
{ type: "streamable-http", url: server.url },
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
await client.connect();
|
||||||
|
expect(client.getStatus()).toBe("connected");
|
||||||
|
// Second connect() should hit the early-return branch
|
||||||
|
await client.connect();
|
||||||
|
expect(client.getStatus()).toBe("connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("connect() throws 'Client not initialized' when this.client is null", async () => {
|
||||||
|
const c = new InspectorClient(
|
||||||
|
{
|
||||||
|
type: "stdio",
|
||||||
|
command: serverCommand.command,
|
||||||
|
args: serverCommand.args,
|
||||||
|
},
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
(c as unknown as { client: unknown }).client = null;
|
||||||
|
await expect(c.connect()).rejects.toThrow(/Client not initialized/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getTaskCapabilities() returns undefined when the server has no tasks capability", async () => {
|
||||||
|
server = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
tools: [createEchoTool()],
|
||||||
|
});
|
||||||
|
await server.start();
|
||||||
|
client = new InspectorClient(
|
||||||
|
{ type: "streamable-http", url: server.url },
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
await client.connect();
|
||||||
|
expect(client.getTaskCapabilities()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("elicit form mode adds form to elicitation capability (constructor branch)", () => {
|
||||||
|
const c = new InspectorClient(
|
||||||
|
{
|
||||||
|
type: "stdio",
|
||||||
|
command: serverCommand.command,
|
||||||
|
args: serverCommand.args,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environment: { transport: createTransportNode },
|
||||||
|
// Hits the `this.elicit.form` branch in the constructor's
|
||||||
|
// elicitationCap.form assignment block
|
||||||
|
elicit: { form: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(c.getStatus()).toBe("disconnected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("receiver-task internals: TTL cleanup and cancel terminate via private surface", async () => {
|
||||||
|
// Drive the private createReceiverTask + cancelReceiverTask + TTL-cleanup
|
||||||
|
// paths by reaching into the instance. These are server-driven in
|
||||||
|
// practice (tasks/cancel from server), but the existing receiver-task
|
||||||
|
// e2e tests don't exercise the cancel path; this is the focused unit
|
||||||
|
// pass the issue suggested.
|
||||||
|
const c = new InspectorClient(
|
||||||
|
{
|
||||||
|
type: "stdio",
|
||||||
|
command: serverCommand.command,
|
||||||
|
args: serverCommand.args,
|
||||||
|
},
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
const internal = c as unknown as {
|
||||||
|
createReceiverTask: (opts: {
|
||||||
|
ttl?: number;
|
||||||
|
initialStatus: "input_required" | "working";
|
||||||
|
statusMessage?: string;
|
||||||
|
}) => {
|
||||||
|
task: { taskId: string; status: string };
|
||||||
|
payloadPromise: Promise<unknown>;
|
||||||
|
};
|
||||||
|
cancelReceiverTask: (taskId: string) => {
|
||||||
|
taskId: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
listReceiverTasks: () => Array<{ taskId: string; status: string }>;
|
||||||
|
getReceiverTask: (taskId: string) => unknown;
|
||||||
|
getReceiverTaskPayload: (taskId: string) => Promise<unknown>;
|
||||||
|
receiverTaskRecords: Map<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Short TTL so the cleanup setTimeout fires in-test
|
||||||
|
const record = internal.createReceiverTask({
|
||||||
|
ttl: 50,
|
||||||
|
initialStatus: "working",
|
||||||
|
statusMessage: "running",
|
||||||
|
});
|
||||||
|
// Pre-attach a catch so cancel's reject doesn't surface as unhandled
|
||||||
|
const payloadResult = record.payloadPromise.catch(
|
||||||
|
(e) => (e as Error).message,
|
||||||
|
);
|
||||||
|
expect(record.task.taskId).toBeDefined();
|
||||||
|
// listReceiverTasks contains the new task
|
||||||
|
const list = internal.listReceiverTasks();
|
||||||
|
expect(list.some((t) => t.taskId === record.task.taskId)).toBe(true);
|
||||||
|
expect(internal.getReceiverTask(record.task.taskId)).toBeDefined();
|
||||||
|
|
||||||
|
// getReceiverTaskPayload on an unknown id throws InvalidParams
|
||||||
|
await expect(
|
||||||
|
internal.getReceiverTaskPayload("does-not-exist"),
|
||||||
|
).rejects.toThrow(/Unknown taskId/);
|
||||||
|
|
||||||
|
// Cancel before TTL fires
|
||||||
|
const cancelled = internal.cancelReceiverTask(record.task.taskId);
|
||||||
|
expect(cancelled.status).toBe("cancelled");
|
||||||
|
await expect(payloadResult).resolves.toBe("Task cancelled");
|
||||||
|
// Cancel again — record is in terminal state, returns existing task
|
||||||
|
const reCancel = internal.cancelReceiverTask(record.task.taskId);
|
||||||
|
expect(reCancel.status).toBe("cancelled");
|
||||||
|
|
||||||
|
// cancelReceiverTask on an unknown id throws InvalidParams
|
||||||
|
expect(() => internal.cancelReceiverTask("nope")).toThrow(
|
||||||
|
/Unknown taskId/,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drive the TTL-cleanup path: create a record with very short ttl and
|
||||||
|
// let setTimeout fire — receiverTaskRecords drops the entry.
|
||||||
|
const ttlRecord = internal.createReceiverTask({
|
||||||
|
ttl: 20,
|
||||||
|
initialStatus: "working",
|
||||||
|
statusMessage: "running",
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => setTimeout(r, 80));
|
||||||
|
expect(internal.receiverTaskRecords.has(ttlRecord.task.taskId)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("defensive guards when client is uninitialized", () => {
|
||||||
|
// These guards exist as TypeScript narrows for the `Client | null` field
|
||||||
|
// even though the constructor always assigns it. Force the field to null
|
||||||
|
// via a private-field cast so we can exercise the throw branches once,
|
||||||
|
// rather than sprinkling `if` guards through tests for each method.
|
||||||
|
//
|
||||||
|
// NOTE: keep this list in sync with the `if (!this.client) throw …`
|
||||||
|
// sites in core/mcp/inspectorClient.ts. If a guard is removed during a
|
||||||
|
// future refactor (e.g. when the field becomes `Client` instead of
|
||||||
|
// `Client | null`) this test will silently under-cover rather than
|
||||||
|
// fail — the matching call below should be removed at the same time.
|
||||||
|
function nullify(c: InspectorClient): void {
|
||||||
|
(c as unknown as { client: unknown }).client = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("public methods that require a client throw or short-circuit when client is null", async () => {
|
||||||
|
const c = new InspectorClient(
|
||||||
|
{
|
||||||
|
type: "stdio",
|
||||||
|
command: serverCommand.command,
|
||||||
|
args: serverCommand.args,
|
||||||
|
},
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
nullify(c);
|
||||||
|
|
||||||
|
const expectThrow = async (
|
||||||
|
fn: () => Promise<unknown>,
|
||||||
|
msg = "Client is not connected",
|
||||||
|
) => {
|
||||||
|
await expect(fn()).rejects.toThrow(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// listing / pagination
|
||||||
|
await expectThrow(() => c.listTools());
|
||||||
|
await expectThrow(() => c.listResources());
|
||||||
|
await expectThrow(() => c.listResourceTemplates());
|
||||||
|
await expectThrow(() => c.listPrompts());
|
||||||
|
|
||||||
|
// single-item fetch
|
||||||
|
await expectThrow(() =>
|
||||||
|
c.callTool({ name: "x" } as unknown as Tool, {}),
|
||||||
|
);
|
||||||
|
await expectThrow(() => c.readResource("res://x"));
|
||||||
|
await expectThrow(() =>
|
||||||
|
c.readResourceFromTemplate("file:///{p}", { p: "x" }),
|
||||||
|
);
|
||||||
|
await expectThrow(() => c.getPrompt("x"));
|
||||||
|
|
||||||
|
// logging guard fires the "not connected" branch first
|
||||||
|
await expectThrow(() => c.setLoggingLevel("info"));
|
||||||
|
|
||||||
|
// roots + subscriptions
|
||||||
|
await expectThrow(() => c.setRoots([]));
|
||||||
|
await expectThrow(() => c.subscribeToResource("res://x"));
|
||||||
|
await expectThrow(() => c.unsubscribeFromResource("res://x"));
|
||||||
|
|
||||||
|
// requestor task ops
|
||||||
|
await expectThrow(() => c.getRequestorTask("t"));
|
||||||
|
await expectThrow(() => c.getRequestorTaskResult("t"));
|
||||||
|
await expectThrow(() => c.cancelRequestorTask("t"));
|
||||||
|
await expectThrow(() => c.listRequestorTasks());
|
||||||
|
|
||||||
|
// ping
|
||||||
|
await expectThrow(() => c.ping(), "Client not initialized");
|
||||||
|
|
||||||
|
// callToolStream: throws synchronously? Actually returns Promise from
|
||||||
|
// the iterator's first .next() — call and assert rejection.
|
||||||
|
await expectThrow(() =>
|
||||||
|
c.callToolStream({ name: "x" } as unknown as Tool, {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// getCompletions short-circuits to { values: [] } rather than throwing
|
||||||
|
await expect(
|
||||||
|
c.getCompletions({ type: "ref/prompt", name: "x" }, "arg", "val"),
|
||||||
|
).resolves.toEqual({ values: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OAuth + stdio transport", () => {
|
||||||
|
it("authenticate() throws because stdio transports have no server URL", async () => {
|
||||||
|
const c = new InspectorClient(
|
||||||
|
{
|
||||||
|
type: "stdio",
|
||||||
|
command: serverCommand.command,
|
||||||
|
args: serverCommand.args,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environment: {
|
||||||
|
transport: createTransportNode,
|
||||||
|
oauth: {
|
||||||
|
// Minimal stubs — providers won't ever be reached because
|
||||||
|
// getServerUrl throws first.
|
||||||
|
storage: {} as never,
|
||||||
|
navigation: {} as never,
|
||||||
|
redirectUrlProvider: {} as never,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// ensureOAuthManager hits the manager (since oauth env was supplied)
|
||||||
|
// and the manager calls getServerUrl() which throws for stdio.
|
||||||
|
await expect(c.authenticate()).rejects.toThrow(
|
||||||
|
/OAuth is only supported for HTTP-based transports/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
50
clients/web/src/test/core/remote-tokenAuthProvider.test.ts
Normal file
50
clients/web/src/test/core/remote-tokenAuthProvider.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { createTokenAuthProvider } from "@inspector/core/mcp/remote/node/tokenAuthProvider.js";
|
||||||
|
|
||||||
|
describe("createTokenAuthProvider", () => {
|
||||||
|
it("returns undefined when tokens are not provided", () => {
|
||||||
|
expect(createTokenAuthProvider(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a provider whose tokens() resolves with the supplied tokens", async () => {
|
||||||
|
const tokens = { access_token: "abc", token_type: "Bearer" };
|
||||||
|
const provider = createTokenAuthProvider(tokens);
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
await expect(provider!.tokens()).resolves.toEqual(tokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes no-op stubs for the auxiliary OAuthClientProvider methods", async () => {
|
||||||
|
const provider = createTokenAuthProvider({
|
||||||
|
access_token: "abc",
|
||||||
|
token_type: "Bearer",
|
||||||
|
});
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
// The aux methods are no-op stubs that satisfy the OAuthClientProvider
|
||||||
|
// surface; the underlying object type widens via the `as unknown as`
|
||||||
|
// cast in the source, so we narrow here for the test assertions.
|
||||||
|
const p = provider! as unknown as {
|
||||||
|
clientInformation: () => Promise<undefined>;
|
||||||
|
saveTokens: (t: {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
codeVerifier: () => string | undefined;
|
||||||
|
saveCodeVerifier: (v: string) => Promise<void>;
|
||||||
|
clear: () => void;
|
||||||
|
redirectToAuthorization: (url: URL) => void;
|
||||||
|
state: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(p.clientInformation()).resolves.toBeUndefined();
|
||||||
|
await expect(
|
||||||
|
p.saveTokens({ access_token: "noop", token_type: "Bearer" }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(p.codeVerifier()).toBeUndefined();
|
||||||
|
await expect(p.saveCodeVerifier("v")).resolves.toBeUndefined();
|
||||||
|
expect(() => p.clear()).not.toThrow();
|
||||||
|
expect(() =>
|
||||||
|
p.redirectToAuthorization(new URL("https://example.com/")),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(p.state()).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { serve } from "@hono/node-server";
|
import { serve } from "@hono/node-server";
|
||||||
|
|
@ -1231,4 +1231,277 @@ describe("Remote transport e2e", () => {
|
||||||
expect(res.status).not.toBe(403);
|
expect(res.status).not.toBe(403);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("additional coverage paths", () => {
|
||||||
|
it("/api/mcp/connect returns 500 when createTransportNode throws", async () => {
|
||||||
|
const { baseUrl, server, authToken } = await startRemoteServer(0);
|
||||||
|
remoteServer = server;
|
||||||
|
// Invalid URL causes new URL() in createTransportNode to throw synchronously
|
||||||
|
const res = await fetch(`${baseUrl}/api/mcp/connect`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-mcp-remote-auth": `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
config: { type: "sse" as const, url: "not a url" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect((await res.json()).error).toMatch(/Failed to create transport:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/mcp/send returns 500 when transport is dead", async () => {
|
||||||
|
mcpHttpServer = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
tools: [createEchoTool()],
|
||||||
|
serverType: "sse",
|
||||||
|
});
|
||||||
|
await mcpHttpServer.start();
|
||||||
|
|
||||||
|
const { baseUrl, server, authToken } = await startRemoteServer(0);
|
||||||
|
remoteServer = server;
|
||||||
|
|
||||||
|
// Connect to a live MCP server so we get a valid sessionId
|
||||||
|
const connectRes = await fetch(`${baseUrl}/api/mcp/connect`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-mcp-remote-auth": `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
config: { type: "sse" as const, url: mcpHttpServer.url },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(connectRes.status).toBe(200);
|
||||||
|
const { sessionId } = (await connectRes.json()) as { sessionId: string };
|
||||||
|
|
||||||
|
// Kill the upstream so the transport closes/errors and is marked dead
|
||||||
|
await mcpHttpServer.stop();
|
||||||
|
mcpHttpServer = null;
|
||||||
|
|
||||||
|
// Give the transport a moment to notice the closed upstream. The
|
||||||
|
// assertion below is intentionally permissive (status >= 500) so the
|
||||||
|
// test passes whether the SDK transport marks itself dead (short-
|
||||||
|
// circuit at /api/mcp/send) or the send call surfaces the failure
|
||||||
|
// directly. Either path covers a 5xx branch in send().
|
||||||
|
await new Promise<void>((r) => setTimeout(r, 250));
|
||||||
|
|
||||||
|
const sendRes = await fetch(`${baseUrl}/api/mcp/send`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-mcp-remote-auth": `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId,
|
||||||
|
message: { jsonrpc: "2.0", id: 99, method: "ping" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// Either the dead-transport short-circuit (500) or a downstream send
|
||||||
|
// failure (500) — either way, the path is covered.
|
||||||
|
expect(sendRes.status).toBeGreaterThanOrEqual(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/log accepts non-JSON body silently via the catch fallback", async () => {
|
||||||
|
const { baseUrl, server, authToken } = await startRemoteServer(0);
|
||||||
|
remoteServer = server;
|
||||||
|
const res = await fetch(`${baseUrl}/api/log`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-mcp-remote-auth": `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: "not json at all",
|
||||||
|
});
|
||||||
|
// The catch arrow swallows the parse error and uses {} — handler still 200s.
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/log ignores events whose level label is not a logger method", async () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), "inspector-log-unknown-level-"));
|
||||||
|
const logFile = join(tmp, "app.log");
|
||||||
|
try {
|
||||||
|
const logger = pino({ level: "info" }, pino.destination(logFile));
|
||||||
|
const { baseUrl, server, authToken } = await startRemoteServer(0, {
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
remoteServer = server;
|
||||||
|
const res = await fetch(`${baseUrl}/api/log`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-mcp-remote-auth": `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
level: { label: "totally-not-a-real-level" },
|
||||||
|
messages: ["should be discarded"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/log forwards events that have no messages", async () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), "inspector-log-no-messages-"));
|
||||||
|
const logFile = join(tmp, "app.log");
|
||||||
|
try {
|
||||||
|
const logger = pino({ level: "info" }, pino.destination(logFile));
|
||||||
|
const { baseUrl, server, authToken } = await startRemoteServer(0, {
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
remoteServer = server;
|
||||||
|
const res = await fetch(`${baseUrl}/api/log`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-mcp-remote-auth": `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
level: { label: "info" },
|
||||||
|
bindings: [{ source: "test" }],
|
||||||
|
messages: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Flush + read back to confirm the bindings were forwarded
|
||||||
|
await new Promise<void>((r) => setTimeout(r, 100));
|
||||||
|
const contents = readFileSync(logFile, "utf-8");
|
||||||
|
expect(contents).toContain('"source":"test"');
|
||||||
|
} finally {
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/log forwards events whose first message is a string", async () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), "inspector-log-string-first-"));
|
||||||
|
const logFile = join(tmp, "app.log");
|
||||||
|
try {
|
||||||
|
const logger = pino({ level: "info" }, pino.destination(logFile));
|
||||||
|
const { baseUrl, server, authToken } = await startRemoteServer(0, {
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
remoteServer = server;
|
||||||
|
const res = await fetch(`${baseUrl}/api/log`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-mcp-remote-auth": `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
level: { label: "info" },
|
||||||
|
bindings: [{ tag: "string-first" }],
|
||||||
|
messages: ["hello world"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
await new Promise<void>((r) => setTimeout(r, 100));
|
||||||
|
const contents = readFileSync(logFile, "utf-8");
|
||||||
|
expect(contents).toContain('"msg":"hello world"');
|
||||||
|
expect(contents).toContain('"tag":"string-first"');
|
||||||
|
} finally {
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/fetch returns 400 when url is missing", async () => {
|
||||||
|
const { baseUrl, server, authToken } = await startRemoteServer(0);
|
||||||
|
remoteServer = server;
|
||||||
|
const res = await fetch(`${baseUrl}/api/fetch`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-mcp-remote-auth": `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect((await res.json()).error).toBe("Missing url");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/fetch returns 500 when the upstream fetch throws", async () => {
|
||||||
|
const { baseUrl, server, authToken } = await startRemoteServer(0);
|
||||||
|
remoteServer = server;
|
||||||
|
// Use a clearly-unroutable URL so global fetch rejects synchronously-ish
|
||||||
|
const res = await fetch(`${baseUrl}/api/fetch`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-mcp-remote-auth": `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: "http://127.0.0.1:1/does-not-exist",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect((await res.json()).error).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/storage POST returns 400 for invalid storeId", async () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), "inspector-storage-bad-id-"));
|
||||||
|
try {
|
||||||
|
const { baseUrl, server, authToken } = await startRemoteServer(0, {
|
||||||
|
storageDir: tmp,
|
||||||
|
});
|
||||||
|
remoteServer = server;
|
||||||
|
const res = await fetch(`${baseUrl}/api/storage/has.dots`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-mcp-remote-auth": `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ k: "v" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect((await res.json()).error).toBe("Invalid storeId");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/storage POST returns 400 for invalid JSON body", async () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), "inspector-storage-bad-json-"));
|
||||||
|
try {
|
||||||
|
const { baseUrl, server, authToken } = await startRemoteServer(0, {
|
||||||
|
storageDir: tmp,
|
||||||
|
});
|
||||||
|
remoteServer = server;
|
||||||
|
const res = await fetch(`${baseUrl}/api/storage/teststore`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-mcp-remote-auth": `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: "not json",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect((await res.json()).error).toBe("Invalid JSON body");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/storage GET returns 500 when the on-disk store is unparseable", async () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), "inspector-storage-corrupt-"));
|
||||||
|
try {
|
||||||
|
// Write a corrupted file at the storeId path so parseStore() throws
|
||||||
|
writeFileSync(join(tmp, "teststore.json"), "{ not json");
|
||||||
|
const { baseUrl, server, authToken } = await startRemoteServer(0, {
|
||||||
|
storageDir: tmp,
|
||||||
|
});
|
||||||
|
remoteServer = server;
|
||||||
|
const res = await fetch(`${baseUrl}/api/storage/teststore`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { "x-mcp-remote-auth": `Bearer ${authToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect((await res.json()).error).toMatch(/Failed to read store:/);
|
||||||
|
} finally {
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -138,18 +138,6 @@ export default defineConfig({
|
||||||
path.join(repoRoot, 'core/mcp/remote/types.ts'),
|
path.join(repoRoot, 'core/mcp/remote/types.ts'),
|
||||||
// .d.ts files are declaration-only.
|
// .d.ts files are declaration-only.
|
||||||
path.join(repoRoot, '**/*.d.ts'),
|
path.join(repoRoot, '**/*.d.ts'),
|
||||||
// TODO(#1310): coverage debt on two large v1.5-ported files that the
|
|
||||||
// existing v1.5 tests don't fully exercise. #1307 brought the
|
|
||||||
// integration suite back online and lifted all other v1.5 coverage
|
|
||||||
// gates; #1310 tracks filling these two:
|
|
||||||
// - inspectorClient.ts (2184 LOC) is at 73% lines despite 4005 LOC
|
|
||||||
// of integration tests; remaining gaps are scattered across OAuth,
|
|
||||||
// subscription, and error-path code (~150 lines, 107 ranges).
|
|
||||||
// - remote/node/server.ts is at 88% lines / 78% functions; gaps are
|
|
||||||
// in transport-failure / 401-propagation error paths and the
|
|
||||||
// private createTokenAuthProvider helper.
|
|
||||||
path.join(repoRoot, 'core/mcp/inspectorClient.ts'),
|
|
||||||
path.join(repoRoot, 'core/mcp/remote/node/server.ts'),
|
|
||||||
// inspectorClientEventTarget.ts is types + a single empty-body class
|
// inspectorClientEventTarget.ts is types + a single empty-body class
|
||||||
// (extends TypedEventTarget). v8/istanbul records 0 statements for it
|
// (extends TypedEventTarget). v8/istanbul records 0 statements for it
|
||||||
// today. TODO(#1243): drop this exclusion once the class gains real
|
// today. TODO(#1243): drop this exclusion once the class gains real
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,7 @@ import { createTransportNode } from "../../node/transport.js";
|
||||||
import type { RemoteConnectRequest, RemoteSendRequest } from "../types.js";
|
import type { RemoteConnectRequest, RemoteSendRequest } from "../types.js";
|
||||||
import type { MCPServerConfig } from "../../types.js";
|
import type { MCPServerConfig } from "../../types.js";
|
||||||
import { RemoteSession } from "./remote-session.js";
|
import { RemoteSession } from "./remote-session.js";
|
||||||
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
import { createTokenAuthProvider } from "./tokenAuthProvider.js";
|
||||||
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
||||||
import { API_SERVER_ENV_VARS } from "../constants.js";
|
import { API_SERVER_ENV_VARS } from "../constants.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -185,44 +184,6 @@ function createAuthMiddleware(authToken: string) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple OAuth client provider that just returns tokens.
|
|
||||||
* Used by remote server to inject Bearer tokens into transport requests.
|
|
||||||
*/
|
|
||||||
function createTokenAuthProvider(
|
|
||||||
tokens: RemoteConnectRequest["oauthTokens"],
|
|
||||||
): OAuthClientProvider | undefined {
|
|
||||||
if (!tokens) return undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
async tokens(): Promise<OAuthTokens | undefined> {
|
|
||||||
return tokens as OAuthTokens;
|
|
||||||
},
|
|
||||||
// Other methods not needed for transport Bearer token injection
|
|
||||||
async clientInformation() {
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
async saveTokens() {
|
|
||||||
// No-op
|
|
||||||
},
|
|
||||||
codeVerifier() {
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
async saveCodeVerifier() {
|
|
||||||
// No-op
|
|
||||||
},
|
|
||||||
clear() {
|
|
||||||
// No-op
|
|
||||||
},
|
|
||||||
redirectToAuthorization() {
|
|
||||||
// No-op
|
|
||||||
},
|
|
||||||
state() {
|
|
||||||
return "";
|
|
||||||
},
|
|
||||||
} as unknown as OAuthClientProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
function forwardLogEvent(
|
function forwardLogEvent(
|
||||||
logger: pino.Logger,
|
logger: pino.Logger,
|
||||||
logEvent: Partial<LogEvent>,
|
logEvent: Partial<LogEvent>,
|
||||||
|
|
|
||||||
43
core/mcp/remote/node/tokenAuthProvider.ts
Normal file
43
core/mcp/remote/node/tokenAuthProvider.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
|
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
import type { RemoteConnectRequest } from "../types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple OAuth client provider that just returns tokens.
|
||||||
|
* Used by the remote server to inject Bearer tokens into transport requests.
|
||||||
|
* The other OAuthClientProvider methods are required by the interface but never
|
||||||
|
* exercised in the remote-server path (the SDK only calls `tokens()` for Bearer
|
||||||
|
* injection); they are kept as no-op stubs.
|
||||||
|
*/
|
||||||
|
export function createTokenAuthProvider(
|
||||||
|
tokens: RemoteConnectRequest["oauthTokens"],
|
||||||
|
): OAuthClientProvider | undefined {
|
||||||
|
if (!tokens) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async tokens(): Promise<OAuthTokens | undefined> {
|
||||||
|
return tokens as OAuthTokens;
|
||||||
|
},
|
||||||
|
async clientInformation() {
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
async saveTokens() {
|
||||||
|
// No-op
|
||||||
|
},
|
||||||
|
codeVerifier() {
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
async saveCodeVerifier() {
|
||||||
|
// No-op
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
// No-op
|
||||||
|
},
|
||||||
|
redirectToAuthorization() {
|
||||||
|
// No-op
|
||||||
|
},
|
||||||
|
state() {
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
} as unknown as OAuthClientProvider;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue