Search by

chkltlabs / laravel-porthole

chkltlabs

Authenticated Model Context Protocol server for Laravel Horizon, Pulse, Telescope, and log files.

Package info

github.com/chkltlabs/laravel-porthole

pkg:composer/chkltlabs/laravel-porthole

Statistics

Installs: 85

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.8.1 2026-10-28 17:59 UTC

README

Latest Version on Packagist Total Downloads Tests Website

Authenticated Model Context Protocol server for Laravel Horizon, Pulse, Telescope, and log files under storage/logs. Built on laravel/mcp.

Read tools are registered by default. Write tools stay unregistered until you opt in. Callers need an authenticated user who passes viewPorthole, plus the package gate for that source (viewHorizon, viewPulse, viewTelescope, or viewLogs). An undefined gate denies the call.

Requirements

  • PHP 8.2+
  • Laravel 11.45, 12.41, or 13
  • laravel/mcp 1.x
  • At least one of laravel/horizon, laravel/pulse, or laravel/telescope (log tools need none of these)

Install

composer require chkltlabs/laravel-porthole

The next Artisan command copies the example to config/porthole.php when that file is missing. Edit that file. Republish it with:

php artisan vendor:publish --tag=porthole-config

vendor:publish leaves an existing file in place unless you pass --force.

The HTTP endpoint is POST /mcp/porthole.

Authorization

Define gates before the package boots. There is no permissive default.

use App\Models\User;
use Illuminate\Support\Facades\Gate;

Gate::define('viewPorthole', function (User $user): bool {
    return $user->hasRole('staff');
});

Gate::define('mutateHorizon', function (User $user): bool {
    return $user->hasRole('staff');
});

An institution admin is not this role. viewPorthole is a staff gate for reading operational data. mutateHorizon is a separate staff gate for pausing Horizon, resuming it, and retrying a job. A missing mutateHorizon gate denies those writes.

Horizon, Pulse, and Telescope still apply their own gates. A caller needs both viewPorthole and the source gate.

Point HTTP auth at the guard your MCP clients already use. With the Porthole OAuth issuer off, Porthole does not register POST /mcp/porthole when auth.middleware is empty, including when auth.http_required is false. With the issuer on, an empty list is valid because the package authenticates its own access tokens.

'auth' => [
    'middleware' => [
        'auth:api',
    ],
    'throttle' => '60,1',
],

Sanctum apps can use auth:sanctum instead. Set auth.throttle to an empty string to disable the limiter.

OAuth stays off until oauth.enabled is true and the app is running in local (or oauth.allow_non_local is true). Porthole then serves its own authorization server at /porthole/oauth. That issuer has its own client table and the mcp:use scope. It does not register Passport routes, call Passport::tokensCan(), or change ignoreRoutes() and $user->createToken(). A token from this flow is not an application API token.

laravel/mcp adds an mcp:use scope to Passport on every boot when Passport is installed, including when Porthole OAuth is off. Porthole removes that scope again before the application handles a request. A scope the application already registered as mcp:use stays.

The MCP client URL stays https://your-app.test/mcp/porthole. The client discovers /porthole/oauth from the 401 response. Do not put the issuer in the client's url field, and do not send it to the application's Passport server.

Dynamic client registration stays off until oauth.dynamic_registration is true. With it off, create a public client with php artisan porthole:oauth-client. Enable OAuth, then run php artisan migrate. Porthole loads its OAuth migrations in that mode. Do not also publish porthole-migrations into database/migrations, or migrate runs the same tables twice. When OAuth is on, auth.middleware can stay empty. Do not put auth:api in front of these tokens.

HTTP and stdio auth are separate keys in the same file:

'auth' => [
    'http_required' => true,
    'stdio_required' => false,
],

stdio_required set to false trusts the local process and skips viewPorthole and the source gates for stdio tool calls only. It does not open HTTP. auth.required is a legacy fallback used only when a transport flag is missing from an older published file.

Connect an AI coding agent

Porthole speaks MCP. Your editor or CLI agent is the MCP client. There are two ways to attach:

Transport How the client reaches Porthole Typical auth
Local stdio Client spawns php artisan mcp:start porthole in your app. Registered only when the Laravel environment is local. Trust the machine, or disable auth for that local env
HTTP Client calls https://your-app.test/mcp/porthole Porthole OAuth, or a bearer token from auth:api / Sanctum

Pasting a stdio JSON block into an MCP settings file does not open a browser login. Cursor (and most other tools) start a local process and talk over stdin/stdout. Auth still runs inside each tool call.

