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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions packages/backend/src/auth/mcp-combined-auth.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
11 changes: 11 additions & 0 deletions packages/backend/src/auth/mcp-combined-auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('MCP_AUTH_MODE') || 'none';
const configuredApiKey = this.configService.get<string>('MCP_API_KEY');
const mcpBearerToken = this.configService.get<string>('MCP_BEARER_TOKEN');
Expand Down
104 changes: 104 additions & 0 deletions packages/backend/src/mcp-server/mcp-demo.tools.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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 }] }),
);
}
20 changes: 20 additions & 0 deletions packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
79 changes: 78 additions & 1 deletion packages/backend/src/mcp-server/mcp-endpoint.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading