Skip to content
Closed
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
1,435 changes: 1,435 additions & 0 deletions backend/__tests__/fixtures/hostedMcp/posthog-selected-tools-2026-10-21.json

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions backend/__tests__/unit/integrations/posthogHostedMcp.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const { POSTHOG_ENTRY } = require('../../../integrations/hostedMcp/posthog');
const { HOSTED_MCP_ENTRIES } = require('../../../integrations/hostedMcp/entries');
const { assessEntryTools, canonicalJson } = require('../../../services/hostedMcpEntryService');
const capture = require('../../fixtures/hostedMcp/posthog-selected-tools-2026-10-21.json');

test('PostHog registers only the selected native tools from the consenting tools-mode capture', () => {
expect(HOSTED_MCP_ENTRIES).toContain(POSTHOG_ENTRY);
expect(capture.server).toBe(POSTHOG_ENTRY.resource);
expect(POSTHOG_ENTRY.audience).toBe('https://mcp.posthog.com/mcp');
expect(capture.toolCountInFullCapture).toBe(780);
expect(capture.requestedScope).toBeNull(); // Scope narrowing needs its own consent check.
expect(POSTHOG_ENTRY.tools.map((tool) => tool.upstreamName)).toEqual(capture.tools.map((tool) => tool.name));
expect(POSTHOG_ENTRY.tools).toHaveLength(9);
expect(POSTHOG_ENTRY.tools.filter((tool) => tool.class === 'read')).toHaveLength(7);
expect(POSTHOG_ENTRY.tools.filter((tool) => tool.class === 'write')).toHaveLength(2);
expect(POSTHOG_ENTRY.tools.some((tool) => tool.name === 'exec' || tool.name === 'execute-sql')).toBe(false);

for (const [index, pin] of POSTHOG_ENTRY.tools.entries()) {
const measured = capture.tools[index];
expect(canonicalJson(pin.inputSchema)).toBe(canonicalJson(measured.inputSchema));
expect(pin.annotations).toEqual({
readOnlyHint: measured.annotations.readOnlyHint,
destructiveHint: measured.annotations.destructiveHint,
});
expect(pin.class).toBe(measured.annotations.readOnlyHint ? 'read' : 'write');
}
expect(assessEntryTools(POSTHOG_ENTRY, capture.tools).every((result) => result.verdict === 'ok')).toBe(true);
});

