Skip to content
Go back

Turning a REST API into AI Tools in Laravel

Most MCP servers are just wrappers around existing API endpoints. If you’re building a Laravel app and want your AI agent to query an external API, you don’t need to find (or build) an MCP server. You can wrap the API yourself using Laravel’s HTTP client and expose it as tool classes.

Here’s how I did it in Growth Method to connect Google Search Console to our AI agent — from OAuth to tool classes to the agent picking up the tools automatically.

The pipeline

Four pieces:

  1. OAuth — let each customer authorise access to their Google Search Console data
  2. Service wrapper — a class that calls the Google Search Console REST API using Laravel’s Http facade
  3. Tool classes — implement the Tool interface so the AI agent can call them
  4. Wiring — a resolver that turns an integration record into tool instances

Step 1: OAuth with League OAuth2 Client

I considered building the OAuth flow by hand — it’s only three HTTP requests (authorise, token exchange, refresh). But PKCE and token refresh are crypto-sensitive code where subtle bugs cause hard-to-debug auth failures. That’s the kind of thing you want a well-tested library to handle.

I installed league/oauth2-client and league/oauth2-google, then built a generic OAuthService that any integration can use. The Google client ID and secret go in .env once — they belong to your app’s Google Cloud project. Each customer authorises separately, getting their own access and refresh tokens stored per-team.

The config lives in config/services.php:

// config/services.php
'google' => [
    'client_id' => env('GOOGLE_CLIENT_ID'),
    'client_secret' => env('GOOGLE_CLIENT_SECRET'),
    'redirect_uri' => env('APP_URL').'/oauth/callback',
    'scopes' => [
        'https://www.googleapis.com/auth/webmasters.readonly',
    ],
],

The OAuthService reads from this config and builds the League provider. Adding a new integration later (Google Analytics, HubSpot) means adding a config block and a provider mapping — no new OAuth code:

// app/Services/OAuthService.php
class OAuthService
{
    private const PROVIDERS = [
        'google-search-console' => [
            'class' => GoogleProvider::class,
            'config' => 'services.google',
        ],
    ];

    public function provider(string $slug): AbstractProvider
    {
        $mapping = self::PROVIDERS[$slug] ?? null;
        $config = config($mapping['config']);

        return new $mapping['class']([
            'clientId' => $config['client_id'],
            'clientSecret' => $config['client_secret'],
            'redirectUri' => $config['redirect_uri'],
            'accessType' => 'offline',
        ]);
    }

    public function exchangeCode(string $slug, string $code): AccessToken
    {
        return $this->provider($slug)
            ->getAccessToken('authorization_code', ['code' => $code]);
    }

    public function refreshToken(string $slug, string $refreshToken): AccessToken
    {
        return $this->provider($slug)
            ->getAccessToken('refresh_token', ['refresh_token' => $refreshToken]);
    }
}

The controller handles the redirect and callback — standard OAuth flow:

// app/Http/Controllers/OAuthController.php
public function redirect(string $slug): RedirectResponse
{
    $url = $this->oauth->getAuthorizationUrl($slug);

    return redirect()->away($url);
}

public function callback(Request $request): RedirectResponse
{
    $slug = session()->pull('oauth_slug');
    $token = $this->oauth->exchangeCode($slug, $request->input('code'));

    Integration::updateOrCreate(
        [
            'team_id' => $team->id,
            'user_id' => $user->id,
            'server_slug' => $slug,
        ],
        [
            'auth_type' => 'oauth',
            'auth_token' => $token->getToken(),
            'refresh_token' => $token->getRefreshToken(),
            'token_expires_at' => $token->getExpires()
                ? Carbon::createFromTimestamp($token->getExpires())
                : null,
            'status' => 'connected',
        ],
    );

    return redirect()->route('settings.index', 'integrations')
        ->with('success', 'Connected successfully.');
}

The question I asked

Really, PKCE and token refresh sound complex and not something we want to implement in-house?

Yes — and I was right to push back. The League package handles PKCE, token refresh, and all the edge cases.

Step 2: The service wrapper

This is the core of the approach. GoogleSearchConsoleService wraps the REST API using Laravel’s Http facade. Each method maps to one API endpoint. No SDK, no abstraction library — just HTTP requests:

// app/Services/GoogleSearchConsoleService.php
class GoogleSearchConsoleService
{
    private const BASE_URL = 'https://www.googleapis.com/webmasters/v3';

    public function __construct(
        private readonly string $accessToken,
    ) {}

    public function querySearchAnalytics(
        string $siteUrl,
        string $startDate,
        string $endDate,
        array $dimensions = [],
        int $rowLimit = 1000,
    ): array {
        $encodedSiteUrl = urlencode($siteUrl);

        return $this->client()
            ->post(self::BASE_URL."/sites/{$encodedSiteUrl}/searchAnalytics/query", [
                'startDate' => $startDate,
                'endDate' => $endDate,
                'dimensions' => $dimensions,
                'rowLimit' => min($rowLimit, 25000),
            ])
            ->throw()
            ->json();
    }

