feat: MCP domain as native endpoint with OpenBrain support

The mcp.* domain now serves both the human-readable portal AND the
functional API. Agents POST to /tools/call with an API key, browsers
get the HTML docs as a fallback. No separate api.* bridge needed.

- Add POST /tools/call, GET /resources/{uri} routes to mcp domain
- Add JSON server list/detail routes (servers.json, servers/{id}.json)
- Add OpenBrain icon to landing, index, and show views
- Replace all api.* domain references with mcp.* (request()->getSchemeAndHttpHost())
- Rewrite connect.blade.php as HTTP-only documentation
- Update ApiExplorer base URL to use current mcp domain
- Remove stdio/Docker configuration from all views

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-03 21:39:36 +00:00
parent 92faed247e
commit 0653c82148
8 changed files with 159 additions and 124 deletions

View file

@ -1,7 +1,9 @@
<?php
use Illuminate\Support\Facades\Route;
use Core\Mcp\Middleware\McpApiKeyAuth;
use Core\Mcp\Middleware\McpAuthenticate;
use Mod\Api\Controllers\McpApiController;
use Core\Website\Mcp\Controllers\McpRegistryController;
/*
@ -9,8 +11,8 @@ use Core\Website\Mcp\Controllers\McpRegistryController;
| MCP Portal Routes
|--------------------------------------------------------------------------
|
| Public routes for the MCP server registry and documentation portal.
| These routes serve both human-readable docs and machine-readable JSON.
| The MCP domain IS the endpoint. Agents hit /tools/call and /resources/*
| with an API key; browsers get the human-readable fallback (HTML docs).
|
| Domain is set via config('mcp.domain') the app Boot may override
| this with a wildcard for multi-domain support.
@ -18,27 +20,36 @@ use Core\Website\Mcp\Controllers\McpRegistryController;
*/
Route::domain(config('mcp.domain'))->name('mcp.')->group(function () {
// Agent discovery endpoint (always JSON)
// Agent discovery endpoint (always JSON, no auth)
Route::get('.well-known/mcp-servers.json', [McpRegistryController::class, 'registry'])
->name('registry');
// Landing page
// ── Functional API (authenticated) ────────────────────────────
Route::middleware(McpApiKeyAuth::class)->group(function () {
Route::post('tools/call', [McpApiController::class, 'callTool'])->name('tools.call');
Route::get('resources/{uri}', [McpApiController::class, 'resource'])->name('resources.read')
->where('uri', '.+');
Route::get('servers.json', [McpApiController::class, 'servers'])->name('servers.json');
Route::get('servers/{id}.json', [McpApiController::class, 'server'])->name('servers.json.show')
->where('id', '[a-z0-9-]+');
Route::get('servers/{id}/tools', [McpApiController::class, 'tools'])->name('servers.tools')
->where('id', '[a-z0-9-]+');
});
// ── Human-readable portal (optional auth) ────────────────────
Route::get('/', [McpRegistryController::class, 'landing'])
->middleware(McpAuthenticate::class.':optional')
->name('landing');
// Server list (HTML/JSON based on Accept header)
Route::get('servers', [McpRegistryController::class, 'index'])
->middleware(McpAuthenticate::class.':optional')
->name('servers.index');
// Server detail (supports .json extension)
Route::get('servers/{id}', [McpRegistryController::class, 'show'])
->middleware(McpAuthenticate::class.':optional')
->name('servers.show')
->where('id', '[a-z0-9-]+(?:\.json)?');
->where('id', '[a-z0-9-]+');
// Connection config page
Route::get('connect', [McpRegistryController::class, 'connect'])
->middleware(McpAuthenticate::class.':optional')
->name('connect');

View file

@ -26,8 +26,8 @@
wire:model="baseUrl"
class="rounded-md border-yellow-300 shadow-sm focus:border-yellow-500 focus:ring-yellow-500 text-sm"
>
<option value="https://api.host.uk.com">Production</option>
<option value="https://api.staging.host.uk.com">Staging</option>
<option value="https://mcp.lthn.ai">Production</option>
<option value="https://mcp.lthn.sh">Homelab</option>
<option value="http://localhost">Local</option>
</select>
</div>

View file

@ -149,13 +149,14 @@
<p class="text-zinc-600 dark:text-zinc-400 mb-4 text-sm">
Call an MCP tool via HTTP POST:
</p>
<pre class="bg-zinc-900 dark:bg-zinc-950 rounded-lg p-3 overflow-x-auto text-xs"><code class="text-emerald-400">curl -X POST https://mcp.host.uk.com/api/v1/tools/call \
@php $mcpUrl = request()->getSchemeAndHttpHost(); @endphp
<pre class="bg-zinc-900 dark:bg-zinc-950 rounded-lg p-3 overflow-x-auto text-xs"><code class="text-emerald-400">curl -X POST {{ $mcpUrl }}/tools/call \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"server": "commerce",
"tool": "product_list",
"arguments": {}
"server": "openbrain",
"tool": "brain_recall",
"arguments": { "query": "recent decisions" }
}'</code></pre>
</div>
</div>

View file

@ -1,11 +1,15 @@
<x-layouts.mcp>
<x-slot:title>Setup Guide</x-slot:title>
@php
$mcpUrl = request()->getSchemeAndHttpHost();
@endphp
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">Setup Guide</h1>
<p class="mt-2 text-lg text-zinc-600 dark:text-zinc-400">
Connect to MCP servers via HTTP API or stdio.
Connect AI agents to MCP servers via HTTP.
</p>
</div>
@ -28,7 +32,7 @@
</a>
</div>
<!-- HTTP API (Primary) -->
<!-- HTTP API -->
<div class="bg-white dark:bg-zinc-800 rounded-xl border-2 border-cyan-500 p-6 mb-8">
<div class="flex items-center space-x-3 mb-4">
<div class="p-2 bg-cyan-100 dark:bg-cyan-900/30 rounded-lg">
@ -37,143 +41,118 @@
<div>
<h2 class="text-xl font-semibold text-zinc-900 dark:text-white">HTTP API</h2>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300">
Recommended
All platforms
</span>
</div>
</div>
<p class="text-zinc-600 dark:text-zinc-400 mb-4">
Call MCP tools from any language or platform using standard HTTP requests.
Perfect for external integrations, webhooks, and remote agents.
<p class="text-zinc-600 dark:text-zinc-400 mb-6">
Call MCP tools from any language, platform, or AI agent using standard HTTP requests.
Works with Claude Code, Cursor, custom agents, webhooks, and any HTTP client.
</p>
<h3 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2">1. Get your API key</h3>
<p class="text-zinc-600 dark:text-zinc-400 mb-4 text-sm">
Sign in to your account to create an API key from the admin dashboard.
Create an API key from your admin dashboard. Keys use the <code class="px-1 py-0.5 bg-zinc-100 dark:bg-zinc-700 rounded text-xs">hk_</code> prefix.
</p>
<h3 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2">2. Call a tool</h3>
<pre class="bg-zinc-900 dark:bg-zinc-950 rounded-lg p-4 overflow-x-auto text-sm mb-4"><code class="text-emerald-400">curl -X POST https://mcp.host.uk.com/api/v1/mcp/tools/call \
<h3 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2">2. Discover available servers</h3>
<pre class="bg-zinc-900 dark:bg-zinc-950 rounded-lg p-4 overflow-x-auto text-sm mb-4"><code class="text-emerald-400">curl {{ $mcpUrl }}/servers.json \
-H "Authorization: Bearer YOUR_API_KEY"</code></pre>
<h3 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2">3. Call a tool</h3>
<pre class="bg-zinc-900 dark:bg-zinc-950 rounded-lg p-4 overflow-x-auto text-sm mb-4"><code class="text-emerald-400">curl -X POST {{ $mcpUrl }}/tools/call \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"server": "commerce",
"tool": "product_list",
"arguments": { "category": "hosting" }
"server": "openbrain",
"tool": "brain_recall",
"arguments": { "query": "authentication decisions" }
}'</code></pre>
<h3 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2">3. List available tools</h3>
<pre class="bg-zinc-900 dark:bg-zinc-950 rounded-lg p-4 overflow-x-auto text-sm"><code class="text-emerald-400">curl https://mcp.host.uk.com/api/v1/mcp/servers \
<h3 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2">4. Read a resource</h3>
<pre class="bg-zinc-900 dark:bg-zinc-950 rounded-lg p-4 overflow-x-auto text-sm"><code class="text-emerald-400">curl {{ $mcpUrl }}/resources/plans://all \
-H "Authorization: Bearer YOUR_API_KEY"</code></pre>
<div class="mt-6 p-4 bg-zinc-50 dark:bg-zinc-900/50 rounded-lg">
<div class="flex items-center justify-between mb-2">
<h4 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300">API Endpoints</h4>
<h4 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300">Endpoints</h4>
<a href="{{ route('mcp.openapi.json') }}" target="_blank" class="text-xs text-cyan-600 hover:text-cyan-700 dark:text-cyan-400">
View OpenAPI Spec
</a>
</div>
<div class="space-y-2 text-sm">
<div class="flex items-center justify-between">
<code class="text-zinc-600 dark:text-zinc-400">GET /api/v1/mcp/servers</code>
<code class="text-zinc-600 dark:text-zinc-400">GET /.well-known/mcp-servers.json</code>
<span class="text-zinc-500">Agent discovery</span>
</div>
<div class="flex items-center justify-between">
<code class="text-zinc-600 dark:text-zinc-400">GET /servers</code>
<span class="text-zinc-500">List all servers</span>
</div>
<div class="flex items-center justify-between">
<code class="text-zinc-600 dark:text-zinc-400">GET /api/v1/mcp/servers/{id}</code>
<span class="text-zinc-500">Server details</span>
<code class="text-zinc-600 dark:text-zinc-400">GET /servers/{id}</code>
<span class="text-zinc-500">Server details + tools</span>
</div>
<div class="flex items-center justify-between">
<code class="text-zinc-600 dark:text-zinc-400">GET /api/v1/mcp/servers/{id}/tools</code>
<span class="text-zinc-500">List tools</span>
</div>
<div class="flex items-center justify-between">
<code class="text-zinc-600 dark:text-zinc-400">POST /api/v1/mcp/tools/call</code>
<code class="text-zinc-600 dark:text-zinc-400">POST /tools/call</code>
<span class="text-zinc-500">Execute a tool</span>
</div>
<div class="flex items-center justify-between">
<code class="text-zinc-600 dark:text-zinc-400">GET /api/v1/mcp/resources/{uri}</code>
<code class="text-zinc-600 dark:text-zinc-400">GET /resources/{uri}</code>
<span class="text-zinc-500">Read a resource</span>
</div>
</div>
</div>
</div>
<!-- Stdio (Secondary) -->
<!-- Code Examples -->
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 p-6 mb-8">
<div class="flex items-center space-x-3 mb-4">
<div class="p-2 bg-violet-100 dark:bg-violet-900/30 rounded-lg">
<flux:icon.command-line class="w-6 h-6 text-violet-600 dark:text-violet-400" />
<flux:icon.code-bracket class="w-6 h-6 text-violet-600 dark:text-violet-400" />
</div>
<div>
<h2 class="text-xl font-semibold text-zinc-900 dark:text-white">Stdio (Local)</h2>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-zinc-100 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400">
For local development
</span>
<h2 class="text-xl font-semibold text-zinc-900 dark:text-white">Code Examples</h2>
</div>
</div>
<p class="text-zinc-600 dark:text-zinc-400 mb-4">
Direct stdio connection for Claude Code and other local AI agents.
Ideal for OSS framework users running their own Host Hub instance.
</p>
<div class="space-y-6">
<!-- Python -->
<div>
<h3 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2">Python</h3>
<pre class="bg-zinc-900 dark:bg-zinc-950 rounded-lg p-4 overflow-x-auto text-sm"><code class="text-emerald-400">import requests
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-cyan-600 dark:text-cyan-400 hover:text-cyan-700">
Show stdio configuration
</summary>
<div class="mt-4 space-y-6">
<!-- Claude Code -->
<div>
<h3 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2">Claude Code</h3>
<p class="text-zinc-500 dark:text-zinc-400 text-sm mb-2">
Add to <code class="px-1 py-0.5 bg-zinc-100 dark:bg-zinc-700 rounded">~/.claude/claude_code_config.json</code>:
</p>
<pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-4 overflow-x-auto text-sm"><code class="text-zinc-800 dark:text-zinc-200">{
"mcpServers": {
@foreach($servers as $server)
"{{ $server['id'] }}": {
"command": "{{ $server['connection']['command'] ?? 'php' }}",
"args": {!! json_encode($server['connection']['args'] ?? ['artisan', 'mcp:agent-server']) !!},
"cwd": "{{ $server['connection']['cwd'] ?? '/path/to/host.uk.com' }}"
}{{ !$loop->last ? ',' : '' }}
@endforeach
}
}</code></pre>
</div>
<!-- Cursor -->
<div>
<h3 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2">Cursor</h3>
<p class="text-zinc-500 dark:text-zinc-400 text-sm mb-2">
Add to <code class="px-1 py-0.5 bg-zinc-100 dark:bg-zinc-700 rounded">.cursor/mcp.json</code>:
</p>
<pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-4 overflow-x-auto text-sm"><code class="text-zinc-800 dark:text-zinc-200">{
"mcpServers": {
@foreach($servers as $server)
"{{ $server['id'] }}": {
"command": "{{ $server['connection']['command'] ?? 'php' }}",
"args": {!! json_encode($server['connection']['args'] ?? ['artisan', 'mcp:agent-server']) !!}
}{{ !$loop->last ? ',' : '' }}
@endforeach
}
}</code></pre>
</div>
<!-- Docker -->
<div>
<h3 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2">Docker</h3>
<pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-4 overflow-x-auto text-sm"><code class="text-zinc-800 dark:text-zinc-200">{
"mcpServers": {
"hosthub-agent": {
"command": "docker",
"args": ["exec", "-i", "hosthub-app", "php", "artisan", "mcp:agent-server"]
resp = requests.post(
"{{ $mcpUrl }}/tools/call",
headers={"Authorization": "Bearer hk_your_key"},
json={
"server": "openbrain",
"tool": "brain_recall",
"arguments": {"query": "recent decisions"}
}
}
}</code></pre>
</div>
)
print(resp.json())</code></pre>
</div>
</details>
<!-- JavaScript -->
<div>
<h3 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2">JavaScript</h3>
<pre class="bg-zinc-900 dark:bg-zinc-950 rounded-lg p-4 overflow-x-auto text-sm"><code class="text-emerald-400">const resp = await fetch("{{ $mcpUrl }}/tools/call", {
method: "POST",
headers: {
"Authorization": "Bearer hk_your_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
server: "openbrain",
tool: "brain_recall",
arguments: { query: "recent decisions" },
}),
});
const data = await resp.json();</code></pre>
</div>
</div>
</div>
<!-- Authentication Methods -->
@ -199,6 +178,28 @@
check your key's server scopes in your admin dashboard.
</p>
</div>
<div class="mt-4 p-4 bg-cyan-50 dark:bg-cyan-900/20 rounded-lg border border-cyan-200 dark:border-cyan-800">
<h4 class="text-sm font-semibold text-cyan-800 dark:text-cyan-300 mb-1">Rate limiting</h4>
<p class="text-sm text-cyan-700 dark:text-cyan-400">
Requests are rate limited to 120 per minute. Rate limit headers
(<code class="text-xs">X-RateLimit-Limit</code>, <code class="text-xs">X-RateLimit-Remaining</code>)
are included in all responses.
</p>
</div>
</div>
<!-- Discovery -->
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 p-6 mb-8">
<h2 class="text-xl font-semibold text-zinc-900 dark:text-white mb-4">Discovery</h2>
<p class="text-zinc-600 dark:text-zinc-400 mb-4">
Agents discover available servers automatically via the well-known endpoint:
</p>
<pre class="bg-zinc-900 dark:bg-zinc-950 rounded-lg p-4 overflow-x-auto text-sm mb-4"><code class="text-emerald-400">curl {{ $mcpUrl }}/.well-known/mcp-servers.json</code></pre>
<p class="text-sm text-zinc-500 dark:text-zinc-400">
Returns the server registry with capabilities and connection details.
No authentication required for discovery.
</p>
</div>
<!-- Help -->
@ -208,8 +209,8 @@
<flux:button href="{{ route('mcp.servers.index') }}" icon="server-stack">
Browse Servers
</flux:button>
<flux:button href="https://host.uk.com/contact" variant="ghost">
Contact Support
<flux:button href="{{ route('mcp.openapi.json') }}" icon="code-bracket" variant="ghost" target="_blank">
OpenAPI Spec
</flux:button>
</div>
</div>

