Skip to content
Go back

When Your Laravel App Needs Both MCP and API Access

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 toolsDirect API
Best forInteractive AI explorationStructured data pipelines
Response formatWhatever the server returnsYou control the query and shape
SchemaDefined by the MCP server authorYou define it
AuthHandled by the MCP connectionYou manage the HTTP call
Adding new capabilitiesLimited to what the server exposesFull 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:

  1. IntegrationToolResolver — knew which integrations were “direct” vs MCP, resolved display names
  2. GoalMetricService — knew how to call PostHog’s REST API, derive base URLs, get project IDs
  3. ResolveMetricConfig — knew how to describe each integration’s tools to the AI
  4. AgentChat — 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:

PatternChat toolsMetric fetchingExample
DirectPHP tool classesREST APIGoogle Analytics, Google Search Console
HybridMCP tools via RelayREST APIPostHog
MCP-onlyMCP tools via RelayNot supportedGitHub, 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:

  1. Scaffolding — interface, manager, four driver classes (no behaviour change)
  2. GoalMetricService — shrank from 592 lines to 267 by moving integration-specific logic into drivers
  3. TeamToolGatherer — extracted ~260 lines of duplicated tool-gathering code from two Livewire components into a shared service
  4. Cleanup — deleted the old IntegrationToolResolver class entirely
  5. 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:


Back to top ↑