What happens after you add the config

  1. Restart the agent / reload MCP so the client picks up the new server.
  2. For stdio, the client runs php artisan mcp:start porthole from your Laravel app directory (set cwd or use absolute paths).
  3. The client lists tools. Log tools always appear. Horizon, Pulse, and Telescope tools appear when those packages are installed.
  4. The first tool call checks $request->user() and the gates.
  5. On stdio there is normally no logged-in user, so you get Authentication is required to read Porthole data until you either:
    • set auth.stdio_required to false in config/porthole.php for that local app (HTTP stays authenticated), or
    • switch to HTTP and send a Porthole OAuth token, or a bearer token your auth middleware already accepts.

App setup by transport

Local stdio (laptop / same machine as the app)

Stdio registers only when APP_ENV=local and stdio.enabled is true. Staging, production, and any other environment ignore the flag.

'auth' => [
    'http_required' => true,
    'stdio_required' => true, // false trusts this machine only
    'middleware' => ['auth:api'],
],

'stdio' => [
    'enabled' => true,
],

Confirm the handle:

php artisan list mcp

HTTP + bearer token (same team, fixed token)

'auth' => [
    'middleware' => ['auth:api'],
],

Issue a personal access token for a user that passes viewPorthole (and the source gates). Clients send:

Authorization: Bearer YOUR_ACCESS_TOKEN

Sanctum apps use auth:sanctum and a Sanctum token in that same header.

HTTP + Porthole OAuth (external / interactive clients)

'auth' => [
    'middleware' => [],
],

'oauth' => [
    'enabled' => true,
    'dynamic_registration' => false,
    'allow_non_local' => false,
    'prefix' => 'porthole/oauth',
],

Enable OAuth and run php artisan migrate. The client calls https://your-app.test/mcp/porthole with no bearer header. A 401 points it at /.well-known/oauth-protected-resource/mcp/porthole, and that document names the /porthole/oauth issuer. The browser login uses the web guard and viewPorthole. The access token is stored in porthole_oauth_tokens and is accepted only by this package.

The browser login route stays oauth.login, which defaults to login. Laravel's React, Vue, Svelte, and Livewire starter kits register that route through Fortify. A Filament panel login route is named filament.{panel}.auth.login.

The consent screen follows the auth page the app already has. With none of the screens below installed, it is the plain page shipped with the package.

  • Filament 4 or 5. The page uses the panel simple layout when a panel is available. oauth.filament set to false keeps that layout off. oauth.filament_panel names the panel. When that key is null, a login route named filament.{panel}.auth.login supplies the panel id, and otherwise Porthole uses Filament's default panel. Filament 3 stays on the plain page: its simple page reads the view from a static property, and Filament 4 and 5 use an instance property. The Filament blades are registered only when Filament 4 or 5 is installed.
  • Livewire starter kit. The page uses <x-layouts::auth>, <x-auth-header>, and Flux buttons, and it renders as soon as the kit is installed. That blade is registered only then, so php artisan view:cache does not compile <x-auth-header> in an app that does not have the kit.
  • React, Vue, and Svelte starter kits. The page uses that kit's auth/ Inertia layout and Button component. It renders after php artisan porthole:consent-screen publishes it. React's page is resources/js/pages/auth/porthole-consent.tsx (auth/porthole-consent). Vue's page is resources/js/pages/auth/PortholeConsent.vue and Svelte's page is resources/js/pages/auth/PortholeConsent.svelte (both auth/PortholeConsent). Rebuild the frontend after publishing one of those pages.

When more than one of those screens is installed, set oauth.consent to plain, filament, livewire, react, vue, or svelte. With that key left null, Porthole uses the first available screen in this order: Filament, Livewire, React, Vue, Svelte.

php artisan porthole:consent-screen publishes the detected screen so the app can edit it. With one screen installed, the command publishes that screen and remembers the choice. With more than one, it asks which to publish, or whether to publish all. Pass --screen= or --all when the terminal is not interactive. --force replaces an existing file. The command writes:

Screen Published file
Plain resources/views/vendor/porthole/oauth/consent.blade.php
Filament resources/views/vendor/porthole/oauth/filament.blade.php and filament-form.blade.php
Livewire resources/views/vendor/porthole/oauth/livewire.blade.php
React resources/js/pages/auth/porthole-consent.tsx
Vue resources/js/pages/auth/PortholeConsent.vue
Svelte resources/js/pages/auth/PortholeConsent.svelte

A published resources/views/vendor/porthole/oauth/consent.blade.php replaces every built-in screen. Delete that file to return to automatic selection. Every screen posts to porthole.oauth.approve with the CSRF token, request, and decision (approve or deny). Keep those fields when you edit a published screen.

Leave dynamic_registration false unless you intend to expose POST /porthole/oauth/register. That route writes Porthole clients, not Passport clients. Leave allow_non_local false outside local development. Do not set prefix to oauth.

Do not commit real tokens. Prefer env vars / your tool’s secret inputs.

Shared config snippets

Stdio (run from the Laravel app root, or set cwd):

