diff --git a/packages/backend/src/auth/mcp-combined-auth.guard.spec.ts b/packages/backend/src/auth/mcp-combined-auth.guard.spec.ts index 65ea4eb..3b074ee 100644 --- a/packages/backend/src/auth/mcp-combined-auth.guard.spec.ts +++ b/packages/backend/src/auth/mcp-combined-auth.guard.spec.ts @@ -152,4 +152,54 @@ describe('McpCombinedAuthGuard', () => { expect(ctx.switchToHttp().getRequest().user.authMethod).toBe('none'); }); }); + + describe('public demo endpoint exemption (/mcp/demo)', () => { + const ctxWithPath = (path: string) => { + const request = { headers: {}, path, user: undefined as any }; + const response = { + setHeader: jest.fn(), + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + return { + switchToHttp: () => ({ getRequest: () => request, getResponse: () => response }), + } as any; + }; + + // Strictest fail-closed config: legacy mode, no creds, anon NOT allowed. + const strict = () => + mockConfig.get.mockImplementation((key: string) => + key === 'MCP_AUTH_MODE' ? 'legacy' : undefined, + ); + + it('allows anonymous access to the EXACT /mcp/demo path, with no DB lookup', async () => { + strict(); + const ctx = ctxWithPath('/mcp/demo'); + const result = await guard.canActivate(ctx); + expect(result).toBe(true); + expect(ctx.switchToHttp().getRequest().user.authMethod).toBe('none'); + expect(mockPrisma.user.findFirst).not.toHaveBeenCalled(); + expect(mockPrisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it('ignores trailing slash and query string but stays exact', async () => { + strict(); + const ok = await guard.canActivate(ctxWithPath('/mcp/demo/')); + expect(ok).toBe(true); + }); + + it('does NOT exempt a real server id — /mcp/:serverId stays fail-closed', async () => { + strict(); + const ctx = ctxWithPath('/mcp/cmpzj8mm9007j1ymn5mo2y3eq'); + const result = await guard.canActivate(ctx); + expect(result).toBe(false); + expect(ctx.switchToHttp().getResponse().status).toHaveBeenCalledWith(401); + }); + + it('does NOT exempt a look-alike path containing demo', async () => { + strict(); + const result = await guard.canActivate(ctxWithPath('/mcp/demo-evil')); + expect(result).toBe(false); + }); + }); }); diff --git a/packages/backend/src/auth/mcp-combined-auth.guard.ts b/packages/backend/src/auth/mcp-combined-auth.guard.ts index 5a12191..00f99b4 100644 --- a/packages/backend/src/auth/mcp-combined-auth.guard.ts +++ b/packages/backend/src/auth/mcp-combined-auth.guard.ts @@ -30,6 +30,17 @@ export class McpCombinedAuthGuard implements CanActivate { const req = context.switchToHttp().getRequest(); const res = context.switchToHttp().getResponse(); + // Public demo MCP server: a static, tenant-less, anonymous endpoint at the + // EXACT path /mcp/demo. Its handler exposes only self-describing info tools + // and never resolves a serverId or touches the database, so allowing + // anonymous access here cannot expose any tenant data. Match the exact path + // only — every other /mcp/:serverId stays fail-closed below. + const reqPath = (req.path || req.url || '').split('?')[0].replace(/\/+$/, ''); + if (reqPath === '/mcp/demo') { + req.user = { authMethod: 'none' }; + return true; + } + const mode = this.configService.get('MCP_AUTH_MODE') || 'none'; const configuredApiKey = this.configService.get('MCP_API_KEY'); const mcpBearerToken = this.configService.get('MCP_BEARER_TOKEN'); diff --git a/packages/backend/src/mcp-server/mcp-demo.tools.ts b/packages/backend/src/mcp-server/mcp-demo.tools.ts new file mode 100644 index 0000000..a39f18c --- /dev/null +++ b/packages/backend/src/mcp-server/mcp-demo.tools.ts @@ -0,0 +1,104 @@ +import { z } from 'zod'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +/** + * Static, self-describing tools for the PUBLIC demo MCP server (`/mcp/demo`). + * + * These tools return information about AnythingMCP only — they never touch the + * database, connectors, credentials or any tenant data. The demo endpoint + * exists so directory crawlers (Glama, Smithery, mcp.so) and curious agents can + * introspect a working MCP server anonymously and learn how to use the product, + * without us exposing the auth-protected per-tenant endpoints. + */ + +const SITE = 'https://anythingmcp.com'; +const REPO = 'https://github.com/HelpCode-ai/anythingmcp'; +const CLOUD = 'https://cloud.anythingmcp.com'; + +const OVERVIEW = `AnythingMCP is a self-hosted, open-source MCP gateway that turns any API, database or MCP server into custom connectors for Claude, ChatGPT, Gemini, Copilot and Cursor — no code. + +This is a PUBLIC, READ-ONLY demo endpoint: it only describes the product and exposes no customer data. To do real work, run your own instance (it's free, AGPL-3.0) or use the managed cloud. + +• Website: ${SITE} +• Source: ${REPO} +• Cloud: ${CLOUD} + +Next steps — call: +• "anythingmcp_get_started" to install your own gateway in ~60 seconds +• "anythingmcp_connect_client" to connect Claude / ChatGPT / Gemini / Copilot / Cursor +• "anythingmcp_list_connectors" to see the 175+ pre-built connectors`; + +const GET_STARTED = `Run your own AnythingMCP in ~60 seconds: + + git clone ${REPO}.git + cd anythingmcp && ./setup.sh + +Then open http://localhost:3000 and register the first user (it becomes admin). +Import an API spec (OpenAPI/Swagger, Postman, cURL, WSDL, GraphQL) or pick a +pre-built adapter, assign it to an MCP server, and connect your AI client to +http://localhost:4000/mcp. + +Prefer not to self-host? Use the managed cloud: ${CLOUD} +Full guides (EN/DE/IT): ${SITE}/guides`; + +const CONNECT: Record = { + claude: `Claude (Desktop, Code, claude.ai): open Settings → Connectors → "Add custom connector" and paste your AnythingMCP server URL (e.g. http://localhost:4000/mcp or your cloud URL). OAuth 2.0 is supported out of the box. Guide: ${SITE}/guides`, + chatgpt: `ChatGPT: AnythingMCP gives you the MCP backend behind "apps in ChatGPT" (formerly connectors). Add your AnythingMCP URL as a connector/app in ChatGPT's settings, or use it as the tool layer of an Apps SDK app. Guide: ${SITE}/guides`, + gemini: `Google Gemini: point Gemini's MCP/tooling at your AnythingMCP server URL over HTTP/SSE. Guide: ${SITE}/guides`, + copilot: `GitHub Copilot: add your AnythingMCP server URL as an MCP server (Streamable HTTP). Guide: ${SITE}/guides`, + cursor: `Cursor: add your AnythingMCP server URL as an MCP server (Streamable HTTP) in Cursor's MCP settings. Guide: ${SITE}/guides`, +}; + +const CONNECTORS = `AnythingMCP ships 175+ pre-built, ready-to-use connectors. Highlights by category: + +• Logistics & shipping — Deutsche Bahn, DHL, DPD, GLS, Sendcloud +• ERP, accounting & invoicing — weclapp, Xentral, Scopevisio, Billomat +• E-commerce — Etsy, Shopware 6, WooCommerce, Mercado Libre, ImmobilienScout24 +• HR & field service — Personio, HRWorks, Kenjo +• Government & public data — VIES VAT, Handelsregister, DESTATIS, Bundesbank, OpenPLZ +• Banking & payments — N26, Wise, PAYONE +• Messaging — WhatsApp, LINE, TeamViewer +• Sports & Web3 — Playtomic, Sorare + +Plus 5 connector types you can build yourself with no code: REST, SOAP/WSDL, +GraphQL, Database (PostgreSQL/MySQL/MSSQL/Oracle/MongoDB/SQLite) and an +MCP-to-MCP bridge. Browse everything: ${SITE}/guides`; + +/** + * Register the static demo tools on a per-request McpServer instance. + */ +export function registerDemoTools(server: McpServer): void { + server.tool( + 'anythingmcp_overview', + 'What AnythingMCP is, what this demo endpoint does, and where to learn more.', + {}, + async () => ({ content: [{ type: 'text' as const, text: OVERVIEW }] }), + ); + + server.tool( + 'anythingmcp_get_started', + 'How to install and run your own AnythingMCP gateway in ~60 seconds.', + {}, + async () => ({ content: [{ type: 'text' as const, text: GET_STARTED }] }), + ); + + server.tool( + 'anythingmcp_connect_client', + 'Setup instructions to connect an AI client (Claude, ChatGPT, Gemini, Copilot, Cursor) to AnythingMCP.', + { + client: z + .enum(['claude', 'chatgpt', 'gemini', 'copilot', 'cursor']) + .describe('Which AI client to connect.'), + }, + async ({ client }) => ({ + content: [{ type: 'text' as const, text: CONNECT[client] ?? CONNECT.claude }], + }), + ); + + server.tool( + 'anythingmcp_list_connectors', + 'Overview of the 175+ pre-built connectors and the connector types you can build.', + {}, + async () => ({ content: [{ type: 'text' as const, text: CONNECTORS }] }), + ); +} diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts index 679a348..1a78299 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts @@ -129,6 +129,26 @@ describe('McpEndpointController — tenant isolation', () => { ); }); + it('demo endpoint serves static tools without touching tenant data', async () => { + // /mcp/demo must NEVER resolve a server or read connectors — it has no + // tenant data to leak. Verify the per-server services are never called. + const req: any = { headers: {}, user: { authMethod: 'none' } }; + const res = makeRes(); + + await expect( + controller.handleDemoPost(req, res, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: {}, + }), + ).resolves.toBeUndefined(); + + expect(mcpServersService.findById).not.toHaveBeenCalled(); + expect(mcpServersService.getConnectorIds).not.toHaveBeenCalled(); + expect(mcpServersService.isUserInOrganization).not.toHaveBeenCalled(); + }); + it('allows a user whose primary org matches the server (zero-query fast path)', async () => { const req: any = { user: { sub: 'u-a', organizationId: 'org-A', authMethod: 'jwt' }, diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts index e35acbd..508e575 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts @@ -10,7 +10,7 @@ import { UseGuards, Logger, } from '@nestjs/common'; -import { SkipThrottle } from '@nestjs/throttler'; +import { SkipThrottle, Throttle } from '@nestjs/throttler'; import { Request, Response } from 'express'; import { z } from 'zod'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -20,6 +20,7 @@ import { McpServersService } from '../mcp-servers/mcp-servers.service'; import { ToolRegistry } from './tool-registry'; import { DynamicMcpTools } from './dynamic-mcp-tools'; import { RolesService } from '../roles/roles.service'; +import { registerDemoTools } from './mcp-demo.tools'; /** * Per-server MCP endpoint controller. @@ -44,6 +45,35 @@ export class McpEndpointController { private readonly rolesService: RolesService, ) {} + // ─── Public, anonymous, static demo MCP server ────────────────────────── + // A self-describing MCP endpoint at the EXACT path /mcp/demo. It exposes only + // static "how to use AnythingMCP" tools and NEVER resolves a serverId, queries + // the database, or touches connectors / tenant data — so it has nothing to + // leak. Exists so directory crawlers (Glama, Smithery, mcp.so) and agents can + // introspect a working MCP server without auth. The auth guard exempts ONLY + // this exact path; every /mcp/:serverId stays fail-closed. + // + // MUST be declared BEFORE the ':serverId' routes so the static "demo" segment + // wins route matching (otherwise it'd resolve as serverId="demo"). + @Post('demo') + @Throttle({ default: { limit: 60, ttl: 60_000 } }) + async handleDemoPost( + @Req() req: Request, + @Res() res: Response, + @Body() body: unknown, + ) { + await this.handleDemoRequest(req, res, body); + } + + @Get('demo') + handleDemoGet(@Req() _req: Request, @Res() res: Response) { + res.status(405).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method not allowed in stateless mode' }, + id: null, + }); + } + @Post(':serverId') async handlePost( @Param('serverId') serverId: string, @@ -82,6 +112,53 @@ export class McpEndpointController { }); } + /** + * Handle the public demo MCP server. Builds a per-request McpServer with only + * the static info tools (see registerDemoTools) — no DB, no connectors, no + * tenant resolution. Never reaches any of the per-server logic below. + */ + private async handleDemoRequest( + req: Request, + res: Response, + body: unknown, + ) { + const mcpServer = new McpServer( + { name: 'AnythingMCP Demo', version: '1.0.0' }, + { + instructions: + 'Public, read-only demo of AnythingMCP. These tools describe the ' + + 'product and how to use it; they expose no customer data. Start with ' + + 'anythingmcp_overview.', + }, + ); + registerDemoTools(mcpServer); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + try { + await mcpServer.connect(transport); + await transport.handleRequest(req, res, body); + } catch (error: any) { + this.logger.error(`Error handling demo MCP request: ${error.message}`); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal server error' }, + id: null, + }); + } + } finally { + try { + await transport.close(); + await mcpServer.close(); + } catch { + // Ignore cleanup errors + } + } + } + private async handleMcpRequest( serverId: string, req: Request,