We recently connected PostHog to Growth Method as an MCP integration. The AI chat agent could explore data, ask questions, run queries — all through PostHog’s MCP tools. But when we tried to pull structured metric data (date/value pairs for charts), the MCP tools couldn’t deliver. The tool schemas were too deeply nested for the AI to construct reliably, and the response format wasn’t what we needed.
We had to call PostHog’s REST API directly. Same credentials, different access path.
This raised a question: if one integration needs both MCP and direct API access, how do you organise that in a Laravel app?
MCP vs API — what each is good at
| MCP tools | Direct API | |
|---|---|---|
| Best for | Interactive AI exploration | Structured data pipelines |
| Response format | Whatever the server returns | You control the query and shape |
| Schema | Defined by the MCP server author | You define it |
| Auth | Handled by the MCP connection | You manage the HTTP call |
| Adding new capabilities | Limited to what the server exposes | Full API surface available |
MCP tools are designed for conversational use — an AI agent asking questions and getting answers. Direct API access gives your application code full control over the request format, response shape, and error handling.
The two aren’t in competition. They serve different audiences: MCP serves the AI agent, the API serves your backend code.
The real question I asked
When I realised Growth Method was already doing both — using PostHog’s MCP tools for chat and PostHog’s REST API for metrics — I asked Claude to audit the codebase:
I already have this built into Growth Method, check the codebase, see if we’re doing this sensibly/efficiently based on this discussion.
The answer was that the approach was right, but the implementation was scattered. Knowledge about each integration was spread across four files:
IntegrationToolResolver— knew which integrations were “direct” vs MCP, resolved display namesGoalMetricService— knew how to call PostHog’s REST API, derive base URLs, get project IDsResolveMetricConfig— knew how to describe each integration’s tools to the AIAgentChat— knew how to wire up MCP vs direct tools for chat
Adding a new integration meant touching all four files and hoping nothing was missed.
One credential, two access strategies
The key insight is that an integration is one thing, not two. PostHog has one set of credentials. How your app talks to it — MCP for chat, REST for metrics — is an implementation detail.
In Growth Method, the Integration model stores a single auth token. The MCP server URL and the REST API URL are derived from the same connection:
// PostHog MCP server: https://mcp-eu.posthog.com
// PostHog REST API: https://eu.posthog.com
// Same token works for both.
The question then becomes: where does the routing logic live?
The driver pattern
I asked whether PostHog really needed its own driver, or whether a generic REST driver could handle it:
If I was to add the PostHog MCP server, and authenticate, and manually supply the PostHog API endpoint — is there still a need for the PostHog driver in addition to the MCP driver? Could this rather just be a generic REST API driver?
The answer: keep a PostHog-specific driver. PostHog has quirks — project ID discovery via /api/users/@me/, a specific request body format for HogQL queries, column-oriented response format. A generic driver would need a mini request-templating system to accommodate all this. A 50-line PostHog driver that’s clear and obvious is simpler than a configurable generic driver that’s harder to debug.
The value of the driver pattern is that every integration implements the same interface — not that they share the same driver class.
What the interface looks like
Each integration driver implements IntegrationDriver:
interface IntegrationDriver
{
public function displayName(): string;
public function connectionType(): string;
public function isDirectToolProvider(): bool;
public function supportsMetricFetch(): bool;
public function resolveChatTools(Integration $integration): array;
public function fetchMetric(Integration $integration, ?string $toolName, array $params): mixed;
public function describeMetricTools(): string;
public function transformResponse(mixed $response, array $mapping): array;
public function supportedMappingTypes(): array;
}
A manager resolves the integration slug to the correct driver:
class IntegrationDriverManager
{
public function driver(string $slug): IntegrationDriver
{
if (str_starts_with($slug, 'posthog')) {
return new PostHogDriver($slug);
}
return match ($slug) {
'google-analytics' => new GoogleAnalyticsDriver,
'google-search-console' => new GoogleSearchConsoleDriver,
default => new McpDriver($slug),
};
}
}
Now the consumers just ask the driver:
$driver = $this->driverManager->driver($integration->server_slug);
if ($driver->supportsMetricFetch()) {
return $driver->fetchMetric($integration, $toolName, $params);
}
return $this->callMcpTool($integration, $toolName, $params);
Three patterns emerged
Growth Method now has three types of integration, all behind the same interface:
| Pattern | Chat tools | Metric fetching | Example |
|---|---|---|---|
| Direct | PHP tool classes | REST API | Google Analytics, Google Search Console |
| Hybrid | MCP tools via Relay | REST API | PostHog |
| MCP-only | MCP tools via Relay | Not supported | GitHub, Bento, Webflow |
PostHog is the hybrid — MCP tools power the chat agent, but metric fetching bypasses MCP entirely and calls the REST API. The driver knows this. The consumers don’t need to.
What changed
The refactoring touched five commits:
- Scaffolding — interface, manager, four driver classes (no behaviour change)
- GoalMetricService — shrank from 592 lines to 267 by moving integration-specific logic into drivers
- TeamToolGatherer — extracted ~260 lines of duplicated tool-gathering code from two Livewire components into a shared service
- Cleanup — deleted the old
IntegrationToolResolverclass entirely - Docs — updated project documentation to reflect the new architecture
Adding a new integration now means creating one driver class and registering it in the manager. No more touching four files.
The Laravel way
This follows the same pattern Laravel uses internally. Think of how the FilesystemManager resolves a disk name to a driver (local, s3, ftp). Or how CacheManager resolves a store name. The manager pattern is Laravel’s answer to “I have multiple implementations of the same contract, and I want to resolve the right one at runtime.”
The difference here is that the driver isn’t configured in config/ — it’s determined by the integration’s slug, which comes from the database. But the principle is identical: one interface, multiple implementations, a manager to resolve them.
If you’re building something similar
What I’d do differently — or the same:
- Don’t split one integration into two records. If PostHog has one set of credentials, it should be one integration. The dual access pattern is internal routing, not a data model concern.
- Keep drivers thin. A 50-line driver with clear, obvious code beats a generic abstraction that handles everything via configuration.
- The interface is the value. It’s fine if each driver is quite different internally. The point is that consumers interact with all of them the same way.
- Refactor incrementally. We did this in five commits, each independently shippable. The app worked at every step.