{
  "command": "php",
  "args": ["artisan", "mcp:start", "porthole"],
  "cwd": "/absolute/path/to/your-app"
}

HTTP with a fixed bearer token (auth:api or Sanctum):

{
  "url": "https://your-app.test/mcp/porthole",
  "headers": {
    "Authorization": "Bearer YOUR_ACCESS_TOKEN"
  }
}

HTTP with Porthole OAuth. Omit the bearer header so the client can discover the issuer. The per-client examples below that include Authorization are the fixed-token path.

{
  "url": "https://your-app.test/mcp/porthole"
}

Replace the host with your real app URL. Some clients also need an explicit transport type (see below).

Client examples

Bearer headers in these examples are the fixed-token path (auth:api or Sanctum). For Porthole OAuth, keep the same URL and omit Authorization.

Cursor

  • Project: .cursor/mcp.json
  • User: ~/.cursor/mcp.json
  • Key: mcpServers
{
  "mcpServers": {
    "porthole": {
      "command": "php",
      "args": ["artisan", "mcp:start", "porthole"],
      "cwd": "/absolute/path/to/your-app"
    }
  }
}

HTTP example:

{
  "mcpServers": {
    "porthole": {
      "url": "https://your-app.test/mcp/porthole",
      "headers": {
        "Authorization": "Bearer YOUR_SANCTUM_TOKEN"
      }
    }
  }
}

VS Code / GitHub Copilot

  • Workspace: .vscode/mcp.json — top-level key is servers (not mcpServers)
  • Or portable: .mcp.json / ~/.copilot/mcp-config.json with mcpServers
{
  "servers": {
    "porthole": {
      "type": "stdio",
      "command": "php",
      "args": ["artisan", "mcp:start", "porthole"],
      "cwd": "${workspaceFolder}"
    }
  }
}

HTTP:

{
  "servers": {
    "porthole": {
      "type": "http",
      "url": "https://your-app.test/mcp/porthole",
      "headers": {
        "Authorization": "Bearer ${input:porthole-token}"
      }
    }
  }
}

Claude Code

  • Project: .mcp.json
  • User: ~/.claude.json
  • Key: mcpServers
  • CLI: claude mcp add

Stdio:

claude mcp add --transport stdio porthole -- php artisan mcp:start porthole

Run that from the Laravel app root, or pass an absolute path to artisan.

HTTP:

claude mcp add --transport http porthole https://your-app.test/mcp/porthole \
  --header "Authorization: Bearer YOUR_SANCTUM_TOKEN"

JSON (remote entries must include "type": "http"):

{
  "mcpServers": {
    "porthole": {
      "type": "http",
      "url": "https://your-app.test/mcp/porthole",
      "headers": {
        "Authorization": "Bearer YOUR_SANCTUM_TOKEN"
      }
    }
  }
}

Claude Desktop

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
  • Key: mcpServers (stdio is the usual Desktop path)
{
  "mcpServers": {
    "porthole": {
      "command": "php",
      "args": [
        "/absolute/path/to/your-app/artisan",
        "mcp:start",
        "porthole"
      ]
    }
  }
}

Windsurf / Devin Desktop

  • Current: ~/.config/devin/mcp_config.json (Windows: %APPDATA%\devin\mcp_config.json)
  • Older Windsurf: ~/.codeium/windsurf/mcp_config.json
  • Key: mcpServers
{
  "mcpServers": {
    "porthole": {
      "command": "php",
      "args": ["artisan", "mcp:start", "porthole"],
      "cwd": "/absolute/path/to/your-app"
    }
  }
}

Zed

  • macOS / Linux: ~/.config/zed/settings.json
  • Windows: %APPDATA%\Zed\settings.json
  • Key: context_servers
{
  "context_servers": {
    "porthole": {
      "command": {
        "path": "php",
        "args": [
          "/absolute/path/to/your-app/artisan",
          "mcp:start",
          "porthole"
        ]
      }
    }
  }
}

(Zed’s exact command shape can vary by version; if the nested object fails, try a flat command + args under the same key.)

OpenAI Codex CLI

  • User: ~/.codex/config.toml
  • Project: .codex/config.toml (trusted projects)
  • Tables: [mcp_servers.<name>]
[mcp_servers.porthole]
command = "php"
args = ["artisan", "mcp:start", "porthole"]
cwd = "/absolute/path/to/your-app"

Gemini CLI

  • User: ~/.gemini/settings.json
  • Project: .gemini/settings.json
  • Key: mcpServers
  • Remote HTTP uses httpUrl, not url
{
  "mcpServers": {
    "porthole": {
      "command": "php",
      "args": ["artisan", "mcp:start", "porthole"],
      "cwd": "/absolute/path/to/your-app"
    }
  }
}

HTTP:

