diff --git a/.env.example b/.env.example index 49e87d6c..1ef7acbb 100644 --- a/.env.example +++ b/.env.example @@ -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/ 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/ 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 diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts index 39a9d00b..229caabd 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts @@ -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'; /** @@ -377,6 +378,13 @@ 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, @@ -384,9 +392,9 @@ export class McpEndpointController { () => { 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', diff --git a/packages/backend/src/mcp-server/shared-endpoint.controller.spec.ts b/packages/backend/src/mcp-server/shared-endpoint.controller.spec.ts index 10fc392b..132ce30b 100644 --- a/packages/backend/src/mcp-server/shared-endpoint.controller.spec.ts +++ b/packages/backend/src/mcp-server/shared-endpoint.controller.spec.ts @@ -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'; /** @@ -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 = { + '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 } = {}) { const executor = { executeTool: jest.fn(async () => ({ @@ -67,7 +73,10 @@ function build(opts: { grant?: unknown; allowedByOrg?: Record (id ? (REDIRECTS[id] ?? []) : [])), + } as any, { create: jest.fn() } as any, ); @@ -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); diff --git a/packages/backend/src/mcp-server/shared-toolset.spec.ts b/packages/backend/src/mcp-server/shared-toolset.spec.ts index 78d32c4f..badc8de2 100644 --- a/packages/backend/src/mcp-server/shared-toolset.spec.ts +++ b/packages/backend/src/mcp-server/shared-toolset.spec.ts @@ -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 & { name: string; connectorId: string }): RegisteredTool { @@ -75,12 +79,16 @@ function makeDeps(overrides: Partial = {}) { 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' }); @@ -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'); + }); +}); diff --git a/packages/backend/src/mcp-server/shared-toolset.ts b/packages/backend/src/mcp-server/shared-toolset.ts index 51b431d7..f71a6a4c 100644 --- a/packages/backend/src/mcp-server/shared-toolset.ts +++ b/packages/backend/src/mcp-server/shared-toolset.ts @@ -43,6 +43,70 @@ export const SHARED_TOOLSET_INSTRUCTIONS = [ 'kg_how_to_obtain tells which tool produces a value you need (for example a customer id). anythingmcp_get_configuration_url links to the dashboard where connectors are added or changed.', ].join('\n'); +/** + * Which variant of the fixed tool set a connection gets, chosen by the OAuth + * client it authorized. Each assistant directory reviews the tool list its own + * client sees, so each client gets one set, the same for all of its users: + * + * - `default`: the eight tools above. Claude, every other client, and any + * request whose client cannot be identified. This is the set the Claude + * connectors directory reviewed, and it must not change by accident. + * - `chatgpt`: those eight plus four tools for multi-step work (several reads + * in one call, adding and checking connectors), with all three hints set + * explicitly on every tool, which OpenAI's review requires. + */ +export type SharedToolsetProfile = 'default' | 'chatgpt'; + +export const CHATGPT_EXTRA_TOOL_NAMES = [ + 'anythingmcp_run_read_steps', + 'anythingmcp_find_connectors', + 'anythingmcp_add_connector', + 'anythingmcp_connection_status', +] as const; + +const CHATGPT_HOSTS = ['chatgpt.com', 'openai.com']; + +/** + * The profile for an OAuth client, from the redirect URIs it registered. They + * are fixed at registration and the access token is bound to the client, so + * this cannot be steered by a header. Anything that is not clearly ChatGPT + * gets the default set. + */ +export function profileForRedirectUris( + uris: readonly string[] | null | undefined, +): SharedToolsetProfile { + for (const uri of uris ?? []) { + let host: string; + try { + const url = new URL(uri); + if (url.protocol !== 'https:') continue; + host = url.hostname.toLowerCase(); + } catch { + continue; + } + if (CHATGPT_HOSTS.some((h) => host === h || host.endsWith(`.${h}`))) return 'chatgpt'; + } + return 'default'; +} + +export function sharedToolNames(profile: SharedToolsetProfile): string[] { + return profile === 'chatgpt' + ? [...SHARED_TOOL_NAMES, ...CHATGPT_EXTRA_TOOL_NAMES] + : [...SHARED_TOOL_NAMES]; +} + +const CHATGPT_INSTRUCTIONS = [ + "AnythingMCP runs work across the business systems the user connected to their AnythingMCP workspace: ERP, accounting, online shops, CRM, databases and their own APIs. The tool list is the same for every user; the workspace's own tools are reached through it.", + '1. anythingmcp_list_connectors shows what this connection can reach; anythingmcp_get_workspace_guide returns the workspace\'s notes and approved workflows. Read the guide before first using a connector.', + '2. anythingmcp_search_tools finds a tool by keyword; anythingmcp_describe_tool returns its parameters. kg_how_to_obtain tells which tool produces a value you need, also across systems (for example the customer id in the ERP for an order in the shop).', + '3. anythingmcp_run_read_steps runs several read-only tools in one call, for requests that combine systems; anythingmcp_run_read_tool runs one. anythingmcp_run_write_tool runs a tool that creates, changes, deletes or sends something: say what it will do and get the user\'s confirmation before each call.', + '4. When an app the user needs is not connected, anythingmcp_find_connectors and anythingmcp_add_connector add it (ask the user first, never ask for passwords or keys in the chat); anythingmcp_connection_status checks what still needs the user. anythingmcp_get_configuration_url links to the dashboard.', +].join('\n'); + +export function sharedToolsetInstructions(profile: SharedToolsetProfile): string { + return profile === 'chatgpt' ? CHATGPT_INSTRUCTIONS : SHARED_TOOLSET_INSTRUCTIONS; +} + /** * Which endpoint surface `/mcp` serves. `fixed` = the tool set above; * `direct` = each workspace's own tools, as the endpoint did originally. @@ -270,8 +334,26 @@ const runInput = { .describe('The tool\'s parameters, as described by anythingmcp_describe_tool.'), }; +type Annotations = { + title: string; + readOnlyHint: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint: boolean; +}; + +/** OpenAI's review rejects a tool that leaves any of the three hints unset. */ +function explicitHints(a: Annotations): Annotations { + return a.readOnlyHint + ? { ...a, destructiveHint: a.destructiveHint ?? false, idempotentHint: a.idempotentHint ?? true } + : a; +} + +const RUN_STEPS_MAX = 10; + /** - * Registers the eight tools on a per-request server. + * Registers the tool set on a per-request server: the eight shared tools, plus + * the ChatGPT ones for that profile (see {@link SharedToolsetProfile}). * * `scopeTools` is everything this caller may use, already narrowed by the * controller to the connection grant and the MCP role. The exclusions are @@ -281,7 +363,12 @@ export function registerSharedToolset( mcpServer: McpServer, scopeTools: RegisteredTool[], deps: SharedToolsetDeps, + profile: SharedToolsetProfile = 'default', ): void { + // The default set's annotations are passed through untouched: they are what + // the Claude directory reviewed. + const hints = (a: Annotations): Annotations => + profile === 'chatgpt' ? explicitHints(a) : a; const served = scopeTools.filter((t) => !excludedOnSharedEndpoint(t)); const withheld = scopeTools.filter((t) => excludedOnSharedEndpoint(t)); // Connectors still missing a credential or an authorization: not offered to @@ -435,11 +522,11 @@ export function registerSharedToolset( description: 'List the connectors (APIs, databases, applications) this connection can use in the user\'s AnythingMCP workspace, with how many tools each has and whether they read or write.', inputSchema: {}, - annotations: { + annotations: hints({ title: 'List connectors', readOnlyHint: true, openWorldHint: false, - }, + }), }, async () => { const byId = await connectorsById(); @@ -487,7 +574,9 @@ export function registerSharedToolset( ...(connectors.every((c) => c.id === SETUP_CONNECTOR_ID) && needsSetup.length === 0 ? { hint: deps.setup - ? `No apps are connected yet. Ask the user which app they want to work with, then add it with the ${SETUP_CONNECTOR_NAME} tools (setup_find_connectors, then setup_install_connector).` + ? profile === 'chatgpt' + ? 'No apps are connected yet. Ask the user which app they want to work with, then add it with anythingmcp_find_connectors and anythingmcp_add_connector.' + : `No apps are connected yet. Ask the user which app they want to work with, then add it with the ${SETUP_CONNECTOR_NAME} tools (setup_find_connectors, then setup_install_connector).` : 'No connectors yet. The user adds them in the dashboard: call anythingmcp_get_configuration_url.', } : {}), @@ -518,11 +607,11 @@ export function registerSharedToolset( .optional() .describe(`Maximum results (default ${SEARCH_DEFAULT_LIMIT}, max ${SEARCH_MAX_LIMIT}).`), }, - annotations: { + annotations: hints({ title: 'Search tools', readOnlyHint: true, openWorldHint: false, - }, + }), }, async (args: { query?: string; @@ -586,11 +675,11 @@ export function registerSharedToolset( description: 'Full description, input schema and annotations of one tool of the user\'s workspace, and which run tool to use for it.', inputSchema: toolRef, - annotations: { + annotations: hints({ title: 'Describe tool', readOnlyHint: true, openWorldHint: false, - }, + }), }, async (args: { tool: string; connector?: string }) => { const found = await resolve(args.tool, args.connector); @@ -617,11 +706,11 @@ export function registerSharedToolset( description: 'Run a tool of the user\'s workspace that only reads data (its access is "read" in anythingmcp_search_tools). Tools that change data are refused here.', inputSchema: runInput, - annotations: { + annotations: hints({ title: 'Run read-only tool', readOnlyHint: true, openWorldHint: true, - }, + }), }, async (args: { tool: string; connector?: string; arguments?: Record }) => run('read', args), @@ -633,13 +722,13 @@ export function registerSharedToolset( description: 'Run a tool of the user\'s workspace that creates, changes, deletes or sends something (its access is "write"). Tell the user what it will do and get their confirmation before each call.', inputSchema: runInput, - annotations: { + annotations: hints({ title: 'Run tool that changes data', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, - }, + }), }, async (args: { tool: string; connector?: string; arguments?: Record }) => run('write', args), @@ -653,11 +742,11 @@ export function registerSharedToolset( inputSchema: { connector: z.string().optional().describe('Only this connector (id or name).'), }, - annotations: { + annotations: hints({ title: 'Workspace guide', readOnlyHint: true, openWorldHint: false, - }, + }), }, async (args: { connector?: string }) => { const ids = args.connector ? [...(await matchConnector(args.connector))] : connectorIds; @@ -687,11 +776,11 @@ export function registerSharedToolset( inputSchema: { query: z.string().describe('An entity or parameter name, e.g. "customer_id" or "deal".'), }, - annotations: { + annotations: hints({ title: 'How to obtain', readOnlyHint: true, openWorldHint: false, - }, + }), }, async (args: { query: string }) => { const result = await deps.kgLookup(args.query, connectorIds); @@ -711,11 +800,11 @@ export function registerSharedToolset( description: 'Link to the AnythingMCP dashboard where the user adds, removes or configures connectors, plus the direct URL of each of their MCP servers.', inputSchema: {}, - annotations: { + annotations: hints({ title: 'Configuration link', readOnlyHint: true, openWorldHint: false, - }, + }), }, async () => { const cfg = await deps.configuration(); @@ -726,4 +815,181 @@ export function registerSharedToolset( }); }, ); + + if (profile === 'chatgpt') registerChatgptTools(mcpServer, deps, run); +} + +type RunArgs = { tool: string; connector?: string; arguments?: Record }; + +/** Parsed JSON when the text is JSON, the text otherwise. */ +function resultBody(result: TextResult): unknown { + const text = result.content.map((c) => c.text).join('\n'); + try { + return JSON.parse(text); + } catch { + return text; + } +} + +/** + * A setup answer without plan quotas or upgrade links. OpenAI does not allow a + * plugin to promote upgrades or link to a page that starts one, so on the + * ChatGPT tools a connector limit is explained without either. + */ +function withoutPlanDetails(body: unknown): unknown { + if (!body || typeof body !== 'object' || Array.isArray(body)) return body; + const { + connectorsLeftOnThisPlan: _left, + upgradeUrl, + whatTheUserCanDo, + ...rest + } = body as Record; + const aboutBilling = + upgradeUrl !== undefined || + (typeof whatTheUserCanDo === 'string' && /upgrade|plan|card|trial/i.test(whatTheUserCanDo)); + return { + ...rest, + ...(aboutBilling + ? { + whatTheUserCanDo: + 'This workspace cannot add more connectors right now. Remove a connector it no longer needs, or ask a workspace administrator.', + } + : whatTheUserCanDo !== undefined + ? { whatTheUserCanDo } + : {}), + }; +} + +/** + * The ChatGPT profile's extra tools. Every one of them goes through the same + * scope and checks as the shared tools: the steps runner calls the read + * runner for each step, and the connector tools call the same setup service + * that the hidden setup_* tools use. + */ +function registerChatgptTools( + mcpServer: McpServer, + deps: SharedToolsetDeps, + run: (mode: 'read' | 'write', args: RunArgs) => Promise, +): void { + // Always listed, so every ChatGPT user sees the same tools; a caller who may + // not add connectors gets this answer instead. + const setupOrRefuse = async (name: string, args: Record) => { + if (!deps.setup) { + const cfg = await deps.configuration(); + return json( + { + error: + 'Adding connectors from the chat is open to the workspace\'s admins and editors only, and not to a connection pinned to one server. Ask a workspace admin, or add the connector in the dashboard.', + dashboardUrl: cfg.dashboardUrl, + }, + true, + ); + } + const out = await deps.setup.run(name, args); + // The setup service names its own tools in its hints; point the model at + // the tools it actually sees here. + const text = JSON.stringify(withoutPlanDetails(out.body), null, 2) + .replace(/\bsetup_find_connectors\b/g, 'anythingmcp_find_connectors') + .replace(/\bsetup_install_connector\b/g, 'anythingmcp_add_connector') + .replace(/\bsetup_get_status\b/g, 'anythingmcp_connection_status'); + return { + content: [{ type: 'text' as const, text }], + ...(out.isError ? { isError: true } : {}), + }; + }; + + mcpServer.registerTool( + 'anythingmcp_run_read_steps', + { + description: `Run up to ${RUN_STEPS_MAX} read-only tools of the user's workspace in one call, for requests that combine several systems (for example orders from the shop, the matching customers from the ERP and their open invoices from accounting). Steps run in parallel and each step's result is returned separately; a failing step does not stop the others. Tools that change data are refused here.`, + inputSchema: { + steps: z + .array(z.object(runInput)) + .min(1) + .max(RUN_STEPS_MAX) + .describe('The read-only tools to run, each with its arguments as described by anythingmcp_describe_tool.'), + }, + annotations: explicitHints({ + title: 'Run several read-only tools', + readOnlyHint: true, + openWorldHint: true, + }), + }, + async (args: { steps: RunArgs[] }) => { + const results = await Promise.all( + args.steps.map(async (step, i) => { + const result = await run('read', step); + return { + step: i + 1, + tool: step.tool, + ok: !result.isError, + result: resultBody(result), + }; + }), + ); + return json({ + steps: results, + succeeded: results.filter((r) => r.ok).length, + failed: results.filter((r) => !r.ok).length, + }); + }, + ); + + mcpServer.registerTool( + 'anythingmcp_find_connectors', + { + description: + 'Search the AnythingMCP catalog of ready connectors (ERPs, online shops, accounting, CRM, messaging, data APIs) for an app the user wants to connect. Returns each connector id, what setting it up involves, and which non-secret settings may be passed to anythingmcp_add_connector.', + inputSchema: { + query: z.string().min(1).describe('App name or topic, e.g. "etsy", "odoo", "invoices".'), + limit: z.number().int().min(1).max(10).optional().describe('Maximum results, 1 to 10. Default 5.'), + }, + annotations: explicitHints({ + title: 'Find a connector to add', + readOnlyHint: true, + openWorldHint: false, + }), + }, + async (args: { query: string; limit?: number }) => + setupOrRefuse('setup_find_connectors', args), + ); + + mcpServer.registerTool( + 'anythingmcp_add_connector', + { + description: + "Add a catalog connector to the user's workspace. Ask the user first. Pass only the non-secret settings anythingmcp_find_connectors listed (such as a tenant name or a shop URL), never passwords, API keys or tokens: when those are needed, the answer contains a one-time link where the user enters them or signs in to the provider.", + inputSchema: { + adapter: z.string().min(1).describe('Connector id from anythingmcp_find_connectors, e.g. "etsy".'), + settings: z + .record(z.string(), z.any()) + .optional() + .describe('Non-secret settings by name, e.g. {"WECLAPP_TENANT": "acme"}.'), + }, + annotations: { + title: 'Add a connector', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async (args: { adapter: string; settings?: Record }) => + setupOrRefuse('setup_install_connector', args), + ); + + mcpServer.registerTool( + 'anythingmcp_connection_status', + { + description: + "Which connectors of the workspace are ready and which still need the user, each with a fresh link to finish it. Call it after the user says they completed a setup link.", + inputSchema: {}, + annotations: explicitHints({ + title: 'Setup status', + readOnlyHint: true, + openWorldHint: false, + }), + }, + async () => setupOrRefuse('setup_get_status', {}), + ); } diff --git a/packages/backend/src/mcp-servers/mcp-connection-grant.service.spec.ts b/packages/backend/src/mcp-servers/mcp-connection-grant.service.spec.ts index 7572f611..c9aaaacc 100644 --- a/packages/backend/src/mcp-servers/mcp-connection-grant.service.spec.ts +++ b/packages/backend/src/mcp-servers/mcp-connection-grant.service.spec.ts @@ -379,3 +379,26 @@ describe('McpConnectionGrantService reports the first connection of a client', ( expect(events.log).not.toHaveBeenCalled(); }); }); + +describe('McpConnectionGrantService.clientRedirectUris', () => { + it('reads a client\'s redirect URIs once and caches them', async () => { + const findUnique = jest.fn(async () => ({ + redirectUris: ['https://chatgpt.com/connector_platform_oauth_redirect'], + })); + const svc = new McpConnectionGrantService({ oAuthClient: { findUnique } } as any); + expect(await svc.clientRedirectUris('c1')).toEqual(['https://chatgpt.com/connector_platform_oauth_redirect']); + expect(await svc.clientRedirectUris('c1')).toEqual(['https://chatgpt.com/connector_platform_oauth_redirect']); + expect(findUnique).toHaveBeenCalledTimes(1); + }); + + it('answers [] for no client, an unknown client or a database error', async () => { + const findUnique = jest + .fn() + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error('db down')); + const svc = new McpConnectionGrantService({ oAuthClient: { findUnique } } as any); + expect(await svc.clientRedirectUris(undefined)).toEqual([]); + expect(await svc.clientRedirectUris('missing')).toEqual([]); + expect(await svc.clientRedirectUris('c2')).toEqual([]); + }); +}); diff --git a/packages/backend/src/mcp-servers/mcp-connection-grant.service.ts b/packages/backend/src/mcp-servers/mcp-connection-grant.service.ts index 4bf2347e..5eee0fd6 100644 --- a/packages/backend/src/mcp-servers/mcp-connection-grant.service.ts +++ b/packages/backend/src/mcp-servers/mcp-connection-grant.service.ts @@ -39,9 +39,12 @@ export type ResolvedGrant = * tell "no such server" from "not your server" — that difference would * confirm the existence of another tenant's server. */ +const REDIRECT_URI_CACHE_MAX = 10_000; + @Injectable() export class McpConnectionGrantService { private readonly logger = new Logger(McpConnectionGrantService.name); + private readonly redirectUrisByClient = new Map(); constructor( private readonly prisma: PrismaService, @@ -275,6 +278,25 @@ export class McpConnectionGrantService { } /** First connection of this client for this user: the funnel step between sign-up and first call. */ + /** + * The redirect URIs an OAuth client registered, which tell which assistant + * it is (see `profileForRedirectUris`). A registration never changes them, + * so they are cached; a lookup failure answers `[]`, which means the default + * tool set. + */ + async clientRedirectUris(clientId: string | undefined): Promise { + if (!clientId) return []; + const cached = this.redirectUrisByClient.get(clientId); + if (cached) return cached; + const row = await this.prisma.oAuthClient + .findUnique({ where: { clientId }, select: { redirectUris: true } }) + .catch(() => null); + if (!row) return []; + if (this.redirectUrisByClient.size >= REDIRECT_URI_CACHE_MAX) this.redirectUrisByClient.clear(); + this.redirectUrisByClient.set(clientId, row.redirectUris); + return row.redirectUris; + } + private async reportConnected( clientId: string, userId: string,