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
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,11 @@ MCP_RATE_LIMIT_PER_MINUTE=60
# What the shared /mcp endpoint lists. "direct" (self-hosted default) lists
# the caller's own tools; "fixed" (cloud default) lists one fixed set of tools
# for every user (search, describe, run read / run write, workspace guide…)
# through which the caller's tools are reached. /mcp/<serverId> always lists a
# server's tools directly.
# through which the caller's tools are reached. In "fixed" mode a ChatGPT
# connection (OAuth client registered with a chatgpt.com/openai.com redirect)
# also gets tools for multi-step reads and adding connectors; every other
# client gets the base set. /mcp/<serverId> always lists a server's tools
# directly.
# MCP_SHARED_ENDPOINT_TOOLS=direct
# Idle session eviction (minutes) and a global cap on concurrent sessions.
# MCP_SESSION_IDLE_MIN=30
Expand Down
14 changes: 11 additions & 3 deletions packages/backend/src/mcp-server/mcp-endpoint.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ import {
ResolvedGrant,
} from '../mcp-servers/mcp-connection-grant.service';
import {
SHARED_TOOLSET_INSTRUCTIONS,
SharedToolsetDeps,
profileForRedirectUris,
registerSharedToolset,
sharedEndpointMode,
sharedToolsetInstructions,
} from './shared-toolset';

