Skip to content
Open
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
59 changes: 59 additions & 0 deletions docs/mcp-analytics-onboarding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# MCP analytics onboarding

Run `npx @posthog/wizard@latest mcp-analytics` to instrument an existing MCP
server. To connect the PostHog MCP server to a coding agent instead, run
`npx @posthog/wizard@latest mcp add`.

Before authentication, the wizard performs a bounded local scan for JavaScript,
TypeScript, and Python server entry points. A single match is preselected:
choose "Set up MCP analytics" to continue. Multiple matches offer a choice. When
the scan is inconclusive, the default action lets the agent find the server and
set up analytics in the current directory. "Choose another location" is an
optional escape hatch for a different directory or entry-point file. The quick
scan is a suggestion: no matches does not rule out a supported server. Tests,
dependencies, and symlinked files are excluded. Suggestions recognize the
official SDK constructors, TypeScript FastMCP (including generic constructors),
and Mastra's MCPServer. Recognition does not guarantee that a wrapper supports
direct instrumentation: the agent still verifies its integration path.

The scan reserves up to 500 matches for MCP-named directories and server-named
files, alongside up to 500 general source matches. Both passes are bounded to
six directory levels and read at most 64 KiB per unique file. This keeps
unrelated application code from consuming the entire suggestion budget in large
monorepos. Application packages sort before examples and templates. Test
fixtures and comment-only examples do not become suggested servers. Aliases,
deeper trees, and files beyond these bounds can still require agent discovery.

The wizard also reads up to 500 package/deployment metadata paths. For
JavaScript workspaces, it groups related server files into one application
suggestion using `package.json` executable/start metadata or a Wrangler config,
server/factory usage, and runtime package dependencies. Development-only
dependencies and client-only consumers do not establish server applications.
Package names are shown beside their locations; choosing an application lets the
agent resolve its entry points and follow workspace imports while keeping
changes scoped to that application. Python entry-point suggestions remain
file-based.

Shared libraries help identify their consumers rather than becoming the default
installation target. If only shared code is found, agent discovery finds the
runnable app first. An MCP launcher with no local implementation gets a source
tracing path: the agent checks imports and package metadata, avoids editing
installed dependencies, and explains the source location when it is outside the
checkout. These hints are local heuristics, not proof of runtime support.

Entering a directory scans that location again. Selecting a file uses its
closest ancestor project directory and asks the agent to verify the selected
entry point. Manually selected files and ambiguous matches get a review before
confirming; a single suggestion and automatic discovery need no second
confirmation. Invalid paths stay on the selection screen and can be corrected
without restarting authentication.

Installation completion does not verify event ingestion. Set the environment
variables in `posthog-mcp-analytics-report.md`, start or redeploy the server,
and invoke a tool that is safe to run. The completion screen links to MCP
analytics in the authenticated project and region so you can check that the call
arrived.