{
  "mcpServers": {
    "porthole": {
      "httpUrl": "https://your-app.test/mcp/porthole",
      "headers": {
        "Authorization": "Bearer YOUR_SANCTUM_TOKEN"
      }
    }
  }
}

Cline

  • VS Code global storage settings (path varies by OS), or CLINE_MCP_SETTINGS_PATH
  • Key: mcpServers
{
  "mcpServers": {
    "porthole": {
      "command": "php",
      "args": ["artisan", "mcp:start", "porthole"],
      "cwd": "/absolute/path/to/your-app"
    }
  }
}

For remote HTTP, set an explicit streamable HTTP type (Cline defaults to legacy SSE if omitted), for example "type": "streamableHttp" plus url / headers per current Cline docs.

Continue

Drop a Cursor/Claude-style JSON file into .continue/mcpServers/ (plural), or use YAML with a list of servers:

name: Porthole
version: 0.0.1
schema: v1
mcpServers:
  - name: porthole
    command: php
    args:
      - artisan
      - mcp:start
      - porthole
    cwd: /absolute/path/to/your-app

JetBrains AI Assistant

Open the MCP settings UI and paste a standard mcpServers JSON block (stdio or HTTP). Prefer absolute paths to artisan so the IDE’s working directory does not matter.

Roo Code

  • Global: VS Code globalStorage/.../mcp_settings.json
  • Project: .roo/mcp.json
  • Key: mcpServers (same shape as Cursor)

Quick troubleshooting

Symptom Likely cause
mcp:start porthole unknown / server missing stdio.enabled is false, or the app environment is not local
artisan not found Wrong cwd / missing absolute path
Authentication is required to read Porthole data Stdio with no user; set auth.stdio_required to false locally, or switch to HTTP + token
HTTP 404 on /mcp/porthole The Porthole issuer is off and auth.middleware is empty, so the route was not registered
You are not authorized to use Porthole User is authenticated but viewPorthole is missing or fails
VS Code ignores a pasted Cursor file Workspace file must use "servers", not "mcpServers"
Claude Code skips a remote entry Add "type": "http" next to url

What an agent can read

Tool Source
horizon-status Masters, supervisors, workload, job counts
horizon-jobs Recent, pending, completed, failed, or silenced jobs
horizon-metrics Throughput and average runtime
pulse-health Ingest driver and one-hour counts
pulse-overview Totals plus the top slow requests, queries, and exceptions
pulse-exceptions Exception class and location counts
pulse-slow-queries Slow SQL with literals masked
pulse-slow-requests Slow routes
pulse-slow-jobs Slow jobs
pulse-slow-outgoing-requests Slow HTTP client calls
pulse-queues Queued, processing, processed, released, and failed counts
pulse-servers Latest CPU, memory, and disk samples
telescope-list Entries by type, tag, batch, or family
telescope-show One entry and a capped slice of its batch
logs-list .log files under storage/logs (single, daily, nested)
logs-read Head or tail of one log file, with line and byte caps
logs-search Literal search across one or all listed log files

Pulse periods are 1h, 6h, 24h, and 7d.

Log paths stay under storage/logs. Traversal, absolute paths, and non-.log files are refused. Large files are truncated by porthole.logs.lines_max and porthole.logs.bytes_max.

Horizon job payloads are reduced to the display name. Telescope query bindings, cookies, authorization headers, and trace arguments are removed. Sensitive array keys such as password, token, secret, email, account_number, routing_number, and private_key are replaced with [redacted]. On a request to a route this package registered, code, state, nonce, code_verifier, code_challenge, redirect_uri, and return_url are replaced too, and that request path loses its query string. Other application routes keep their query strings and those fields. Quoted SQL strings and unquoted numeric literals are masked. Absolute URLs lose their query strings, including in exception, log, and job-exception text. This is not a tenant boundary: a caller who can read a source can still see that source's operational data.

Writes

These tools are absent from tools/list until the matching flag is true. Calling one while the flag is off returns an error and does not touch Horizon or Telescope.

'writes_enabled' => true,
'destructive_enabled' => true,

writes_enabled registers horizon-pause, horizon-continue, and horizon-retry-failed-job. Those tools also require a defined, passing mutateHorizon gate. Retry stays refused unless writes.retry_allowlist contains the failed job's display name. An empty allowlist refuses every retry.

destructive_enabled registers telescope-prune and logs-delete. Retention hours for prune must fall between the prune floor and prune_max_hours (default 168). Outside local, the floor is at least 24 hours even when prune_min_hours is lower. prune_allow_short_retention is honored only when the environment is local. logs-delete removes listed .log files under storage/logs only; it never appends or edits log contents.

Tests

composer install
composer test
composer test:coverage

CI requires at least 60% line coverage under src/ (pest --coverage --min=60).