diff --git a/docs/integrations/chatgpt.md b/docs/integrations/chatgpt.md index 5756875b..c51de4ad 100644 --- a/docs/integrations/chatgpt.md +++ b/docs/integrations/chatgpt.md @@ -70,6 +70,25 @@ ChatGPT will call the appropriate MCP tool and show you the results. --- +## The fixed plugin endpoint: `/mcp/chatgpt-app` + +`https:///mcp/chatgpt-app` serves one fixed set of tools, the same for every user. It is the endpoint for a plugin published in OpenAI's plugin directory, whose guidelines require every operation to be its own tool. It does not run your connectors' own tools; use `/mcp/` (or `/mcp`) for that. + +| Tool | What it does | +|------|--------------| +| `anythingmcp_list_connectors` | Connectors the connection reaches, their type and setup state | +| `anythingmcp_list_databases` | Databases that can be queried, with their engine | +| `anythingmcp_get_database_schema` | Tables and columns of one database | +| `anythingmcp_get_query_examples` | Example queries in the database's dialect | +| `anythingmcp_query_database` | One read-only query (SQL `SELECT`, or a MongoDB find) | +| `anythingmcp_get_connector_activity` | Calls and failures per connector, counts only | +| `anythingmcp_get_workspace_guide` | The workspace's notes on its connectors | +| `anythingmcp_get_configuration_url` | Link to the dashboard | + +Only database connectors set to **read-only** are queried; a connector with write access is listed as not queryable. Sign-in, the server or workspace picker, MCP roles and the audit log work as on `/mcp`. The endpoint behaves the same on Cloud and on a self-hosted instance, and needs a signed-in user (OAuth or a per-user MCP API key), not the static `MCP_API_KEY` / `MCP_BEARER_TOKEN`. + +--- + ## Authentication Options | Method | Configuration | diff --git a/packages/backend/src/mcp-server/chatgpt-app-toolset.spec.ts b/packages/backend/src/mcp-server/chatgpt-app-toolset.spec.ts new file mode 100644 index 00000000..07088a14 --- /dev/null +++ b/packages/backend/src/mcp-server/chatgpt-app-toolset.spec.ts @@ -0,0 +1,308 @@ +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server'; +import { RegisteredTool } from './tool-registry'; +import { + CHATGPT_APP_INSTRUCTIONS, + CHATGPT_APP_TOOL_NAMES, + ChatgptAppDeps, + databaseEngine, + registerChatgptAppToolset, +} from './chatgpt-app-toolset'; + +function tool(p: Partial & { name: string; connectorId: string }): RegisteredTool { + return { + id: `${p.connectorId}:${p.name}`, + organizationId: 'org-A', + description: `${p.name} description`, + parameters: {}, + connectorType: 'REST', + connectorConfig: { baseUrl: 'https://api.example.com', authType: 'NONE' }, + endpointMapping: { method: 'GET', path: '/x' }, + ...p, + } as RegisteredTool; +} + +/** The three tools a DATABASE connector is generated with. */ +function database( + connectorId: string, + opts: { baseUrl?: string; readOnly?: boolean; prefix?: string } = {}, +): RegisteredTool[] { + const prefix = opts.prefix ? `${opts.prefix}_` : ''; + const connectorConfig = { + baseUrl: opts.baseUrl ?? 'postgresql://reader:s3cret@db.internal:5432/sales', + authType: 'NONE', + config: opts.readOnly === false ? { readOnly: false } : {}, + }; + const base = { connectorId, connectorType: 'DATABASE', connectorConfig }; + return [ + tool({ ...base, name: `${prefix}get_database_schema`, endpointMapping: { method: 'query', path: 'SELECT … FROM information_schema.columns' } }), + tool({ ...base, name: `${prefix}get_example_queries`, endpointMapping: { method: 'static', path: '' } }), + tool({ + ...base, + name: `${prefix}execute_query`, + endpointMapping: { method: 'query', path: '${query}' }, + parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] }, + }), + ]; +} + +const SALES_DB = database('c-sales', { prefix: 'sales' }); +const WRITABLE_DB = database('c-erp', { prefix: 'erp', readOnly: false, baseUrl: 'mssql://sa:pw@erp:1433/erp' }); +const CRM_READ = tool({ name: 'crm_find_customer', connectorId: 'c-crm' }); +const CRM_PENDING = tool({ name: 'shop_list_orders', connectorId: 'c-shop', setupStatus: 'needs_input' }); +const WISE_PAY = tool({ + name: 'wise_create_transfer', + connectorId: 'c-wise', + endpointMapping: { method: 'POST', path: '/transfers' }, + connectorConfig: { baseUrl: 'https://api.wise.com', authType: 'BEARER_TOKEN', config: { adapterSlug: 'wise' } }, +}); + +const NAMES: Record = { + 'c-sales': 'Sales DB', + 'c-erp': 'ERP', + 'c-crm': 'Acme CRM', + 'c-shop': 'Shop', + 'c-wise': 'Wise', +}; + +function makeDeps(overrides: Partial = {}) { + return { + execute: jest.fn(async (t: RegisteredTool, args: Record) => ({ + content: [{ type: 'text' as const, text: JSON.stringify({ ran: t.id, args }) }], + })), + connectors: jest.fn(async (ids: string[]) => + ids.map((id) => ({ id, name: NAMES[id] ?? id, hasGuide: id === 'c-sales' })), + ), + guide: jest.fn(async () => '## Sales DB\nAmounts are in cents.'), + connectorUrl: (id: string) => `https://cloud.example.com/connectors/${id}`, + configuration: jest.fn(async () => ({ + dashboardUrl: 'https://cloud.example.com/connectors', + servers: [{ name: 'Default', url: 'https://cloud.example.com/mcp/srv-1' }], + })), + activity: jest.fn(async () => [ + { + connectorId: 'c-sales', + calls: 12, + errors: 2, + lastCallAt: new Date('2026-10-23T10:00:00Z'), + lastErrorAt: new Date('2026-10-22T09:00:00Z'), + }, + ]), + ...overrides, + }; +} + +async function connect(scope: RegisteredTool[], deps = makeDeps()) { + const server = new McpServer( + { name: 'AnythingMCP', version: 'test' }, + { instructions: CHATGPT_APP_INSTRUCTIONS }, + ); + registerChatgptAppToolset(server, scope, deps); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await server.connect(serverSide); + const client = new Client({ name: 'spec', version: '1.0.0' }); + await client.connect(clientSide); + return { client, deps }; +} + +async function call(client: Client, name: string, args: Record = {}) { + const result: any = await client.callTool({ name, arguments: args }); + const text = result.content?.[0]?.text ?? ''; + let body: any = text; + try { + body = JSON.parse(text); + } catch { + /* plain text */ + } + return { isError: !!result.isError, body, text }; +} + +describe('/mcp/chatgpt-app tool set', () => { + it('lists the same fixed tools and instructions whatever the caller can reach', async () => { + const a = await connect([...SALES_DB, ...WRITABLE_DB, CRM_READ]); + const b = await connect([]); + const listA = (await a.client.listTools()).tools; + const listB = (await b.client.listTools()).tools; + expect(listA.map((t) => t.name).sort()).toEqual([...CHATGPT_APP_TOOL_NAMES].sort()); + expect(JSON.stringify(listA)).toEqual(JSON.stringify(listB)); + expect(a.client.getInstructions()).toBe(CHATGPT_APP_INSTRUCTIONS); + }); + + it('has no generic executor and sets every hint explicitly, all read-only', async () => { + const { client } = await connect([]); + const tools = (await client.listTools()).tools; + for (const t of tools) { + expect(t.name).not.toMatch(/search_tools|describe_tool|run_|kg_how_to_obtain/); + expect(t.annotations?.readOnlyHint).toBe(true); + expect(t.annotations?.destructiveHint).toBe(false); + expect(t.annotations?.openWorldHint).toBe(false); + expect(typeof t.annotations?.idempotentHint).toBe('boolean'); + expect(t.annotations?.title).toBeTruthy(); + // No tool takes a tool name or free-form arguments to dispatch. + const props = Object.keys((t.inputSchema as any).properties ?? {}); + expect(props).not.toContain('tool'); + expect(props).not.toContain('arguments'); + } + }); + + it('lists only read-only databases as queryable, without connection URLs', async () => { + const { client } = await connect([...SALES_DB, ...WRITABLE_DB, CRM_READ]); + const { body, text } = await call(client, 'anythingmcp_list_databases'); + expect(body.databases).toEqual([{ id: 'c-sales', name: 'Sales DB', engine: 'PostgreSQL' }]); + expect(body.notQueryable).toEqual([ + expect.objectContaining({ id: 'c-erp', engine: 'SQL Server', reason: expect.stringMatching(/write access/) }), + ]); + expect(text).not.toMatch(/s3cret|reader:|postgresql:\/\/|mssql:\/\//); + }); + + it('runs a query through the database\'s own query tool, by id or name', async () => { + const { client, deps } = await connect([...SALES_DB, CRM_READ]); + const byName = await call(client, 'anythingmcp_query_database', { + database: 'sales db', + query: 'SELECT count(*) FROM orders', + }); + expect(byName.isError).toBe(false); + expect(deps.execute).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'c-sales:sales_execute_query' }), + { query: 'SELECT count(*) FROM orders' }, + ); + await call(client, 'anythingmcp_query_database', { database: 'c-sales', query: 'SELECT 1' }); + expect(deps.execute).toHaveBeenCalledTimes(2); + }); + + it('serves the schema and the dialect examples from the generated tools', async () => { + const { client, deps } = await connect([...SALES_DB]); + await call(client, 'anythingmcp_get_database_schema', { database: 'Sales DB' }); + expect(deps.execute).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'c-sales:sales_get_database_schema' }), + {}, + ); + await call(client, 'anythingmcp_get_query_examples', { database: 'Sales DB' }); + expect(deps.execute).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'c-sales:sales_get_example_queries' }), + {}, + ); + }); + + it('refuses a database connected with write access and never runs its query tool', async () => { + const { client, deps } = await connect([...WRITABLE_DB]); + const { isError, body } = await call(client, 'anythingmcp_query_database', { + database: 'ERP', + query: 'SELECT 1', + }); + expect(isError).toBe(true); + expect(body.error).toMatch(/write access/); + expect(deps.execute).not.toHaveBeenCalled(); + }); + + it('never runs a non-database tool, even when its name is passed as the database', async () => { + const { client, deps } = await connect([CRM_READ]); + const { isError } = await call(client, 'anythingmcp_query_database', { + database: 'Acme CRM', + query: 'SELECT 1', + }); + expect(isError).toBe(true); + expect(deps.execute).not.toHaveBeenCalled(); + }); + + it('respects a role that hides the query tool but keeps the schema', async () => { + const schemaOnly = SALES_DB.filter((t) => !t.name.endsWith('execute_query')); + const { client, deps } = await connect(schemaOnly); + const list = await call(client, 'anythingmcp_list_databases'); + expect(list.body.databases).toEqual([]); + expect(list.body.notQueryable[0].reason).toMatch(/may not run queries/); + const q = await call(client, 'anythingmcp_query_database', { database: 'Sales DB', query: 'SELECT 1' }); + expect(q.isError).toBe(true); + expect(deps.execute).not.toHaveBeenCalled(); + await call(client, 'anythingmcp_get_database_schema', { database: 'Sales DB' }); + expect(deps.execute).toHaveBeenCalledTimes(1); + }); + + it('asks for an id when two databases match a name', async () => { + const other = database('c-sales2', { prefix: 'sales2' }); + const deps = makeDeps({ + connectors: jest.fn(async (ids: string[]) => + ids.map((id) => ({ id, name: id === 'c-sales' ? 'Sales DB EU' : 'Sales DB US', hasGuide: false })), + ), + }); + const { client } = await connect([...SALES_DB, ...other], deps); + const { isError, body } = await call(client, 'anythingmcp_query_database', { database: 'sales', query: 'SELECT 1' }); + expect(isError).toBe(true); + expect(body.databases.map((d: any) => d.id).sort()).toEqual(['c-sales', 'c-sales2']); + expect(deps.execute).not.toHaveBeenCalled(); + }); + + it('lists connectors with their type and setup links, leaving out payment connectors', async () => { + const { client } = await connect([...SALES_DB, CRM_READ, CRM_PENDING, WISE_PAY]); + const { body, text } = await call(client, 'anythingmcp_list_connectors'); + expect(body.connectors).toEqual([ + { id: 'c-crm', name: 'Acme CRM', type: 'rest', hasNotes: false }, + { id: 'c-sales', name: 'Sales DB', type: 'database', queryableHere: true, hasNotes: true }, + ]); + expect(body.needsSetup).toEqual([ + expect.objectContaining({ id: 'c-shop', finishSetupUrl: 'https://cloud.example.com/connectors/c-shop' }), + ]); + expect(text).not.toMatch(/Wise/); + }); + + it('reports activity per connector in scope, counts only', async () => { + const { client, deps } = await connect([...SALES_DB, CRM_READ]); + const { body } = await call(client, 'anythingmcp_get_connector_activity', { days: 30 }); + expect(deps.activity).toHaveBeenCalledWith(expect.arrayContaining(['c-sales', 'c-crm']), 30); + expect(body.days).toBe(30); + expect(body.connectors).toEqual([ + { + id: 'c-sales', + name: 'Sales DB', + calls: 12, + failedCalls: 2, + lastCallAt: '2026-10-23T10:00:00.000Z', + lastFailureAt: '2026-10-22T09:00:00.000Z', + }, + { id: 'c-crm', name: 'Acme CRM', calls: 0, failedCalls: 0, lastCallAt: null, lastFailureAt: null }, + ]); + }); + + it('defaults activity to seven days and skips the lookup with no connectors', async () => { + const withScope = await connect([CRM_READ]); + await call(withScope.client, 'anythingmcp_get_connector_activity'); + expect(withScope.deps.activity).toHaveBeenCalledWith(['c-crm'], 7); + const empty = await connect([]); + const { body } = await call(empty.client, 'anythingmcp_get_connector_activity'); + expect(empty.deps.activity).not.toHaveBeenCalled(); + expect(body.connectors).toEqual([]); + }); + + it('returns the workspace notes and the dashboard link without server URLs', async () => { + const { client, deps } = await connect([...SALES_DB]); + const guide = await call(client, 'anythingmcp_get_workspace_guide', { connector: 'Sales DB' }); + expect(guide.text).toContain('Amounts are in cents.'); + expect(deps.guide).toHaveBeenCalledWith(['c-sales'], false); + const cfg = await call(client, 'anythingmcp_get_configuration_url'); + expect(cfg.body.dashboardUrl).toBe('https://cloud.example.com/connectors'); + expect(cfg.text).not.toMatch(/\/mcp\//); + }); + + it('tells the user where to start when nothing is connected', async () => { + const { client } = await connect([]); + const connectors = await call(client, 'anythingmcp_list_connectors'); + expect(connectors.body.hint).toMatch(/anythingmcp_get_configuration_url/); + const databases = await call(client, 'anythingmcp_list_databases'); + expect(databases.body.hint).toMatch(/dashboard/); + }); +}); + +describe('databaseEngine', () => { + it.each([ + ['postgresql://u:p@h/db', 'PostgreSQL'], + ['mysql://u:p@h/db', 'MySQL'], + ['mariadb://u:p@h/db', 'MariaDB'], + ['sqlserver://h;database=x', 'SQL Server'], + ['oracle://u:p@h/x', 'Oracle'], + ['hana://h:30015', 'SAP HANA'], + ['sqlite:/data/app.db', 'SQLite'], + ['mongodb+srv://u:p@cluster/x', 'MongoDB'], + ])('%s → %s', (url, engine) => { + expect(databaseEngine(url)).toBe(engine); + }); +}); diff --git a/packages/backend/src/mcp-server/chatgpt-app-toolset.ts b/packages/backend/src/mcp-server/chatgpt-app-toolset.ts new file mode 100644 index 00000000..725390a3 --- /dev/null +++ b/packages/backend/src/mcp-server/chatgpt-app-toolset.ts @@ -0,0 +1,516 @@ +import { z } from 'zod'; +import { McpServer } from '@modelcontextprotocol/server'; +import { RegisteredTool, isListable } from './tool-registry'; +import type { ToolResultContent } from './dynamic-mcp-tools'; +import { + ConnectorSummary, + SharedToolsetDeps, + excludedOnSharedEndpoint, +} from './shared-toolset'; + +/** + * The tool set of `/mcp/chatgpt-app`, the endpoint behind the AnythingMCP + * plugin in OpenAI's plugin directory. + * + * OpenAI's plugin guidelines require every model-callable operation to be its + * own tool, and forbid "discovery, operation selection, or schema fetching + * with a generic executor". The meta-tools of the shared `/mcp` (search a + * workspace's tools, describe one, run it) are exactly that, so this endpoint + * serves a different, fixed set instead: each tool below is one operation, the + * same for every user, and none of them runs a workspace tool chosen by name. + * + * What it does: + * - read-only SQL (or MongoDB find) queries against the databases the + * workspace connected, with the schema and the dialect's examples; + * - the workspace's connectors and their setup state, recent activity + * (calls and failures per connector), its notes, and the dashboard link. + * + * A database is only offered when its connector is read-only: the query tool + * is annotated read-only, so a connector that allows writes is listed as not + * available here instead of being queried. The database engine enforces the + * read-only rule as well (SELECT / WITH only, no stacked statements). + * + * Every call goes through `deps.execute`, i.e. the same path, scope, licence + * check and audit row as any other call: nothing here widens what the caller + * may reach. `scopeTools` is already narrowed by the controller to the + * connection grant and the caller's MCP roles. + * + * Kept free of Nest, like shared-toolset.ts, so every rule is testable with + * plain objects. `/mcp` and `/mcp/` are unaffected. + */ + +export const CHATGPT_APP_TOOL_NAMES = [ + 'anythingmcp_list_connectors', + 'anythingmcp_list_databases', + 'anythingmcp_get_database_schema', + 'anythingmcp_get_query_examples', + 'anythingmcp_query_database', + 'anythingmcp_get_connector_activity', + 'anythingmcp_get_workspace_guide', + 'anythingmcp_get_configuration_url', +] as const; + +// Only rules that span tools, the important part first (OpenAI's guidance for +// server instructions). +export const CHATGPT_APP_INSTRUCTIONS = [ + "Rules for working with the user's AnythingMCP workspace:", + '- Before the first query on a database, call anythingmcp_get_database_schema and use only the table and column names it returns.', + '- Queries are read-only and return at most 1000 rows: filter and aggregate in the query instead of reading whole tables.', + '- Never ask for passwords, connection strings or API keys in the chat. The user adds and changes databases in the dashboard (anythingmcp_get_configuration_url).', +].join('\n'); + +export interface ConnectorActivity { + connectorId: string; + calls: number; + errors: number; + lastCallAt: Date | null; + lastErrorAt: Date | null; +} + +export type ChatgptAppDeps = Pick< + SharedToolsetDeps, + 'execute' | 'connectors' | 'guide' | 'connectorUrl' | 'configuration' +> & { + /** Calls and failures per connector over the last `days` days. */ + activity(connectorIds: string[], days: number): Promise; +}; + +type TextResult = { + content: ToolResultContent[number][]; + isError?: boolean; +}; + +const GUIDE_MAX_CHARS = 100_000; +const ACTIVITY_DEFAULT_DAYS = 7; +const ACTIVITY_MAX_DAYS = 90; + +function json(value: unknown, isError = false): TextResult { + return { + content: [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }], + ...(isError ? { isError: true } : {}), + }; +} + +/** True when `name` is the generated tool `base`, with or without a prefix. */ +function isGenerated(tool: RegisteredTool, base: string): boolean { + return tool.name === base || tool.name.endsWith(`_${base}`); +} + +function isQueryTool(tool: RegisteredTool): boolean { + return ( + isGenerated(tool, 'execute_query') && + tool.endpointMapping?.method === 'query' && + tool.endpointMapping?.path === '${query}' + ); +} + +function isSchemaTool(tool: RegisteredTool): boolean { + return ( + isGenerated(tool, 'get_database_schema') && + (tool.endpointMapping?.method === 'query' || tool.endpointMapping?.method === 'mongo_schema') + ); +} + +function isExamplesTool(tool: RegisteredTool): boolean { + return isGenerated(tool, 'get_example_queries') && tool.endpointMapping?.method === 'static'; +} + +/** + * The database engine from the connection URL's scheme. Only the scheme is + * read: the URL itself can carry credentials and never leaves the server. + */ +export function databaseEngine(baseUrl: string | undefined): string { + const url = (baseUrl ?? '').trim().toLowerCase(); + if (url.startsWith('mongodb://') || url.startsWith('mongodb+srv://')) return 'MongoDB'; + if (url.startsWith('hana://') || url.startsWith('saphana://')) return 'SAP HANA'; + if (url.startsWith('mssql://') || url.startsWith('sqlserver://')) return 'SQL Server'; + if (url.startsWith('mysql://')) return 'MySQL'; + if (url.startsWith('mariadb://')) return 'MariaDB'; + if (url.startsWith('oracle://') || url.startsWith('oracledb://')) return 'Oracle'; + if (url.startsWith('sqlite:')) return 'SQLite'; + return 'PostgreSQL'; +} + +function isReadOnlyConnector(tool: RegisteredTool): boolean { + return (tool.connectorConfig?.config as { readOnly?: unknown } | undefined)?.readOnly !== false; +} + +interface Database { + connectorId: string; + engine: string; + readOnly: boolean; + query?: RegisteredTool; + schema?: RegisteredTool; + examples?: RegisteredTool; +} + +const databaseInput = { + database: z + .string() + .min(1) + .describe('Database id or name, as returned by anythingmcp_list_databases.'), +}; + +/** + * Registers the fixed tool set on a per-request server. Every caller gets the + * same names, descriptions, schemas and annotations; only the answers depend + * on the caller's scope. + */ +export function registerChatgptAppToolset( + mcpServer: McpServer, + scopeTools: RegisteredTool[], + deps: ChatgptAppDeps, +): void { + const served = scopeTools.filter((t) => !excludedOnSharedEndpoint(t)); + const ready = served.filter(isListable); + const pending = served.filter((t) => !isListable(t)); + const connectorIds = [...new Set(ready.map((t) => t.connectorId))]; + + // Database connectors in scope, by connector. A role can hide single tools, + // so a database may have a schema tool but no query tool, or the reverse. + const databases = new Map(); + for (const t of ready) { + if (t.connectorType !== 'DATABASE') continue; + const db = databases.get(t.connectorId) ?? { + connectorId: t.connectorId, + engine: databaseEngine(t.connectorConfig?.baseUrl), + readOnly: isReadOnlyConnector(t), + }; + if (isQueryTool(t)) db.query ??= t; + else if (isSchemaTool(t)) db.schema ??= t; + else if (isExamplesTool(t)) db.examples ??= t; + databases.set(t.connectorId, db); + } + + let summaries: Promise> | null = null; + const connectorsById = () => { + summaries ??= deps + .connectors([...new Set(scopeTools.map((t) => t.connectorId))]) + .then((list) => new Map(list.map((c) => [c.id, c]))); + return summaries; + }; + const nameOf = (byId: Map, id: string) => byId.get(id)?.name ?? id; + + /** Connector ids matching an id or a name; an exact match wins over a partial one. */ + const matchIn = async (ids: string[], value: string): Promise => { + const byId = await connectorsById(); + const raw = value.trim(); + const wanted = raw.toLowerCase(); + const exact = ids.filter((id) => id === raw || nameOf(byId, id).toLowerCase() === wanted); + if (exact.length > 0 || !wanted) return exact; + return ids.filter((id) => nameOf(byId, id).toLowerCase().includes(wanted)); + }; + + /** The one queryable database a value names, or an error result. */ + const resolveDatabase = async ( + value: string, + need: 'query' | 'schema' | 'examples', + ): Promise<{ db: Database; name: string } | { error: TextResult }> => { + const byId = await connectorsById(); + const matches = await matchIn([...databases.keys()], value); + if (matches.length === 0) { + return { + error: json( + { + error: `No database '${value}' on this connection. Call anythingmcp_list_databases for the available ones.`, + }, + true, + ), + }; + } + if (matches.length > 1) { + return { + error: json( + { + error: `More than one database matches '${value}'. Pass one of these ids.`, + databases: matches.map((id) => ({ id, name: nameOf(byId, id) })), + }, + true, + ), + }; + } + const db = databases.get(matches[0])!; + const name = nameOf(byId, db.connectorId); + if (!db.readOnly) { + return { + error: json( + { + error: `'${name}' is connected with write access, so it is not queried from here. A workspace admin can switch the connector to read-only in the dashboard.`, + dashboardUrl: deps.connectorUrl(db.connectorId), + }, + true, + ), + }; + } + if (!db[need]) { + return { + error: json( + { error: `This connection may not ${need === 'query' ? 'run queries on' : 'read the structure of'} '${name}'. A workspace admin decides this with the MCP roles.` }, + true, + ), + }; + } + return { db, name }; + }; + + mcpServer.registerTool( + 'anythingmcp_list_connectors', + { + description: + "List the connectors of the user's AnythingMCP workspace that this connection can reach (databases, APIs and business applications), with their type and whether their setup is complete. Connectors whose setup is unfinished come with the link where the user completes it.", + inputSchema: {}, + annotations: { + title: 'List connectors', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async () => { + const byId = await connectorsById(); + const typeOf = (id: string) => + (ready.find((t) => t.connectorId === id) ?? pending.find((t) => t.connectorId === id)) + ?.connectorType ?? 'REST'; + const connectors = connectorIds + .map((id) => ({ + id, + name: nameOf(byId, id), + type: typeOf(id).toLowerCase(), + ...(databases.has(id) ? { queryableHere: databases.get(id)!.readOnly } : {}), + hasNotes: byId.get(id)?.hasGuide ?? false, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + const needsSetup = [...new Map(pending.map((t) => [t.connectorId, t])).values()] + .map((t) => ({ + id: t.connectorId, + name: nameOf(byId, t.connectorId), + whatIsMissing: + t.setupStatus === 'needs_authorization' + ? 'it has to be authorized with the provider' + : 'a credential or setting is still empty', + finishSetupUrl: deps.connectorUrl(t.connectorId), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + return json({ + connectors, + ...(needsSetup.length ? { needsSetup } : {}), + ...(connectors.length === 0 && needsSetup.length === 0 + ? { hint: 'This connection reaches no connectors yet. The user adds them in the dashboard: call anythingmcp_get_configuration_url.' } + : {}), + }); + }, + ); + + mcpServer.registerTool( + 'anythingmcp_list_databases', + { + description: + 'List the databases of the workspace that can be queried from here: their id, name and engine (PostgreSQL, MySQL, SQL Server, Oracle, SAP HANA, SQLite or MongoDB). Only read-only connections are queryable.', + inputSchema: {}, + annotations: { + title: 'List databases', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async () => { + const byId = await connectorsById(); + const all = [...databases.values()].sort((a, b) => + nameOf(byId, a.connectorId).localeCompare(nameOf(byId, b.connectorId)), + ); + const queryable = all + .filter((d) => d.readOnly && d.query) + .map((d) => ({ id: d.connectorId, name: nameOf(byId, d.connectorId), engine: d.engine })); + const notQueryable = all + .filter((d) => !d.readOnly || !d.query) + .map((d) => ({ + id: d.connectorId, + name: nameOf(byId, d.connectorId), + engine: d.engine, + reason: !d.readOnly + ? 'connected with write access; only read-only connections are queried from here' + : 'this connection may not run queries on it', + })); + return json({ + databases: queryable, + ...(notQueryable.length ? { notQueryable } : {}), + ...(all.length === 0 + ? { hint: 'No database is connected yet. The user adds one in the dashboard: call anythingmcp_get_configuration_url.' } + : {}), + }); + }, + ); + + mcpServer.registerTool( + 'anythingmcp_get_database_schema', + { + description: + 'The structure of one database: its tables and columns with data types, nullability and primary keys (for MongoDB: its collections and the fields seen in sample documents). Call it before writing a query.', + inputSchema: databaseInput, + annotations: { + title: 'Get database schema', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args: { database: string }) => { + const found = await resolveDatabase(args.database, 'schema'); + if ('error' in found) return found.error; + return deps.execute(found.db.schema!, {}); + }, + ); + + mcpServer.registerTool( + 'anythingmcp_get_query_examples', + { + description: + "Example queries in the database's own dialect (how to limit rows, quote names, filter dates), so a query works on the first try. Runs nothing against the database.", + inputSchema: databaseInput, + annotations: { + title: 'Get query examples', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args: { database: string }) => { + const found = await resolveDatabase(args.database, 'examples'); + if ('error' in found) return found.error; + return deps.execute(found.db.examples!, {}); + }, + ); + + mcpServer.registerTool( + 'anythingmcp_query_database', + { + description: + 'Run one read-only query on a database and return the rows (at most 1000). SQL databases take a single SELECT (or WITH … SELECT) statement; MongoDB takes a JSON find query such as {"collection":"orders","filter":{"status":"open"},"limit":20}. Statements that change data are refused.', + inputSchema: { + ...databaseInput, + query: z + .string() + .min(1) + .describe('The SELECT statement, or for MongoDB the JSON find query.'), + }, + annotations: { + title: 'Query database', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args: { database: string; query: string }) => { + const found = await resolveDatabase(args.database, 'query'); + if ('error' in found) return found.error; + return deps.execute(found.db.query!, { query: args.query }); + }, + ); + + mcpServer.registerTool( + 'anythingmcp_get_connector_activity', + { + description: + "How the workspace's connectors have been used recently: calls, failed calls and the time of the last call and of the last failure, per connector. Counts only, no request contents.", + inputSchema: { + days: z + .number() + .int() + .min(1) + .max(ACTIVITY_MAX_DAYS) + .optional() + .describe(`Period in days, 1 to ${ACTIVITY_MAX_DAYS}. Default ${ACTIVITY_DEFAULT_DAYS}.`), + }, + annotations: { + title: 'Connector activity', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args: { days?: number }) => { + const days = args.days ?? ACTIVITY_DEFAULT_DAYS; + const ids = [...new Set(served.map((t) => t.connectorId))]; + const [byId, rows] = await Promise.all([ + connectorsById(), + ids.length ? deps.activity(ids, days) : Promise.resolve([]), + ]); + const byConnector = new Map(rows.map((r) => [r.connectorId, r])); + const connectors = ids + .map((id) => { + const r = byConnector.get(id); + return { + id, + name: nameOf(byId, id), + calls: r?.calls ?? 0, + failedCalls: r?.errors ?? 0, + lastCallAt: r?.lastCallAt ? r.lastCallAt.toISOString() : null, + lastFailureAt: r?.lastErrorAt ? r.lastErrorAt.toISOString() : null, + }; + }) + .sort((a, b) => b.calls - a.calls || a.name.localeCompare(b.name)); + return json({ days, connectors }); + }, + ); + + mcpServer.registerTool( + 'anythingmcp_get_workspace_guide', + { + description: + "The workspace's own notes on its connectors: what the tables and fields mean, naming conventions and the approved ways to answer common questions. Read it before the first query on a connector.", + inputSchema: { + connector: z.string().optional().describe('Only this connector (id or name).'), + }, + annotations: { + title: 'Workspace guide', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args: { connector?: string }) => { + const ids = args.connector ? await matchIn(connectorIds, args.connector) : connectorIds; + if (args.connector && ids.length === 0) { + return json({ error: `No connector '${args.connector}' on this connection.` }, true); + } + const guide = ids.length ? await deps.guide(ids, !args.connector) : undefined; + if (!guide) return json({ guide: null, note: 'The workspace has no notes for these connectors.' }); + const text = + guide.length > GUIDE_MAX_CHARS + ? `${guide.slice(0, GUIDE_MAX_CHARS)}\n\n[truncated: pass "connector" for one connector's notes]` + : guide; + return { content: [{ type: 'text' as const, text }] }; + }, + ); + + mcpServer.registerTool( + 'anythingmcp_get_configuration_url', + { + description: + 'Link to the AnythingMCP dashboard, where the user adds a database or another connector, enters its credentials, switches it to read-only and decides who may use it.', + inputSchema: {}, + annotations: { + title: 'Configuration link', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async () => { + const cfg = await deps.configuration(); + return json({ + dashboardUrl: cfg.dashboardUrl, + note: 'Credentials are entered only in the dashboard, never in the chat. Changes there are available here right away.', + }); + }, + ); +} diff --git a/packages/backend/src/mcp-server/chatgpt-app.controller.spec.ts b/packages/backend/src/mcp-server/chatgpt-app.controller.spec.ts new file mode 100644 index 00000000..9df2f7fa --- /dev/null +++ b/packages/backend/src/mcp-server/chatgpt-app.controller.spec.ts @@ -0,0 +1,173 @@ +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server'; +import { McpEndpointController } from './mcp-endpoint.controller'; +import { CHATGPT_APP_INSTRUCTIONS, CHATGPT_APP_TOOL_NAMES } from './chatgpt-app-toolset'; +import type { RegisteredTool } from './tool-registry'; + +/** + * `/mcp/chatgpt-app` end to end through the controller: the same scope as the + * shared `/mcp` (grant, then role), the fixed plugin tool set whatever the + * deployment mode or OAuth client, and every query run by the executor pinned + * to the one connector that owns it. + */ +function dbTool( + id: string, + name: string, + organizationId: string, + connectorId: string, +): RegisteredTool { + return { + id, + connectorId, + organizationId, + name, + description: name, + parameters: {}, + connectorType: 'DATABASE', + connectorConfig: { baseUrl: 'postgresql://u:p@db/x', authType: 'NONE', config: {} }, + endpointMapping: + name.endsWith('execute_query') + ? { method: 'query', path: '${query}' } + : { method: 'query', path: 'SELECT 1' }, + }; +} + +// The same generated tool names in two workspaces. +const ALL = [ + dbTool('t-a1', 'execute_query', 'org-A', 'db-A'), + dbTool('t-a2', 'get_database_schema', 'org-A', 'db-A'), + dbTool('t-b1', 'execute_query', 'org-B', 'db-B'), + dbTool('t-b2', 'get_database_schema', 'org-B', 'db-B'), +]; + +function build(opts: { allowedByOrg?: Record } = {}) { + const executor = { + executeTool: jest.fn(async () => ({ + content: [{ type: 'text' as const, text: '{"rows":[]}' }], + structured: { rows: [] }, + })), + }; + const servers = { + getConnectorIds: jest.fn(async () => []), + getConnectorSummaries: jest.fn(async (ids: string[]) => + ids.map((id) => ({ id, name: `Database ${id}`, hasGuide: false })), + ), + getSharedGuide: jest.fn(async () => undefined), + getServerNames: jest.fn(async () => []), + getServerNamesByOrg: jest.fn(async () => []), + activityByConnector: jest.fn(async () => []), + }; + const setup = { get: jest.fn(() => ({ canSetUp: jest.fn(async () => true) })) }; + const controller = new McpEndpointController( + servers as any, + { getAllTools: () => ALL, countByName: () => 2 } as any, + executor as any, + { + getAllowedToolIds: jest.fn(async (_sub: string, org: string) => + opts.allowedByOrg ? (opts.allowedByOrg[org] ?? null) : null, + ), + } as any, + { isEnabled: jest.fn(async () => false) } as any, + {} as any, + { + resolve: jest.fn().mockResolvedValue(null), + clientRedirectUris: jest.fn(async () => ['https://claude.ai/api/mcp/auth_callback']), + } as any, + { create: jest.fn(), hasVisible: jest.fn().mockResolvedValue(false) } as any, + setup as any, + ); + + const holder: { built?: McpServer } = {}; + (controller as any).serveStateless = jest.fn( + async (_req: unknown, _res: unknown, _body: unknown, factory: () => McpServer) => { + holder.built = factory(); + }, + ); + const connect = async (user: Record) => { + holder.built = undefined; + const req: any = { user, body: { method: 'initialize' }, headers: { host: 'x' } }; + const res: any = { status: jest.fn(() => res), json: jest.fn(() => res) }; + await controller.handleChatgptAppPost(req, res); + const built = holder.built as McpServer | undefined; + if (!built) return { client: undefined, res }; + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await built.connect(serverSide); + const client = new Client({ name: 'spec', version: '1.0.0' }); + await client.connect(clientSide); + return { client, res }; + }; + return { executor, servers, setup, connect }; +} + +const orgA = { sub: 'u-a', organizationId: 'org-A', authMethod: 'jwt', email: 'a@x.io' }; + +describe('/mcp/chatgpt-app', () => { + const saved = process.env.MCP_SHARED_ENDPOINT_TOOLS; + afterEach(() => { + if (saved === undefined) delete process.env.MCP_SHARED_ENDPOINT_TOOLS; + else process.env.MCP_SHARED_ENDPOINT_TOOLS = saved; + }); + + it.each(['fixed', 'direct'])( + 'serves the plugin tool set in %s mode, for any OAuth client', + async (mode) => { + process.env.MCP_SHARED_ENDPOINT_TOOLS = mode; + const { connect } = build(); + const { client } = await connect(orgA); + const names = (await client!.listTools()).tools.map((t) => t.name).sort(); + expect(names).toEqual([...CHATGPT_APP_TOOL_NAMES].sort()); + expect(client!.getInstructions()).toBe(CHATGPT_APP_INSTRUCTIONS); + }, + ); + + it('queries only the caller\'s own database, in its own connector', async () => { + const { connect, executor } = build(); + const { client } = await connect(orgA); + const list: any = await client!.callTool({ name: 'anythingmcp_list_databases', arguments: {} }); + expect(JSON.parse(list.content[0].text).databases).toEqual([ + { id: 'db-A', name: 'Database db-A', engine: 'PostgreSQL' }, + ]); + + await client!.callTool({ + name: 'anythingmcp_query_database', + arguments: { database: 'db-A', query: 'SELECT 1' }, + }); + expect(executor.executeTool).toHaveBeenCalledWith( + 'execute_query', + { query: 'SELECT 1' }, + expect.objectContaining({ organizationId: 'org-A', connectorIds: ['db-A'] }), + ); + + const other: any = await client!.callTool({ + name: 'anythingmcp_query_database', + arguments: { database: 'db-B', query: 'SELECT 1' }, + }); + expect(other.isError).toBe(true); + expect(executor.executeTool).toHaveBeenCalledTimes(1); + }); + + it('applies the caller\'s MCP role', async () => { + const { connect, executor } = build({ allowedByOrg: { 'org-A': ['t-a2'] } }); + const { client } = await connect(orgA); + const q: any = await client!.callTool({ + name: 'anythingmcp_query_database', + arguments: { database: 'db-A', query: 'SELECT 1' }, + }); + expect(q.isError).toBe(true); + expect(executor.executeTool).not.toHaveBeenCalled(); + }); + + it('does not resolve the setup permission, which it has no tools for', async () => { + const { connect, setup } = build(); + await connect(orgA); + const provider = setup.get.mock.results[0]?.value; + expect(provider?.canSetUp).not.toHaveBeenCalled(); + }); + + it('refuses operator credentials that name no user', async () => { + const { connect } = build(); + const { client, res } = await connect({ authMethod: 'static_api_key' }); + expect(client).toBeUndefined(); + expect(res.status).toHaveBeenCalledWith(403); + }); +}); diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts index e7e6ba15..ccac9e89 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts @@ -45,6 +45,10 @@ import { sharedEndpointMode, sharedToolsetInstructions, } from './shared-toolset'; +import { + CHATGPT_APP_INSTRUCTIONS, + registerChatgptAppToolset, +} from './chatgpt-app-toolset'; /** * The OAuth client an access token was issued to. @@ -338,13 +342,61 @@ export class McpEndpointController { }); } + // ── /mcp/chatgpt-app: the endpoint of the OpenAI directory plugin ────────── + // + // A fixed set of single-operation tools (see chatgpt-app-toolset.ts), the + // same for every caller and every deployment mode: OpenAI's guidelines + // forbid the shared endpoint's search/describe/run meta-tools. Same auth, + // grant and role scope as /mcp. Declared before the ':serverId' routes so + // the static segment wins route matching. + + @Post('chatgpt-app') + async handleChatgptAppPost(@Req() req: Request, @Res() res: Response) { + if (!(req as any).user?.sub) { + // Operator credentials (a static key) name no user and no workspace. + res.status(403).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'This endpoint needs a signed-in AnythingMCP user.' }, + id: null, + }); + return; + } + if (isListenRequest((req as any).body)) { + await this.serveStateless( + req, + res, + (req as any).body, + () => listenOnlyServer('AnythingMCP', APP_VERSION, { tools: { listChanged: true } }), + 'chatgpt-app listen', + ); + return; + } + await this.attachVisibleTools(req); + await this.serveSharedToolset(req, res, 'chatgpt-app'); + } + + @Get('chatgpt-app') + handleChatgptAppGet(@Res() res: Response) { + this.statelessOnly(res); + } + + @Delete('chatgpt-app') + handleChatgptAppDelete(@Res() res: Response) { + this.statelessOnly(res); + } + /** - * Serves the shared endpoint's fixed tool set, statelessly, over exactly the - * tools `attachVisibleTools` resolved for this caller. Every run is executed - * in the one connector that owns the tool, so a same-named tool elsewhere - * can never be reached. + * Serves a fixed tool set, statelessly, over exactly the tools + * `attachVisibleTools` resolved for this caller: the shared endpoint's + * (`shared`) or the OpenAI directory plugin's (`chatgpt-app`). Every run is + * executed in the one connector that owns the tool, so a same-named tool + * elsewhere can never be reached. */ - private async serveSharedToolset(req: Request, res: Response): Promise { + private async serveSharedToolset( + req: Request, + res: Response, + variant: 'shared' | 'chatgpt-app' = 'shared', + ): Promise { const user = (req as any).user; // Already narrowed to a pinned credential's server by attachVisibleTools. const scopeTools: RegisteredTool[] = @@ -380,7 +432,9 @@ export class McpEndpointController { const setupCtx = setupOrg ? { userId: user.sub, organizationId: setupOrg, serverIds, dashboardBase, assistant } : null; + // The plugin endpoint has no setup tools: don't resolve the permission. const canSetUp = + variant === 'shared' && !!setupProvider && !!setupCtx && !user.mcpServerId && grant?.mode !== 'none' ? await setupProvider.canSetUp(setupCtx) : false; @@ -459,6 +513,27 @@ export class McpEndpointController { }, }; + if (variant === 'chatgpt-app') { + await this.serveStateless( + req, + res, + (req as any).body, + () => { + const mcpServer = new McpServer( + { name: 'AnythingMCP', version: APP_VERSION }, + { instructions: CHATGPT_APP_INSTRUCTIONS }, + ); + registerChatgptAppToolset(mcpServer, scopeTools, { + ...deps, + activity: (ids, days) => this.mcpServersService.activityByConnector(ids, days), + }); + return mcpServer; + }, + 'chatgpt-app', + ); + return; + } + // 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. diff --git a/packages/backend/src/mcp-servers/mcp-servers.service.ts b/packages/backend/src/mcp-servers/mcp-servers.service.ts index 406f2cf1..17943cdd 100644 --- a/packages/backend/src/mcp-servers/mcp-servers.service.ts +++ b/packages/backend/src/mcp-servers/mcp-servers.service.ts @@ -84,6 +84,61 @@ export class McpServersService { return usage; } + /** + * Calls and failed calls per connector over the last `days` days, with the + * time of the last call and of the last failure. Counts only: no inputs, + * outputs or error texts. Grouped on the (connector_id, created_at) index. + */ + async activityByConnector( + connectorIds: string[], + days: number, + ): Promise< + Array<{ + connectorId: string; + calls: number; + errors: number; + lastCallAt: Date | null; + lastErrorAt: Date | null; + }> + > { + const ids = [...new Set(connectorIds)]; + if (ids.length === 0) return []; + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const where = { connectorId: { in: ids }, createdAt: { gte: since } }; + const [all, failed] = await Promise.all([ + this.prisma.toolInvocation.groupBy({ + by: ['connectorId'], + where, + _count: { _all: true }, + _sum: { repeatCount: true }, + _max: { createdAt: true }, + }), + this.prisma.toolInvocation.groupBy({ + by: ['connectorId'], + where: { ...where, status: { in: ['ERROR', 'TIMEOUT'] } }, + _count: { _all: true }, + _sum: { repeatCount: true }, + _max: { createdAt: true }, + }), + ]); + // A row can stand for repeats of the same failure (repeat_count). + const calls = (r: { _count: { _all: number }; _sum: { repeatCount: number | null } }) => + r._sum.repeatCount ?? r._count._all; + const failedById = new Map(failed.map((r) => [r.connectorId, r])); + return all + .filter((r) => r.connectorId) + .map((r) => { + const f = failedById.get(r.connectorId); + return { + connectorId: r.connectorId as string, + calls: calls(r), + errors: f ? calls(f) : 0, + lastCallAt: r._max.createdAt ?? null, + lastErrorAt: f?._max.createdAt ?? null, + }; + }); + } + async findAllByOrg( organizationId: string, opts?: { limit?: number; offset?: number },