View file

@ -32,6 +32,9 @@
@case('supporthost')
<flux:icon.chat-bubble-left-right class="w-6 h-6 text-cyan-600 dark:text-cyan-400" />
@break
@case('openbrain')
<flux:icon.light-bulb class="w-6 h-6 text-cyan-600 dark:text-cyan-400" />
@break
@case('analyticshost')
<flux:icon.chart-bar class="w-6 h-6 text-cyan-600 dark:text-cyan-400" />
@break

View file

@ -93,6 +93,9 @@
@case('supporthost')
<flux:icon.chat-bubble-left-right class="w-6 h-6 text-cyan-600 dark:text-cyan-400" />
@break
@case('openbrain')
<flux:icon.light-bulb class="w-6 h-6 text-cyan-600 dark:text-cyan-400" />
@break
@case('analyticshost')
<flux:icon.chart-bar class="w-6 h-6 text-cyan-600 dark:text-cyan-400" />
@break
@ -180,18 +183,21 @@
@endif
<!-- Quick Start -->
@php
$mcpUrl = request()->getSchemeAndHttpHost();
@endphp
<section class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 p-8">
<h2 class="text-2xl font-semibold text-zinc-900 dark:text-white mb-4">Quick Start</h2>
<p class="text-zinc-600 dark:text-zinc-400 mb-6">
Call MCP tools via HTTP API with your API key:
Call MCP tools via HTTP with your API key:
</p>
<pre class="bg-zinc-900 dark:bg-zinc-950 rounded-lg p-4 overflow-x-auto text-sm mb-6"><code class="text-emerald-400">curl -X POST https://mcp.host.uk.com/api/v1/mcp/tools/call \
<pre class="bg-zinc-900 dark:bg-zinc-950 rounded-lg p-4 overflow-x-auto text-sm mb-6"><code class="text-emerald-400">curl -X POST {{ $mcpUrl }}/tools/call \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"server": "commerce",
"tool": "product_list",
"arguments": {}
"server": "openbrain",
"tool": "brain_recall",
"arguments": { "query": "recent decisions" }
}'</code></pre>
<div class="flex flex-wrap items-center gap-4">
<flux:button href="{{ route('mcp.connect') }}" icon="document-text" variant="primary">