test('a moved PostHog write schema or withdrawn read claim withholds that pin', () => {
const changed = structuredClone(capture.tools);
changed.find((tool) => tool.name === 'dashboard-create').inputSchema.required = ['name', 'unknown'];
changed.find((tool) => tool.name === 'insights-list').annotations.readOnlyHint = false;
const verdicts = new Map(assessEntryTools(POSTHOG_ENTRY, changed).map((item) => [item.name, item.verdict]));
expect(verdicts.get('dashboard-create')).toBe('tool_drift');
expect(verdicts.get('insights-list')).toBe('tool_drift');
});
4 changes: 3 additions & 1 deletion backend/__tests__/unit/services/hostedMcpEntry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,9 @@ describe('the shipped catalogue cannot land half-wired', () => {
test('ships Linear, Google Calendar, Atlassian, then Airtable, and lookup names the requested entry', () => {
// The catalogue is the one place that says what an instance offers before
// any member has connected anything. Its ordering follows the build plan.
expect(HOSTED_MCP_ENTRIES.map((entry) => entry.id)).toEqual(['linear', 'google-calendar', 'atlassian', 'airtable']);
expect(HOSTED_MCP_ENTRIES.map((entry) => entry.id)).toEqual([
'linear', 'google-calendar', 'atlassian', 'airtable', 'posthog',
]);
const catalogue = [linear(), linear({ id: 'notion', title: 'Notion' })];
expect(findHostedMcpEntry(catalogue, 'notion').title).toBe('Notion');
expect(findHostedMcpEntry(catalogue, 'linear').title).toBe('Linear');
Expand Down
37 changes: 37 additions & 0 deletions backend/__tests__/unit/services/hostedMcpToolDefinitions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/
const {
HOSTED_MCP_CALL_TIMEOUT_MS,
HOSTED_MCP_LIST_MAX_BYTES,
createHostedMcpRuntimeDriftCache,
hostedToolDefinitions,
findHostedToolDefinition,
Expand Down Expand Up @@ -137,6 +138,42 @@ describe('hosted-MCP tool definitions', () => {
expect(JSON.parse(deps.fetcher.mock.calls[0][1].body).method).toBe('tools/list');
});

it('refuses an oversized JSON tools/list body before parsing or calling a tool', async () => {
const reply = JSON.stringify({ jsonrpc: '2.0', id: 'list-1', result: { tools: [{
name: 'list_issues', inputSchema: ENTRY.tools[0].inputSchema,
}] } });
const body = new TextEncoder().encode(reply + ' '.repeat(HOSTED_MCP_LIST_MAX_BYTES));
const deps = makeDeps({
fetcher: jest.fn(async () => new Response(body, {
headers: { 'content-type': 'application/json' },
})),
});
deps.driftCache = createHostedMcpRuntimeDriftCache(deps, [ENTRY]);
await expect(definitionFor('linear.list_issues', deps).call({}, CONNECTION))
.rejects.toMatchObject({ code: 'provider_error', message: expect.stringContaining('could not be verified') });
expect(deps.fetcher).toHaveBeenCalledTimes(1);
});

it('shares the byte budget across SSE tools/list pages', async () => {
const first = `data: ${JSON.stringify({
jsonrpc: '2.0', id: 'list-1', result: { tools: [], nextCursor: 'page-2' },
})}\n${' '.repeat(Math.floor(HOSTED_MCP_LIST_MAX_BYTES / 2))}`;
const second = `data: ${JSON.stringify({
jsonrpc: '2.0', id: 'list-2', result: { tools: [{
name: 'list_issues', inputSchema: ENTRY.tools[0].inputSchema,
}] },
})}\n${' '.repeat(Math.floor(HOSTED_MCP_LIST_MAX_BYTES / 2))}`;
const deps = makeDeps({
fetcher: jest.fn(async (_url, init) => sseResponse([
JSON.parse(init.body).params.cursor ? second : first,
])),
});
deps.driftCache = createHostedMcpRuntimeDriftCache(deps, [ENTRY]);
await expect(definitionFor('linear.list_issues', deps).call({}, CONNECTION))
.rejects.toMatchObject({ code: 'provider_error', message: expect.stringContaining('could not be verified') });
expect(deps.fetcher).toHaveBeenCalledTimes(2);
});

it('offers nothing when the catalogue is empty, and one definition per pinned tool otherwise', () => {
expect(hostedToolDefinitions([], makeDeps())).toHaveLength(0);
const definitions = hostedToolDefinitions([ENTRY], makeDeps());
Expand Down
5 changes: 4 additions & 1 deletion backend/integrations/hostedMcp/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ import { AIRTABLE_ENTRY } from './airtable';
import { ATLASSIAN_ENTRY } from './atlassian';
import { GOOGLE_CALENDAR_ENTRY } from './googleCalendar';
import { LINEAR_ENTRY } from './linear';
import { POSTHOG_ENTRY } from './posthog';

export const HOSTED_MCP_ENTRIES: HostedMcpEntry[] = [LINEAR_ENTRY, GOOGLE_CALENDAR_ENTRY, ATLASSIAN_ENTRY, AIRTABLE_ENTRY];
export const HOSTED_MCP_ENTRIES: HostedMcpEntry[] = [
LINEAR_ENTRY, GOOGLE_CALENDAR_ENTRY, ATLASSIAN_ENTRY, AIRTABLE_ENTRY, POSTHOG_ENTRY,
];

/**
* Seven entry-local defects that would otherwise be silent, checked at module
Expand Down
Loading
Loading