Selection telemetry records scan outcomes, candidate counts, selection method,
and whether a file or directory was selected. These new events do not include
local paths or source code.
1 change: 1 addition & 0 deletions e2e-harness/action-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export const ACTION_REGISTRY: Partial<Record<ScreenName, DriverAction[]>> = {
[ScreenId.SourceMapsIntro]: [confirmSetupAction],
[ScreenId.MigrationIntro]: [confirmSetupAction],
[ScreenId.AgentSkillIntro]: [confirmSetupAction],
[ScreenId.McpAnalyticsIntro]: [confirmSetupAction],
[ScreenId.AiObservabilityIntro]: [confirmSetupAction],
[ScreenId.MetricsIntro]: [confirmSetupAction],
[ScreenId.AuditIntro]: [confirmSetupAction],
Expand Down
1 change: 1 addition & 0 deletions e2e-harness/e2e-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ export function decideE2eAction(
case ScreenId.RevenueIntro:
case ScreenId.MigrationIntro:
case ScreenId.AgentSkillIntro:
case ScreenId.McpAnalyticsIntro:
case ScreenId.AiObservabilityIntro:
case ScreenId.MetricsIntro:
case ScreenId.AuditIntro:
Expand Down
232 changes: 232 additions & 0 deletions scripts/check-mcp-onboarding.no-jest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import React from 'react';
import assert from 'node:assert/strict';
import {
mkdtempSync,
mkdirSync,
writeFileSync,
rmSync,
realpathSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { render } from 'ink-testing-library';
import { WizardStore } from '@ui/tui/store';
import { McpAnalyticsIntroScreen } from '@ui/tui/screens/McpAnalyticsIntroScreen';
import { buildSession } from '@lib/wizard-session';
import { HostResolution } from '@lib/host-resolution';
import {
findMcpServers,
resolveMcpTarget,
} from '@lib/programs/mcp-analytics/detect';
import {
MCP_SCAN_KEY,
MCP_TARGET_KEY,
} from '@lib/programs/mcp-analytics/setup';
import { analytics } from '@utils/analytics';
import {
McpDiscoveryHint,
type McpPackageSuggestions,
} from '@lib/programs/mcp-analytics/packages';

export async function checkMcpOnboarding(): Promise<void> {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-onboarding-')));
const server = join(root, 'server');
mkdirSync(join(server, 'src'), { recursive: true });
writeFileSync(join(server, 'package.json'), '{}');
writeFileSync(
join(server, 'src/index.ts'),
"import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; new McpServer({});",
);
const capture = analytics.wizardCapture;
const events: { event: string; properties?: Record<string, unknown> }[] = [];
analytics.wizardCapture = (event, properties) => {
events.push({ event, properties });
};
const store = new WizardStore('mcp-analytics');
store.session = buildSession({ installDir: root });
store.session.credentials = {
projectId: 42,
projectApiKey: 'phc_example',
accessToken: 'example',
host: HostResolution.fromApiHost('https://eu.i.posthog.com'),
};
store.setFrameworkContext(MCP_SCAN_KEY, { directory: root, candidates: [] });
let scans = 0;
const screen = render(
<McpAnalyticsIntroScreen
store={store}
services={{
resolve: resolveMcpTarget,
scan: async (directory) => {
scans++;
await delay(50);
return findMcpServers(directory);
},
}}
/>,
);
const press = async (key: string): Promise<void> => {
screen.stdin.write(key);
await delay(60);
};
const shows = (text: string): void => {
assert.ok(
screen.lastFrame()?.includes(text),
`Missing ${text}\n${screen.lastFrame()}`,
);
};
try {
await delay(60);
shows('The quick scan didn鈥檛 find a server');
await press('\u001b[B');
await press('\r');
shows('Enter your server directory');
await press('missing');
await press('\r');
shows('This path could not be opened');
assert.equal(store.session.setupConfirmed, false);
await press('\u001b');
await press('\u001b[B');
await press('\r');
await press('server');
screen.stdin.write('\r');
screen.stdin.write('\r');
await delay(200);
assert.equal(scans, 1, 'Repeated Enter must not duplicate a scan');
shows('src/index.ts');
await press('\r');
assert.equal(
store.session.setupConfirmed,
true,
'A single suggestion starts setup with one Enter',
);
assert.equal(store.session.installDir, server);
assert.deepEqual(store.session.frameworkContext[MCP_TARGET_KEY], {
directory: server,
entryPoint: join(server, 'src/index.ts'),
});
assert.equal(store.session.credentials?.projectId, 42);
assert.equal(store.session.setupConfirmed, true);
await press('\r');
assert.equal(
events.filter(({ event }) => event === 'mcp analytics target selected')
.length,
1,
);
const reset = async (
key: string,
candidates: string[] = [],
extras: Partial<McpPackageSuggestions> = {},
): Promise<void> => {
store.session = buildSession({ installDir: root });
store.setFrameworkContext(MCP_SCAN_KEY, {
directory: root,
candidates,
...extras,
});
screen.rerender(
<McpAnalyticsIntroScreen
key={key}
store={store}
services={{
resolve: resolveMcpTarget,
scan: findMcpServers,
}}
/>,
);
await delay(60);
};
await reset('single-suggestion', ['server/src/index.ts']);
shows('Server: server/src/index.ts');
assert.equal(store.session.setupConfirmed, false);
await press('\r');
assert.equal(store.session.setupConfirmed, true);
assert.equal(store.session.installDir, server);

await reset('multiple-suggestions', ['server/src/index.ts', 'another.ts']);
shows('Found several possible servers');
await press('\r');
shows('Instrument this server');
assert.equal(store.session.setupConfirmed, false);
await press('\r');
assert.equal(store.session.setupConfirmed, true);

await reset('application-package', ['server'], {
packageNames: { server: 'example-tools' },
});
shows('example-tools (server)');
await press('\r');
assert.deepEqual(store.session.frameworkContext[MCP_TARGET_KEY], {
directory: server,
});
assert.equal(store.session.setupConfirmed, true);

await reset('launcher', [], { discoveryHint: McpDiscoveryHint.Launcher });
shows('This looks like an MCP launcher');
shows('Find server source and set up analytics');
await press('\r');
assert.equal(store.session.setupConfirmed, true);
assert.equal(store.session.installDir, root);

await reset('shared-library', [], {
discoveryHint: McpDiscoveryHint.SharedLibrary,
});
shows('Found shared MCP code');
await press('\r');
assert.equal(store.session.setupConfirmed, true);
assert.equal(store.session.installDir, root);

await reset('manual-file');
await press('\u001b[B');
await press('\r');
await press('server/src/index.ts');
await press('\r');
shows('Server: src/index.ts');
await press('\r');
assert.equal(store.session.installDir, server);
assert.equal(
events
.filter(({ event }) => event === 'mcp analytics target selected')
.at(-1)?.properties?.selection_source,
'manual',
);

await reset('connect');
await press('\u001b[B');
await press('\u001b[B');
await press('\r');
shows('npx @posthog/wizard@latest mcp add');
assert.equal(store.session.setupConfirmed, false);
await press('\r');
shows('The quick scan didn鈥檛 find a server');
await press('\r');
assert.equal(
store.session.setupConfirmed,
true,
'An inconclusive scan defaults to agent discovery',
);
assert.equal(store.session.installDir, root);
assert.deepEqual(store.session.frameworkContext[MCP_TARGET_KEY], {
directory: root,
});
assert.equal(
events
.filter(({ event }) => event === 'mcp analytics target selected')
.at(-1)?.properties?.selection_source,
'agent_search',
);
assert.ok(
!JSON.stringify(events).includes(root),
'Selection telemetry must not include local paths',
);
console.log(
'\nMCP onboarding directory/file recovery, custom-server fallback, client guidance and duplicate-submit checks passed',
);
console.log(screen.lastFrame());
} finally {
screen.unmount();
analytics.wizardCapture = capture;
rmSync(root, { recursive: true, force: true });
}
}
8 changes: 8 additions & 0 deletions scripts/check-screens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ManagedSettingsScreen } from '@ui/tui/screens/ManagedSettingsScreen';
import { SettingsOverrideScreen } from '@ui/tui/screens/SettingsOverrideScreen';
import { WizardAskScreen } from '@ui/tui/screens/WizardAskScreen';
import type { SettingsConflict } from '@lib/agent/agent-interface';
import { checkMcpOnboarding } from './check-mcp-onboarding.no-jest';

function fakeStore(session: Record<string, unknown>): any {
return {
Expand Down Expand Up @@ -182,6 +183,13 @@ check(
['Database host?', 'ESC', 'skip'],
);

try {
await checkMcpOnboarding();
} catch (error) {
console.error(error);
failures++;
}

if (failures > 0) {
console.error(`\n${failures} screen check(s) failed`);
process.exit(1);
Expand Down
Loading
Loading