/**
Expand Down Expand Up @@ -377,16 +378,23 @@ export class McpEndpointController {
},
};

// Which assistant this connection belongs to, from the OAuth client the
// token was issued to. API keys and anything unidentified get the default
// set, the one the Claude directory reviewed.
const profile = profileForRedirectUris(
await this.grants.clientRedirectUris(oauthClientId(user)),
);

await this.serveStateless(
req,
res,
(req as any).body,
() => {
const mcpServer = new McpServer(
{ name: 'AnythingMCP', version: APP_VERSION },
{ instructions: SHARED_TOOLSET_INSTRUCTIONS },
{ instructions: sharedToolsetInstructions(profile) },
);
registerSharedToolset(mcpServer, scopeTools, deps);
registerSharedToolset(mcpServer, scopeTools, deps, profile);
return mcpServer;
},
'shared /mcp',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Client } from '@modelcontextprotocol/client';
import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server';
import { McpEndpointController } from './mcp-endpoint.controller';
import { SHARED_TOOL_NAMES } from './shared-toolset';
import { CHATGPT_EXTRA_TOOL_NAMES, SHARED_TOOL_NAMES } from './shared-toolset';
import type { RegisteredTool } from './tool-registry';

/**
Expand Down Expand Up @@ -36,6 +36,12 @@ const ALL = [
tool('t-b1', 'crm_find_customer', 'org-B', 'conn-B1'),
];

// Redirect URIs as the two assistants register them in production.
const REDIRECTS: Record<string, string[]> = {
'client-claude': ['https://claude.ai/api/mcp/auth_callback'],
'client-chatgpt': ['https://chatgpt.com/connector_platform_oauth_redirect'],
};

function build(opts: { grant?: unknown; allowedByOrg?: Record<string, string[] | null> } = {}) {
const executor = {
executeTool: jest.fn(async () => ({
Expand Down Expand Up @@ -67,7 +73,10 @@ function build(opts: { grant?: unknown; allowedByOrg?: Record<string, string[] |
} as any,
kg as any,
{} as any,
{ resolve: jest.fn().mockResolvedValue(opts.grant ?? null) } as any,
{
resolve: jest.fn().mockResolvedValue(opts.grant ?? null),
clientRedirectUris: jest.fn(async (id?: string) => (id ? (REDIRECTS[id] ?? []) : [])),
} as any,
{ create: jest.fn() } as any,
);

Expand Down Expand Up @@ -114,6 +123,33 @@ describe('shared /mcp in fixed mode', () => {
expect(names).toEqual([...SHARED_TOOL_NAMES].sort());
});

it('gives a Claude connection exactly the reviewed set, with the Claude instructions', async () => {
const claude = await build().connect({ ...orgB, azp: 'client-claude' });
const plain = await build().connect(orgB);
const listClaude = (await claude.listTools()).tools;
expect(listClaude.map((t) => t.name).sort()).toEqual([...SHARED_TOOL_NAMES].sort());
expect(JSON.stringify(listClaude)).toBe(JSON.stringify((await plain.listTools()).tools));
expect(claude.getInstructions()).toBe(plain.getInstructions());
});

it('gives a ChatGPT connection the reviewed set plus the ChatGPT tools', async () => {
const client = await build().connect({ ...orgB, azp: 'client-chatgpt' });
const names = (await client.listTools()).tools.map((t) => t.name).sort();
expect(names).toEqual([...SHARED_TOOL_NAMES, ...CHATGPT_EXTRA_TOOL_NAMES].sort());
expect(client.getInstructions()).toMatch(/anythingmcp_run_read_steps/);
});

it('falls back to the reviewed set for an unknown client or an API key', async () => {
for (const user of [
{ ...orgB, azp: 'client-unregistered' },
{ ...orgB, authMethod: 'api_key', mcpServerId: undefined },
]) {
const client = await build().connect(user);
const names = (await client.listTools()).tools.map((t) => t.name).sort();
expect(names).toEqual([...SHARED_TOOL_NAMES].sort());
}
});

it('runs the caller\'s own copy of a colliding tool name, pinned to its connector', async () => {
const { executor, connect } = build();
const client = await connect(orgB);
Expand Down
150 changes: 147 additions & 3 deletions packages/backend/src/mcp-server/shared-toolset.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import { Client } from '@modelcontextprotocol/client';
import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server';
import { RegisteredTool } from './tool-registry';
import {
CHATGPT_EXTRA_TOOL_NAMES,
SHARED_TOOL_NAMES,
SHARED_TOOLSET_INSTRUCTIONS,
SharedToolsetDeps,
SharedToolsetProfile,
excludedOnSharedEndpoint,
profileForRedirectUris,
registerSharedToolset,
sharedEndpointMode,
sharedToolsetInstructions,
} from './shared-toolset';

function tool(p: Partial<RegisteredTool> & { name: string; connectorId: string }): RegisteredTool {
Expand Down Expand Up @@ -75,12 +79,16 @@ function makeDeps(overrides: Partial<SharedToolsetDeps> = {}) {
return deps;
}

async function connect(scope: RegisteredTool[], deps = makeDeps()) {
async function connect(
scope: RegisteredTool[],
deps = makeDeps(),
profile: SharedToolsetProfile = 'default',
) {
const server = new McpServer(
{ name: 'AnythingMCP', version: 'test' },
{ instructions: SHARED_TOOLSET_INSTRUCTIONS },
{ instructions: sharedToolsetInstructions(profile) },
);
registerSharedToolset(server, scope, deps);
registerSharedToolset(server, scope, deps, profile);
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
await server.connect(serverSide);
const client = new Client({ name: 'spec', version: '1.0.0' });
Expand Down Expand Up @@ -411,3 +419,139 @@ describe('connector setup from the chat (AnythingMCP Setup)', () => {
expect(tools.map((t) => t.name).sort()).toEqual([...SHARED_TOOL_NAMES].sort());
});
});

describe('which tool set a client gets', () => {
it('recognises ChatGPT by the redirect URIs it registered, and nothing else', () => {
expect(profileForRedirectUris(['https://chatgpt.com/connector_platform_oauth_redirect'])).toBe('chatgpt');
expect(profileForRedirectUris(['https://chatgpt.com/connector/oauth/CvGqWES8FsNv'])).toBe('chatgpt');
expect(profileForRedirectUris(['https://platform.openai.com/apps-manage/oauth'])).toBe('chatgpt');
expect(profileForRedirectUris(['https://claude.ai/api/mcp/auth_callback'])).toBe('default');
expect(profileForRedirectUris(['cursor://anysphere.cursor-mcp/oauth/callback'])).toBe('default');
expect(profileForRedirectUris([])).toBe('default');
expect(profileForRedirectUris(undefined)).toBe('default');
// Look-alikes and cleartext do not count.
expect(profileForRedirectUris(['https://chatgpt.com.evil.io/cb'])).toBe('default');
expect(profileForRedirectUris(['https://notchatgpt.com/cb'])).toBe('default');
expect(profileForRedirectUris(['http://chatgpt.com/cb'])).toBe('default');
expect(profileForRedirectUris(['not a url'])).toBe('default');
});

it('leaves the default set exactly as the Claude directory reviewed it', async () => {
const { client } = await connect([CRM_READ, CRM_WRITE]);
const { tools } = await client.listTools();
expect(tools.map((t) => t.name).sort()).toEqual([...SHARED_TOOL_NAMES].sort());
expect(client.getInstructions()).toBe(SHARED_TOOLSET_INSTRUCTIONS);
const read = tools.find((t) => t.name === 'anythingmcp_run_read_tool')!.annotations;
expect(read).toEqual({ title: 'Run read-only tool', readOnlyHint: true, openWorldHint: true });
});
});

describe('ChatGPT tool set', () => {
const connectGpt = (scope: RegisteredTool[], deps = makeDeps()) => connect(scope, deps, 'chatgpt');

it('is the reviewed set plus four tools, the same for every caller', async () => {
const a = await connectGpt([CRM_READ, CRM_WRITE]);
const b = await connectGpt([]);
const listA = (await a.client.listTools()).tools;
expect(listA.map((t) => t.name).sort()).toEqual(
[...SHARED_TOOL_NAMES, ...CHATGPT_EXTRA_TOOL_NAMES].sort(),
);
expect(JSON.stringify(listA)).toBe(JSON.stringify((await b.client.listTools()).tools));
});

it('sets readOnlyHint, destructiveHint and openWorldHint on every tool', async () => {
const { client } = await connectGpt([]);
for (const t of (await client.listTools()).tools) {
expect(typeof t.annotations?.title).toBe('string');
for (const hint of ['readOnlyHint', 'destructiveHint', 'openWorldHint'] as const) {
expect({ tool: t.name, hint, type: typeof t.annotations?.[hint] }).toEqual({
tool: t.name,
hint,
type: 'boolean',
});
}
}
});

it('runs several reads in one call and reports each step', async () => {
const OTHER_READ = tool({ name: 'erp_open_invoices', connectorId: 'c-erp' });
const { client, deps } = await connectGpt([CRM_READ, OTHER_READ]);
const out = await call(client, 'anythingmcp_run_read_steps', {
steps: [
{ tool: 'crm_find_customer', arguments: { email: 'a@b.io' } },
{ tool: 'erp_open_invoices' },
{ tool: 'crm_find_customer', arguments: {} },
],
});
expect(out.isError).toBe(false);
expect(out.body.succeeded).toBe(2);
expect(out.body.failed).toBe(1);
expect(out.body.steps[0]).toMatchObject({ step: 1, ok: true, result: { ran: 'c-crm:crm_find_customer' } });
expect(out.body.steps[2]).toMatchObject({ step: 3, ok: false });
expect(deps.execute).toHaveBeenCalledTimes(2);
});

it('refuses a write tool inside the steps runner without running it', async () => {
const { client, deps } = await connectGpt([CRM_READ, CRM_WRITE]);
const out = await call(client, 'anythingmcp_run_read_steps', {
steps: [{ tool: 'crm_create_deal', arguments: { title: 'x' } }],
});
expect(out.body.steps[0].ok).toBe(false);
expect(JSON.stringify(out.body)).toContain('anythingmcp_run_write_tool');
expect(deps.execute).not.toHaveBeenCalled();
});

it('does not reach payment connectors through the steps runner', async () => {
const { client, deps } = await connectGpt([WISE_PAY]);
const out = await call(client, 'anythingmcp_run_read_steps', {
steps: [{ tool: 'wise_create_transfer' }],
});
expect(out.body.steps[0].ok).toBe(false);
expect(deps.execute).not.toHaveBeenCalled();
});

it('adds connectors through the setup service, pointing at the tools it lists', async () => {
const run = jest.fn(async (name: string) =>
name === 'setup_find_connectors'
? { body: { results: [{ adapter: 'etsy' }], next: 'then call setup_install_connector' } }
: { body: { installed: 'Etsy', next: 'call setup_get_status when done' } },
);
const { client } = await connectGpt([], makeDeps({ setup: { organizationId: 'org-A', run } } as any));
const found = await call(client, 'anythingmcp_find_connectors', { query: 'etsy' });
expect(found.body.next).toBe('then call anythingmcp_add_connector');
const added = await call(client, 'anythingmcp_add_connector', { adapter: 'etsy' });
expect(added.body).toEqual({ installed: 'Etsy', next: 'call anythingmcp_connection_status when done' });
expect(run).toHaveBeenCalledWith('setup_install_connector', { adapter: 'etsy' });
});

it('explains a connector limit without plan quotas or upgrade links', async () => {
const run = jest.fn(async (name: string) =>
name === 'setup_find_connectors'
? { body: { results: [], connectorsLeftOnThisPlan: 0 } }
: {
isError: true,
body: {
error: 'Trial limit reached (10 connectors).',
whatTheUserCanDo: 'Add a card to continue the trial on the full plan, or remove a connector.',
upgradeUrl: 'https://cloud.example.com/start-trial',
},
},
);
const { client } = await connectGpt([], makeDeps({ setup: { organizationId: 'org-A', run } } as any));
const found = await call(client, 'anythingmcp_find_connectors', { query: 'etsy' });
expect(found.body).toEqual({ results: [] });
const added = await call(client, 'anythingmcp_add_connector', { adapter: 'etsy' });
expect(added.isError).toBe(true);
expect(JSON.stringify(added.body)).not.toMatch(/start-trial|card|upgradeUrl/);
expect(added.body.error).toBe('Trial limit reached (10 connectors).');
});

it('marks adding a connector as a write, and refuses it to callers who may not', async () => {
const { client } = await connectGpt([]);
const add = (await client.listTools()).tools.find((t) => t.name === 'anythingmcp_add_connector');
expect(add?.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false });
const out = await call(client, 'anythingmcp_add_connector', { adapter: 'etsy' });
expect(out.isError).toBe(true);
expect(out.body.dashboardUrl).toBe('https://cloud.example.com/connectors');
});
});
Loading
Loading