godcrm/backend/services/mcp/publicMcp.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

353 lines
14 KiB
JavaScript

/**
* Public, no-signup MCP mount (NO-SIGNUP-MCP.md Stream B, v2).
*
* Mounted under the ADR-105 public-space router as `/s/:slug/mcp`. The space
* slug in the URL is the identity: `publicSpaceAccess` resolves and PINS
* `req.publicSpace` before this handler runs.
*
* SECURITY MODEL (why this is not `executeTool`):
* - DATA isolation comes from the space-gated `public.js` loaders. Every
* loader takes `spaceId` from `req.publicSpace` (never caller args) and
* re-checks space membership, so a table/row/doc id from another tenant
* resolves to `not_found`. `executeTool` — which keys off raw table_id with
* no space gate — is NOT on this path.
* - CAPABILITY isolation comes from `PUBLIC_ALLOWLIST`: an explicit allow-set,
* never a deny-set. A tool is public iff it has a gated loader behind it.
* Both gates are required. Adding a name to the allow-set without a handler
* throws at module load (fail closed) — see the boot assertion below.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { buildToolList } from './buildToolList.js';
import { apiLogger } from '../../utils/logger.js';
import { memoryToolHandlers } from '../agent-tools/memory-tools.js';
import { assessGrounding } from '../dcd/fastGuardrail.js';
import { routeMemoryScope } from '../dcd/scopeRouter.js';
import {
loadPublicTable,
loadPublicTableSchema,
fetchPublicRows,
fetchPublicRowById,
clampPagination,
loadPublicTree,
loadPublicDocumentsRegistry,
loadPublicDocument,
listPublicDashboardWidgets,
} from '../../routes/v3/public.js';
// The lore bank — read-only, public, NEVER the private `godcrm-main` bank.
// Pinned at the mount so a caller can never redirect `memory_recall` at the
// agent's private memory (NO-SIGNUP-MCP §B3 Hindsight note).
export const PUBLIC_MEMORY_BANK = 'godcrm-public';
const NOT_FOUND = { error: 'not_found' };
/**
* Per-tool public handlers. Each receives `(args, space)` where `space` is the
* pinned `req.publicSpace`; the only scope source is `space.id`. Each wraps a
* space-gated `public.js` loader; a loader returning null surfaces as
* `not_found`, never a fallback to an un-gated path.
*/
export const PUBLIC_MCP_HANDLERS = {
async list_projects(args, space) {
const tree = await loadPublicTree(space);
return { projects: tree.projects.map(p => ({ id: p.id, name: p.name, icon: p.icon })) };
},
async list_tables(args, space) {
const tree = await loadPublicTree(space);
let tables = tree.projects.flatMap(p =>
p.tables.map(t => ({ id: t.id, name: t.name, icon: t.icon, project_id: p.id }))
);
const projectId = Number(args?.project_id);
if (Number.isFinite(projectId) && projectId > 0) {
tables = tables.filter(t => t.project_id === projectId);
}
return { tables };
},
async get_table_schema(args, space) {
const schema = await loadPublicTableSchema(space.id, Number(args?.table_id));
return schema || NOT_FOUND;
},
async query_table_data(args, space) {
const table = await loadPublicTable(space.id, Number(args?.table_id));
if (!table) return NOT_FOUND;
const { limit, offset } = clampPagination(args?.limit, args?.offset);
return fetchPublicRows(Number(args.table_id), { limit, offset });
},
async get_table_row(args, space) {
const table = await loadPublicTable(space.id, Number(args?.table_id));
if (!table) return NOT_FOUND;
const row = await fetchPublicRowById(Number(args.table_id), args?.row_id);
return row ? { row } : NOT_FOUND;
},
async get_dashboard_widgets(args, space) {
const result = await listPublicDashboardWidgets(space.id, Number(args?.dashboard_id));
return result || NOT_FOUND;
},
async list_documents(args, space) {
// Space-scoped — the admin tool's `widget_id` is ignored; the public
// surface lists the documents registry of the pinned space.
return loadPublicDocumentsRegistry(space.id);
},
async get_document_content(args, space) {
// `document_id` (registry row id) or a slug both resolve via the loader.
const ref = args?.document_id ?? args?.slug ?? args?.doc_slug;
if (ref === undefined || ref === null || ref === '') return NOT_FOUND;
const doc = await loadPublicDocument(space.id, ref);
return doc || NOT_FOUND;
},
async memory_recall(args, space) {
// ADR-157 scope router — narrow the Collection (Hindsight `room`) BEFORE the
// semantic search (the step that buys +34% precision). A low-confidence match
// returns room=null → the domain is searched unscoped, exactly as before.
// Storage stays liquid: `room` is a predicate over the bank, not a partition.
const route = routeMemoryScope(args?.query);
// bank_id is FORCED to the public bank — caller args cannot redirect it.
const recall = (room) => memoryToolHandlers.memory_recall(
{ query: args?.query, limit: args?.limit, bank_id: PUBLIC_MEMORY_BANK, room },
null,
{ source: 'public-mcp' }
);
let result = await recall(route.room ?? undefined);
// Second fallback (ADR-157 sketch §2): a confident route that comes back empty
// drops one scope level and retries unscoped, so routing can never hide a hit
// that lives outside the chosen room.
let routeOutcome = route.room ? 'routed' : 'widened';
if (route.room && (result?.count ?? 0) === 0) {
result = await recall(undefined);
routeOutcome = 'widened-after-empty';
}
apiLogger.info(
{ spaceId: space?.id, query: args?.query, route, routeOutcome, count: result?.count ?? 0 },
'public memory_recall scope-router'
);
// ADR-157 fast guardrail — SHADOW MODE on the public surface (the costliest,
// least-supervised place to hallucinate). We assess whether the recall is
// grounded in the query and LOG the verdict, but the result is returned
// UNCHANGED. Promote to blocking only after shadow telemetry proves the
// threshold (ADR-157 Consequences: "start in log-only / shadow mode").
try {
const verdict = assessGrounding(args?.query, result?.memories);
apiLogger.info(
{
spaceId: space?.id,
query: args?.query,
verdict,
// Adaptive-head telemetry (ADR-157): how much of the context the
// guardrail actually had to read to settle — this is what later
// justifies the threshold / shadow→blocking promotion.
inspectedTokens: verdict.inspectedTokens,
totalTokens: verdict.totalTokens,
count: result?.count ?? 0,
mode: 'shadow',
},
'public memory_recall fast-guardrail'
);
} catch (err) {
apiLogger.warn({ err: err.message, spaceId: space?.id }, 'fast-guardrail shadow assess failed');
}
return result;
},
};
/**
* Explicit allow-set (NO-SIGNUP-MCP §B3). A tool is public iff it appears here
* AND has a gated handler above. Dropped tools (`list_spaces`, `global_search`,
* `analyze_table_data`, conversations, …) are absent on purpose — they have no
* space-gated loader and would leak cross-tenant.
*/
export const PUBLIC_ALLOWLIST = new Set([
'list_tables',
'list_projects',
'list_documents',
'get_table_schema',
'get_table_row',
'query_table_data',
'get_document_content',
'get_dashboard_widgets',
'memory_recall',
]);
/**
* Boot assertion: every allow-set name MUST have a gated handler (§B3, §Risks
* "New-tool default"). Adding a tool to the allow-set without wiring a gated
* loader re-opens the v1 cross-tenant breach class, so we throw at load — never
* fall back to `executeTool`. Exported so the fail-closed contract is testable.
*/
export function assertHandlersWired(allowlist, handlers) {
for (const name of allowlist) {
if (typeof handlers[name] !== 'function') {
throw new Error(
`[publicMcp] PUBLIC_ALLOWLIST contains "${name}" with no PUBLIC_MCP_HANDLERS entry — ` +
`a public tool MUST have a gated loader (NO-SIGNUP-MCP §B3). Fail closed.`
);
}
}
}
/**
* Symmetric boot assertion (MCP-DOOR-LEGIBILITY "Two hard guardrails" #2): every
* allow-set name MUST appear in `buildToolList()`. Once tools/list advertises
* exactly the allow-set, a rename in `tool-definitions.js` could silently shrink
* the advertised surface BELOW the allow-set (advertise 8, allow 9) with no
* error — the inverse drift `assertHandlersWired` doesn't catch. Throw at load so
* the advertised surface can never fall out of lockstep with the callable one.
* Exported so the lockstep contract is testable.
*/
export function assertAllowlistAdvertised(allowlist, toolList) {
const advertised = new Set(toolList.map((t) => t.name));
for (const name of allowlist) {
if (!advertised.has(name)) {
throw new Error(
`[publicMcp] PUBLIC_ALLOWLIST contains "${name}" but buildToolList() does not ` +
`advertise it — the public tools/list would offer fewer tools than it allows ` +
`(MCP-DOOR-LEGIBILITY guardrail #2). Fail closed.`
);
}
}
}
// Fail closed at module load.
assertHandlersWired(PUBLIC_ALLOWLIST, PUBLIC_MCP_HANDLERS);
assertAllowlistAdvertised(PUBLIC_ALLOWLIST, buildToolList());
/**
* Upsell tool-result for a locked tool. A normal (non-error-transport) result
* so the client renders it inline — the conversion point, pulled AFTER the
* user is already exploring (NO-SIGNUP-MCP §B4).
*/
export function upsellError(name) {
return {
error: 'account_required',
message: `\`${name}\` writes data — create a free space at https://godcrm.ai to unlock it. Reading is open, no signup.`,
signup_url: 'https://godcrm.ai',
};
}
/**
* Build a fresh MCP Server bound to one pinned public space. A new server +
* transport is created per request (stateless), so the space binding cannot
* leak across requests.
*/
function buildPublicMcpServer(space) {
const server = new Server(
// Lever 1 (MCP-DOOR-LEGIBILITY): the name itself signals read-only, and
// `instructions` answers the exact triage question a Shodan/Censys/GreyNoise
// analyst asks of a new no-auth service — in-band, in the protocol it parses.
{ name: 'godcrm-public-readonly', version: '1.0.0' },
{
capabilities: { tools: {} },
instructions:
'Public read-only demo of GOD CRM. Anonymous tier, no authentication by ' +
'design — this endpoint is intentionally public, not a misconfiguration. ' +
'Scoped to a single public demo space; cross-space ids return not_found. ' +
'Reads are open; writes require a free account at https://godcrm.ai.',
}
);
// tools/list advertises EXACTLY the allow-set (NO-SIGNUP-MCP §B6 v2, amended by
// MCP-DOOR-LEGIBILITY "ARCHITECT VERDICT — Option A"). Advertised surface ===
// callable surface: a static scanner reading tools/list sees only read-safe
// tools, not the 68 write verbs it would fingerprint as tool-poisoning. The
// filter lives ONLY here, never in the shared buildToolList() (which the stdio
// agent surface needs whole).
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: buildToolList().filter((t) => PUBLIC_ALLOWLIST.has(t.name)),
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (!PUBLIC_ALLOWLIST.has(name)) {
// Locked or unknown tool → friendly upsell, never a raw 500/401.
return { content: [{ type: 'text', text: JSON.stringify(upsellError(name)) }] };
}
try {
const result = await PUBLIC_MCP_HANDLERS[name](args || {}, space);
const isError = !!result?.error;
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
isError,
};
} catch (err) {
apiLogger.error({ err, tool: name, spaceId: space?.id }, 'public MCP tool error');
return {
content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
isError: true,
};
}
});
return server;
}
/**
* Express handler for `/s/:slug/mcp`. `publicSpaceAccess` has already pinned
* `req.publicSpace` (or 404'd / demanded the password). Stateless
* streamable-http: one Server + transport per request, JSON responses.
*/
export async function mcpPublicHandler(req, res) {
const space = req.publicSpace;
if (!space) {
// Defence-in-depth: should be unreachable (middleware pins it or bails).
return res.status(404).json({ error: 'not_found' });
}
// Lever 2 (MCP-DOOR-LEGIBILITY): GreyNoise/Censys/Shodan probe with GET, not
// MCP JSON-RPC. Without this, GET falls into the streamable-http transport and
// returns a context-less 4xx that reads as a broken/hostile endpoint. Answer a
// benign, fingerprintable banner instead → catalogued as a known-benign
// service, sentinel converts to free third-party verification.
if (req.method === 'GET') {
return res.status(200).json({
service: 'godcrm-public-mcp',
access: 'anonymous-read-only',
intentional: true,
transport: 'streamable-http (POST JSON-RPC)',
docs: 'https://godcrm.ai',
contact: 'security@godcrm.ai',
});
}
const server = buildPublicMcpServer(space);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless — no session, no cross-request state
enableJsonResponse: true,
});
res.on('close', () => {
transport.close();
server.close();
});
try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
apiLogger.error({ err, spaceId: space.id }, 'public MCP transport error');
if (!res.headersSent) {
res.status(500).json({ error: 'mcp_transport_error' });
}
}
}