Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/FastMCP.can-access-empty.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { describe, expect, it } from "vitest";
import { z } from "zod";

import { FastMCP } from "./FastMCP.js";

type Auth = { role: string };

function adminOnlyServer() {
const server = new FastMCP<Auth>({ name: "T", version: "1.0.0" });
server.addTool({
canAccess: (auth) => auth?.role === "admin",
description: "Admin only",
execute: async () => "secret",
name: "admin-only",
parameters: z.object({}),
});
return server;
}

async function connectAs(server: FastMCP<Auth>, auth: Auth) {
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const client = new Client({ name: "c", version: "0.0.0" });
await Promise.all([
server.connect(serverTransport, auth),
client.connect(clientTransport),
]);
return client;
}

describe("canAccess filtering every tool out of a session (#370)", () => {
it("still advertises the tools capability and answers tools/list with an empty list", async () => {
const server = adminOnlyServer();
const client = await connectAs(server, { role: "user" });
try {
expect(client.getServerCapabilities()?.tools).toBeDefined();
await expect(client.listTools()).resolves.toEqual({ tools: [] });
} finally {
await client.close();
await server.stop();
}
});

it("lets addTool() run while such a session is connected", async () => {
const server = adminOnlyServer();
const client = await connectAs(server, { role: "user" });
try {
expect(() =>
server.addTool({
description: "For everyone",
execute: async () => "hi",
name: "public",
parameters: z.object({}),
}),
).not.toThrow();
// The list-changed refresh is asynchronous; the next list reflects it.
await new Promise((resolve) => setTimeout(resolve, 0));
const { tools } = await client.listTools();
expect(tools.map((tool) => tool.name)).toEqual(["public"]);
} finally {
await client.close();
await server.stop();
}
});

it("keeps a session that may see the tool unchanged", async () => {
const server = adminOnlyServer();
const client = await connectAs(server, { role: "admin" });
try {
const { tools } = await client.listTools();
expect(tools.map((tool) => tool.name)).toEqual(["admin-only"]);
} finally {
await client.close();
await server.stop();
}
});

it("does not advertise tools for a server that has none", async () => {
const server = new FastMCP<Auth>({ name: "T", version: "1.0.0" });
const client = await connectAs(server, { role: "user" });
try {
expect(client.getServerCapabilities()?.tools).toBeUndefined();
} finally {
await client.close();
await server.stop();
}
});
});
18 changes: 16 additions & 2 deletions src/FastMCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,7 @@ export class FastMCPSession<

constructor({
auth,
hasTools,
icons,
instructions,
logger,
Expand All @@ -1474,6 +1475,11 @@ export class FastMCPSession<
websiteUrl,
}: {
auth?: T;
/**
* Whether the server has any tools at all, including ones this session's
* `canAccess` filtering removed from `tools`. Defaults to `tools.length > 0`.
*/
hasTools?: boolean;
icons?: Icon[];
instructions?: string;
logger: Logger;
Expand Down Expand Up @@ -1506,7 +1512,14 @@ export class FastMCPSession<
this.#streamKeepaliveConfig = streamKeepalive;
this.#needsEventLoopFlush = transportType === "httpStream";

if (tools.length) {
// The `tools` capability describes the server, not what this session may
// see: a session whose `canAccess` filtering removed every tool still
// belongs to a server that supports tools. Gating on the filtered list
// answered `tools/list` with -32601 instead of an empty list and made
// `addTool()` throw while such a session was connected (#370).
const supportsTools = hasTools ?? tools.length > 0;

if (supportsTools) {
this.#capabilities.tools = {};
}

Expand Down Expand Up @@ -1544,7 +1557,7 @@ export class FastMCPSession<
this.setupRootsHandlers();
this.setupCompleteHandlers();

if (tools.length) {
if (supportsTools) {
this.setupToolHandlers(tools);
}

Expand Down Expand Up @@ -3695,6 +3708,7 @@ export class FastMCP<
: this.#tools;
return new FastMCPSession<T>({
auth,
hasTools: this.#tools.length > 0,
icons: this.#options.icons,
instructions: this.#options.instructions,
logger: this.#logger,
Expand Down
Loading