View file

@ -29,6 +29,9 @@
@case('supporthost')
<flux:icon.chat-bubble-left-right class="w-8 h-8 text-cyan-600 dark:text-cyan-400" />
@break
@case('openbrain')
<flux:icon.light-bulb class="w-8 h-8 text-cyan-600 dark:text-cyan-400" />
@break
@case('analyticshost')
<flux:icon.chart-bar class="w-8 h-8 text-cyan-600 dark:text-cyan-400" />
@break
@ -96,18 +99,28 @@
</div>
<!-- Connection -->
@if(!empty($server['connection']))
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 p-6 mb-8">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white mb-4">Connection</h2>
<pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-4 overflow-x-auto text-sm"><code class="text-zinc-800 dark:text-zinc-200">{
"{{ $server['id'] }}": {
"command": "{{ $server['connection']['command'] ?? 'php' }}",
"args": {!! json_encode($server['connection']['args'] ?? ['artisan', 'mcp:agent-server']) !!},
"cwd": "{{ $server['connection']['cwd'] ?? '/path/to/project' }}"
}
}</code></pre>
</div>
@endif
@php
$mcpUrl = request()->getSchemeAndHttpHost();
@endphp
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 p-6 mb-8">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white mb-4">Connection</h2>
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
Call tools on this server via HTTP:
</p>
<pre class="bg-zinc-900 dark:bg-zinc-950 rounded-lg p-4 overflow-x-auto text-sm"><code class="text-emerald-400">curl -X POST {{ $mcpUrl }}/tools/call \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"server": "{{ $server['id'] }}",
"tool": "{{ !empty($server['tools']) ? $server['tools'][0]['name'] : 'tool_name' }}",
"arguments": {}
}'</code></pre>
<p class="mt-3 text-xs text-zinc-500 dark:text-zinc-400">
<a href="{{ route('mcp.connect') }}" class="text-cyan-600 dark:text-cyan-400 hover:underline">
Full setup guide
</a>
</p>
</div>
<!-- Tools -->
@if(!empty($server['tools']))

View file

@ -108,8 +108,8 @@ class ApiExplorer extends Component
public function mount(): void
{
// Set base URL from config
$this->baseUrl = config('api.base_url', config('app.url'));
// Set base URL from current request (mcp domain)
$this->baseUrl = request()->getSchemeAndHttpHost();
// Pre-select first endpoint
if (! empty($this->endpoints)) {