    public function listSites(): array
    {
        return $this->client()
            ->get(self::BASE_URL.'/sites')
            ->throw()
            ->json();
    }

    private function client(): PendingRequest
    {
        return Http::withToken($this->accessToken)
            ->acceptJson()
            ->retry(2, 500);
    }
}

The pattern is simple: inject the access token, call the endpoint, return the JSON. Every method follows the same shape. Adding a new endpoint takes five lines.

Step 3: Tool classes

Each tool class implements the Tool interface from the Laravel AI SDK. The agent needs three things: a name, a description, and a JSON schema:

// app/Ai/Tools/Gsc/SearchAnalyticsTool.php
class SearchAnalyticsTool implements Tool
{
    public function __construct(
        private readonly GoogleSearchConsoleService $gsc,
    ) {}

    public function name(): string
    {
        return 'gsc_search_analytics';
    }

    public function description(): Stringable|string
    {
        return 'Query Google Search Console search analytics data. '
            . 'Returns clicks, impressions, CTR, and average position.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'site_url' => $schema->string()
                ->description('The site property URL')
                ->required(),
            'start_date' => $schema->string()
                ->description('Start date in YYYY-MM-DD format')
                ->required(),
            'end_date' => $schema->string()
                ->description('End date in YYYY-MM-DD format')
                ->required(),
            'dimensions' => $schema->array()
                ->items($schema->string()->enum([
                    'query', 'page', 'date', 'country', 'device',
                ]))
                ->description('Dimensions to group results by'),
        ];
    }

    public function handle(Request $request): Stringable|string
    {
        $result = $this->gsc->querySearchAnalytics(
            siteUrl: $request['site_url'],
            startDate: $request['start_date'],
            endDate: $request['end_date'],
            dimensions: $request['dimensions'] ?? [],
        );

        return json_encode($result, JSON_PRETTY_PRINT);
    }
}

The handle method calls the service wrapper and returns JSON. The agent receives this as a tool result and can reason about it.

I created four tools: SearchAnalyticsTool, ListSitesTool, ListSitemapsTool, and InspectUrlTool. Each one maps to a single service method.

Step 4: Wiring it together

The IntegrationToolResolver converts an Integration database record into tool instances. When the agent starts a conversation, it checks which integrations the team has connected and resolves the matching tools:

// app/Services/IntegrationToolResolver.php
class IntegrationToolResolver
{
    private const DIRECT_INTEGRATIONS = [
        'google-search-console',
    ];

    public static function isDirect(string $slug): bool
    {
        return in_array($slug, self::DIRECT_INTEGRATIONS);
    }

    public static function resolve(Integration $integration): array
    {
        $token = $integration->getActiveToken();

        return match ($integration->server_slug) {
            'google-search-console' => self::gscTools($token),
            default => [],
        };
    }

    private static function gscTools(string $token): array
    {
        $gsc = new GoogleSearchConsoleService($token);

        return [
            new SearchAnalyticsTool($gsc),
            new ListSitesTool($gsc),
            new ListSitemapsTool($gsc),
            new InspectUrlTool($gsc),
        ];
    }
}

The getActiveToken() method on the Integration model handles token refresh automatically — if the token has expired, it calls the OAuthService to get a fresh one before returning it. The tool classes don’t handle authentication — it’s done for them.

Why not use an MCP server?

For Growth Method, the Google Search Console tools run inside the Laravel process. There’s no separate server to deploy, no WebSocket connection to manage, and no serialisation overhead. The tools are plain PHP classes that call the API directly.

MCP servers make sense when you’re building tools that multiple apps will consume, or when you need the tool to run in a sandboxed process. But if you’re building tools for your own Laravel app, a service wrapper is simpler.

The question that started this

My understanding is that most MCP servers are just wrappers around existing API endpoints. I’d like to create a Laravel service class that wraps the Google Search Console API endpoints and turns them into tools I can use.

That’s exactly what this is. The service class wraps the API. The tool classes expose it to the agent. No MCP server needed.

Gotcha: local development with Google OAuth

Google requires redirect URIs to use a public top-level domain (.com, .org, etc.). If your local dev URL ends in .test or .local, Google will reject it:

Invalid Redirect: must end with a public top-level domain (such as .com or .org).

You’ll need to test OAuth flows on a staging environment or production with a real domain. This caught me off guard — worth knowing before you start.

Adding the next integration

To add Google Analytics:

  1. Add a scope to config/services.php
  2. Add a provider mapping to OAuthService::PROVIDERS
  3. Create a GoogleAnalyticsService with methods for each endpoint
  4. Create tool classes that call the service
  5. Register them in IntegrationToolResolver

The OAuth flow, token storage, and token refresh all work automatically — they’re shared across every integration.


Back to top ↑