Compare commits

..

1 commit

Author SHA1 Message Date
Claude
c908fff193
test: add comprehensive tests for ToolRegistry service
Covers getServers(), getToolsForServer(), getTool() lookup,
getToolsByCategory(), searchTools(), example input management,
category extraction, schema-based example generation, and cache
clearing. Uses Mockery partial mocks to isolate YAML loading.

Fixes #4

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:09:22 +00:00
38 changed files with 1145 additions and 1192 deletions

View file

@ -1,57 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: PHP ${{ matrix.php }}
runs-on: ubuntu-latest
container:
image: lthn/build:php-${{ matrix.php }}
strategy:
fail-fast: true
matrix:
php: ["8.3", "8.4"]
steps:
- uses: actions/checkout@v4
- name: Clone sister packages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Cloning php-framework into ../php-framework"
git clone --depth 1 \
"https://x-access-token:${GITHUB_TOKEN}@forge.lthn.ai/core/php-framework.git" \
../php-framework
ls -la ../php-framework/composer.json
- name: Configure path repositories
run: |
composer config repositories.core path ../php-framework --no-interaction
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Run Pint
run: |
if [ -f vendor/bin/pint ]; then
vendor/bin/pint --test
else
echo "Pint not installed, skipping"
fi
- name: Run tests
run: |
if [ -f vendor/bin/pest ]; then
vendor/bin/pest --ci --coverage
elif [ -f vendor/bin/phpunit ]; then
vendor/bin/phpunit --coverage-text
else
echo "No test runner found, skipping"
fi

View file

@ -1,38 +0,0 @@
name: Publish Composer Package
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create package archive
run: |
apt-get update && apt-get install -y zip
zip -r package.zip . \
-x ".forgejo/*" \
-x ".git/*" \
-x "tests/*" \
-x "docker/*" \
-x "*.yaml" \
-x "infection.json5" \
-x "phpstan.neon" \
-x "phpunit.xml" \
-x "psalm.xml" \
-x "rector.php" \
-x "TODO.md" \
-x "ROADMAP.md" \
-x "CONTRIBUTING.md" \
-x "package.json" \
-x "package-lock.json"
- name: Publish to Forgejo Composer registry
run: |
curl --fail --user "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file package.zip \
"https://forge.lthn.ai/api/packages/core/composer?version=${FORGEJO_REF_NAME#v}"

View file

@ -1,236 +0,0 @@
# Phase 0 Findings: Environment Assessment
**Date:** 2026-02-21
**Branch:** feat/phase-0-assessment
**Issue:** #1
---
## 1. Environment Assessment
### Composer Install
**Result:** FAILED
```
Your requirements could not be resolved to an installable set of packages.
Problem 1
- Root composer.json requires host-uk/core, it could not be found in any version
```
`host-uk/core` is a private proprietary package (the Core PHP framework). It is not
published to Packagist or a configured private registry accessible in this environment.
**Impact:** Tests, lint, and static analysis cannot be executed without the vendor
directory. All tooling assessment below is based on static code review.
---
## 2. Test Baseline
**Status:** Unable to run (no vendor directory)
**Configured test runner:** Pest (`vendor/bin/pest`)
**Test file inventory:**
| File | Suite | Status |
|------|-------|--------|
| `tests/Unit/SqlQueryValidatorTest.php` | Unit | Present |
| `src/Mcp/Tests/Unit/WorkspaceContextSecurityTest.php` | Unit | Present |
| `src/Mcp/Tests/Unit/ToolAnalyticsServiceTest.php` | Unit | Present |
| `src/Mcp/Tests/Unit/McpQuotaServiceTest.php` | Unit | Present |
| `src/Mcp/Tests/Unit/QueryAuditServiceTest.php` | Unit | Present |
| `src/Mcp/Tests/Unit/QueryExecutionServiceTest.php` | Unit | Present |
| `src/Mcp/Tests/Unit/ToolDependencyServiceTest.php` | Unit | Present |
| `src/Mcp/Tests/Unit/ToolVersionServiceTest.php` | Unit | Present |
| `src/Mcp/Tests/Unit/ValidateWorkspaceContextMiddlewareTest.php` | Unit | Present |
| `src/Mcp/Tests/UseCase/ApiKeyManagerBasic.php` | UseCase | Present |
**Notable gaps:**
- No test for `QueryDatabase` tool (the primary entry point)
- No test for `ToolRegistry` / `AgentToolRegistry` service
- No test for `McpAgentServerCommand` (stdio MCP server)
- No test for `AuditLogService` (tamper-evidence verification)
- No test for `CircuitBreaker` service
- No integration tests at all (`tests/Feature/` is empty)
---
## 3. Lint Baseline
**Tool:** `vendor/bin/pint`
**Status:** Unable to run (no vendor directory)
**Static observation:** All reviewed files contain `declare(strict_types=1)` at the top
and follow PSR-12 conventions. Consistent UK English spelling observed throughout
(colour, organisation, licence, sanitise, normalise).
---
## 4. Static Analysis Baseline
**Tool:** `vendor/bin/phpstan` (level unknown — not declared in composer.json)
**Status:** Unable to run (no vendor directory)
**Observations from code review:**
### Type Safety
- All public methods have complete parameter and return type hints
- Private methods are consistently typed
- `SqlQueryValidator::$whitelist` is `array` — could be `array<int, string>` for PHPStan level 5+
- `Boot::$listens` uses `array<class-string, string>` — correct
### Potential PHPStan Issues (estimated level 5)
1. `QueryDatabase::getWorkspaceId()` calls `workspace()` global helper — not declared in stubs
2. `QueryDatabase::getUserId()` calls `auth()->id()` — return type is `int|string|null`, cast to int without null check
3. `QueryDatabase::interpretExplain()` accesses `$rowArray['type']` on `array<string, mixed>` — likely needs type narrowing
4. `QueryDatabase::handleExplain()` passes `$explainResults` (array of stdClass) to `interpretExplain()` typed as `array` — needs `array<int, object>`
5. `Boot::onMcpTools()` has empty body — PHPStan will warn about unused parameter
---
## 5. Architecture Review
### Package Structure
```
src/Mcp/ # Core\Mcp namespace (103 PHP files)
├── Boot.php # ServiceProvider + event-driven registration
├── Console/ # 5 Artisan commands
├── Context/ # WorkspaceContext value object
├── Controllers/ # McpApiController (REST)
├── Dependencies/ # Tool dependency system (interface + DTO + enum)
├── DTO/ # ToolStats data transfer object
├── Events/ # ToolExecuted domain event
├── Exceptions/ # 6 custom exceptions (typed hierarchy)
├── Lang/en_GB/ # UK English translations
├── Listeners/ # RecordToolExecution event listener
├── Middleware/ # 5 middleware (auth, quota, workspace context, deps)
├── Migrations/ # 5 database migrations
├── Models/ # 8 Eloquent models
├── Resources/ # AppConfig, ContentResource, DatabaseSchema
├── Routes/ # admin.php route file
├── Services/ # 18 business logic services
├── Tests/ # Unit tests co-located with package
├── Tools/ # 10 MCP tool implementations
└── View/ # 12 Blade templates + 9 Livewire components
src/Website/Mcp/ # Core\Website\Mcp namespace
└── ... # Web-facing UI module
```
### Key Architectural Patterns
**1. Event-Driven Module Registration**
`Boot.php` uses a `$listens` static array to subscribe to framework lifecycle events
(`AdminPanelBooting`, `ConsoleBooting`, `McpToolsRegistering`). This enables lazy-loading
of admin UI, commands, and tool registrations without booting the full framework.
**2. Tool Contract**
Tools extend `Laravel\Mcp\Server\Tool` and implement:
- `$description` property
- `schema(JsonSchema $schema): array` — declares MCP input schema
- `handle(Request $request): Response` — executes tool logic
**3. Defence-in-Depth SQL Validation** (`SqlQueryValidator`)
Four sequential layers:
1. Dangerous pattern check on raw query (before comment stripping)
2. Comment stripping (removes `--`, `#`, `/* */`, `/*!` obfuscation)
3. Blocked keyword check (write/admin/export operations)
4. Whitelist regex matching (only known-safe SELECT structures pass)
**4. Workspace Tenant Isolation**
`RequiresWorkspaceContext` trait + `ValidateWorkspaceContext` middleware enforce per-request
tenant scoping. `MissingWorkspaceContextException` is thrown for unauthenticated context.
**5. Tier-Based Resource Limits**
`McpQuotaService` and `QueryExecutionService` apply different limits per subscription tier:
- free / starter / pro / business / enterprise / unlimited
- Limits cover: row count, query timeout, daily request quota
**6. Singleton Service Container**
All 8 core services registered as singletons in `Boot::register()`. Each is independently
testable and injected via Laravel's container.
### Notable Issues
**Issue A — `onMcpTools` is a stub**
`Boot::onMcpTools()` contains only a comment:
```php
public function onMcpTools(McpToolsRegistering $event): void
{
// MCP tool handlers will be registered here once extracted
// from the monolithic McpAgentServerCommand
}
```
This means MCP tools are registered inside `McpAgentServerCommand` rather than being
injected via the service container. Refactoring this is P1 work.
**Issue B — `McpAgentServerCommand` is monolithic**
The stdio MCP server command handles tool registration, JSON-RPC dispatch, and tool
execution in a single command class. This makes it untestable in isolation.
**Issue C — `ListTables` tool exists but Schema Exploration is listed as TODO**
`src/Mcp/Tools/ListTables.php` exists but the TODO.md item "Schema Exploration Tools"
lists adding `ListTables` as pending. This is already implemented.
**Issue D — No `composer.lock`**
No `composer.lock` file is present. Dependency versions are not pinned, which creates
reproducibility risk in CI/CD.
**Issue E — `phpunit.xml` references `vendor/phpunit/phpunit`**
The test runner is configured for PHPUnit XML format but Pest is the stated test runner.
This is compatible (Pest uses PHPUnit under the hood) but the XML namespace warning will
appear until `composer.lock` is generated.
**Issue F — `tests/Feature/` is empty**
No feature/integration tests exist. All tests are unit tests that mock the database.
End-to-end request-response flows have no test coverage.
---
## 6. Security Observations
| Finding | Severity | Status |
|---------|----------|--------|
| SQL injection prevention (multi-layer) | GOOD | Implemented |
| Read-only connection enforcement | GOOD | Implemented |
| Workspace tenant isolation | GOOD | Implemented |
| Audit trail with HMAC verification | GOOD | Implemented |
| Tier-based resource limits | GOOD | Implemented |
| Circuit breaker for external calls | GOOD | Implemented |
| Tool registration outside DI container | MEDIUM | Issue A above |
| No integration tests for auth flow | MEDIUM | Issue F above |
| Missing `composer.lock` | LOW | Issue D above |
| `INFORMATION_SCHEMA` access blocked | GOOD | Implemented |
| System table access blocked | GOOD | Implemented |
---
## 7. Phased Work Recommendations
### Phase 1 — Unblock Testing (Prerequisite)
1. Resolve `host-uk/core` dependency access (private registry credentials or mock stubs)
2. Generate `composer.lock` after successful install
3. Run `vendor/bin/pest` to establish a numerical test baseline
### Phase 2 — Critical Gaps
1. **Extract tools from `McpAgentServerCommand`** into the `McpToolsRegistering` event
handler in `Boot::onMcpTools()` — makes the command testable
2. **Write `QueryDatabase` tool tests** — primary public surface has zero test coverage
3. **Write `AuditLogService` tests** — tamper-evident logging is security-critical
4. **Write integration tests** for the full HTTP → tool → response flow
### Phase 3 — Code Quality
1. Fix estimated PHPStan level 5 type errors (see §4)
2. Add `phpstan.neon` configuration file (currently absent)
3. Add `pint.json` configuration file (currently absent)
4. Resolve TODO.md items marked medium priority
### Phase 4 — Features
Refer to `TODO.md` for the full backlog.

View file

@ -5,7 +5,7 @@ Model Context Protocol (MCP) tools and analytics for AI-powered automation and i
## Installation
```bash
composer require lthn/php-mcp
composer require host-uk/core-mcp
```
## Features

53
TODO.md
View file

@ -1,58 +1,5 @@
# Core-MCP TODO
> See [FINDINGS.md](FINDINGS.md) for the full Phase 0 environment assessment report.
## Phase 0 — Environment Blockers (February 2026)
- [ ] **Resolve `host-uk/core` dependency access**
- Package is not available via Packagist; private registry credentials needed
- Blocks: `composer install`, all tests, lint, and static analysis
- **Action:** Configure private composer repository or provide mock stubs
- [ ] **Generate `composer.lock`** after successful install
- Currently absent — dependency versions are unpinned
- Reproducibility risk in CI/CD
- [ ] **Establish numeric test baseline**
- Run `vendor/bin/pest` and record pass/fail counts
- Targeted after dependency access is resolved
- [ ] **Run PHPStan analysis**
- `vendor/bin/phpstan analyse --memory-limit=512M`
- No `phpstan.neon` config file present — needs creating
- [ ] **Run lint baseline**
- `vendor/bin/pint --test`
- No `pint.json` config file present — needs creating
## Phase 1 — Critical Architecture (Prerequisite)
- [ ] **Refactor: Extract tools from `McpAgentServerCommand`**
- MCP tools are registered inside the command, not via DI container
- Implement `Boot::onMcpTools()` handler (currently a stub)
- Enables unit testing of individual tools in isolation
- **Files:** `src/Mcp/Boot.php`, `src/Mcp/Console/Commands/McpAgentServerCommand.php`
- **Estimated effort:** 4-6 hours
- [ ] **Test Coverage: QueryDatabase Tool** — primary public surface has zero tests
- Test SELECT execution, EXPLAIN analysis, connection validation
- Test blocked keywords and injection prevention end-to-end
- Test tier-based row limit truncation
- Test timeout handling
- **Files:** `tests/Unit/QueryDatabaseTest.php`
- **Estimated effort:** 3-4 hours
- [ ] **Test Coverage: AuditLogService** — security-critical, no tests exist
- Test HMAC tamper-evident logging
- Test log verification command (`mcp:verify-audit-log`)
- **Files:** `src/Mcp/Tests/Unit/AuditLogServiceTest.php`
- **Estimated effort:** 2-3 hours
- [ ] **Add integration tests**`tests/Feature/` is currently empty
- Test full HTTP → tool → response flow
- Test authentication and quota enforcement via middleware stack
- **Estimated effort:** 4-5 hours
## Testing & Quality Assurance
### High Priority

View file

@ -1,23 +1,16 @@
{
"name": "lthn/php-mcp",
"name": "host-uk/core-mcp",
"description": "MCP (Model Context Protocol) tools module for Core PHP framework",
"keywords": [
"laravel",
"mcp",
"ai",
"tools",
"claude"
],
"keywords": ["laravel", "mcp", "ai", "tools", "claude"],
"license": "EUPL-1.2",
"require": {
"php": "^8.2",
"lthn/php": "*"
"host-uk/core": "@dev"
},
"autoload": {
"psr-4": {
"Core\\Mcp\\": "src/Mcp/",
"Core\\Website\\Mcp\\": "src/Website/Mcp/",
"Core\\Front\\Mcp\\": "src/Front/Mcp/"
"Core\\Website\\Mcp\\": "src/Website/Mcp/"
}
},
"autoload-dev": {
@ -27,14 +20,9 @@
},
"extra": {
"laravel": {
"providers": [
"Core\\Front\\Mcp\\Boot"
]
"providers": []
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"replace": {
"core/php-mcp": "self.version"
}
"prefer-stable": true
}

View file

@ -1,52 +0,0 @@
# Discovery Scan — 2026-02-21
Automated discovery scan performed for issue #2.
## Issues Created
### Test Coverage (12 issues)
- #4 — test: add tests for ToolRegistry service
- #5 — test: add tests for AuditLogService
- #6 — test: add tests for CircuitBreaker service
- #7 — test: add tests for DataRedactor service
- #8 — test: add tests for McpHealthService
- #9 — test: add tests for McpMetricsService
- #10 — test: add tests for McpWebhookDispatcher
- #11 — test: add tests for OpenApiGenerator
- #12 — test: add tests for ToolRateLimiter
- #13 — test: add tests for AgentSessionService
- #14 — test: add tests for AgentToolRegistry
- #15 — test: add integration tests for QueryDatabase tool
### Refactoring (4 issues)
- #16 — refactor: extract SQL parser from regex to AST-based validation
- #17 — refactor: standardise tool responses with ToolResult DTO
- #18 — refactor: fix PHPStan level 5 type errors across services
- #19 — refactor: extract McpToolsRegistering tool registration from McpAgentServerCommand
### Infrastructure / Chores (4 issues)
- #20 — chore: create missing ToolRegistry YAML server definition files
- #21 — chore: add PHPStan and static analysis to dev dependencies
- #22 — chore: add CI/CD security regression tests
- #31 — chore: add query result streaming for large result sets
### Features (6 issues)
- #23 — feat: add query template system
- #24 — feat: add schema exploration tools (ListTables, DescribeTable, ListIndexes)
- #25 — feat: add data export tool (CSV, JSON)
- #26 — feat: add query result caching
- #32 — feat: add query history tracking per workspace
- #33 — feat: add data validation tool for database quality checks
### Security (3 issues)
- #27 — security: add monitoring and alerting for suspicious query patterns
- #28 — security: review ContentTools for injection and data exposure risks
- #29 — security: review commerce tools for payment data exposure
### Documentation (1 issue)
- #30 — docs: add inline documentation for ContentTools and commerce tools
### Roadmap (1 issue)
- #34 — roadmap: php-mcp production readiness
**Total: 31 issues created**

View file

@ -1,50 +0,0 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Front\Mcp;
use Core\LifecycleEventProvider;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Support\ServiceProvider;
/**
* MCP frontage - MCP API stage.
*
* Provides mcp middleware group for MCP protocol routes.
* Authentication middleware should be added by the core-mcp package.
*/
class Boot extends ServiceProvider
{
/**
* Configure mcp middleware group.
*/
public static function middleware(Middleware $middleware): void
{
$middleware->group('mcp', [
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
]);
}
public function register(): void
{
//
}
public function boot(): void
{
// Fire McpRoutesRegistering event for lazy-loaded modules
LifecycleEventProvider::fireMcpRoutes();
// Fire McpToolsRegistering so modules can register tool handlers
LifecycleEventProvider::fireMcpTools();
}
}

View file

@ -1,48 +0,0 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Front\Mcp\Contracts;
use Core\Front\Mcp\McpContext;
/**
* Interface for MCP tool handlers.
*
* Each MCP tool is implemented as a handler class that provides:
* - A JSON schema describing the tool for Claude
* - A handle method that processes tool invocations
*
* Tool handlers are registered via the McpToolsRegistering event
* and can be used by both stdio and HTTP MCP transports.
*/
interface McpToolHandler
{
/**
* Get the JSON schema describing this tool.
*
* The schema follows the MCP tool specification:
* - name: Tool identifier (snake_case)
* - description: What the tool does (for Claude)
* - inputSchema: JSON Schema for parameters
*
* @return array{name: string, description: string, inputSchema: array}
*/
public static function schema(): array;
/**
* Handle a tool invocation.
*
* @param array $args Arguments from the tool call
* @param McpContext $context Server context (session, notifications, etc.)
* @return array Result to return to Claude
*/
public function handle(array $args, McpContext $context): array;
}

View file

@ -1,133 +0,0 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Front\Mcp;
use Closure;
/**
* Context object passed to MCP tool handlers.
*
* Abstracts the transport layer (stdio vs HTTP) so tool handlers
* can work with either transport without modification.
*
* Provides access to:
* - Current session tracking
* - Current plan context
* - Notification sending
* - Session logging
*/
class McpContext
{
/**
* @param object|null $currentPlan AgentPlan model instance when Agentic module installed
*/
public function __construct(
private ?string $sessionId = null,
private ?object $currentPlan = null,
private ?Closure $notificationCallback = null,
private ?Closure $logCallback = null,
) {}
/**
* Get the current session ID if one is active.
*/
public function getSessionId(): ?string
{
return $this->sessionId;
}
/**
* Set the current session ID.
*/
public function setSessionId(?string $sessionId): void
{
$this->sessionId = $sessionId;
}
/**
* Get the current plan if one is active.
*
* @return object|null AgentPlan model instance when Agentic module installed
*/
public function getCurrentPlan(): ?object
{
return $this->currentPlan;
}
/**
* Set the current plan.
*
* @param object|null $plan AgentPlan model instance
*/
public function setCurrentPlan(?object $plan): void
{
$this->currentPlan = $plan;
}
/**
* Send an MCP notification to the client.
*
* Notifications are one-way messages that don't expect a response.
* Common notifications include progress updates, log messages, etc.
*/
public function sendNotification(string $method, array $params = []): void
{
if ($this->notificationCallback) {
($this->notificationCallback)($method, $params);
}
}
/**
* Log a message to the current session.
*
* Messages are recorded in the session log for handoff context
* and audit trail purposes.
*/
public function logToSession(string $message, string $type = 'info', array $data = []): void
{
if ($this->logCallback) {
($this->logCallback)($message, $type, $data);
}
}
/**
* Set the notification callback.
*/
public function setNotificationCallback(?Closure $callback): void
{
$this->notificationCallback = $callback;
}
/**
* Set the log callback.
*/
public function setLogCallback(?Closure $callback): void
{
$this->logCallback = $callback;
}
/**
* Check if a session is currently active.
*/
public function hasSession(): bool
{
return $this->sessionId !== null;
}
/**
* Check if a plan is currently active.
*/
public function hasPlan(): bool
{
return $this->currentPlan !== null;
}
}

View file

@ -1,87 +0,0 @@
@php
$appName = config('core.app.name', 'Core PHP');
$appUrl = config('app.url', 'https://core.test');
$privacyUrl = config('core.urls.privacy', '/privacy');
$termsUrl = config('core.urls.terms', '/terms');
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'MCP Portal' }} - {{ $appName }}</title>
<meta name="description" content="{{ $description ?? 'Connect AI agents via Model Context Protocol' }}">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@fluxAppearance
</head>
<body class="min-h-screen bg-white dark:bg-zinc-900">
<!-- Header -->
<header class="border-b border-zinc-200 dark:border-zinc-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<div class="flex items-center space-x-8">
<a href="{{ route('mcp.landing') }}" class="flex items-center space-x-2">
<span class="text-xl font-bold text-zinc-900 dark:text-white">MCP Portal</span>
</a>
<nav class="hidden md:flex items-center space-x-6 text-sm">
<a href="{{ route('mcp.servers.index') }}" class="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white">
Servers
</a>
<a href="{{ route('mcp.connect') }}" class="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white">
Setup Guide
</a>
</nav>
</div>
<div class="flex items-center space-x-4">
@php
$workspace = request()->attributes->get('mcp_workspace');
@endphp
@if($workspace)
<a href="{{ route('mcp.dashboard') }}" class="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white">
Dashboard
</a>
<a href="{{ route('mcp.keys') }}" class="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white">
API Keys
</a>
<span class="text-sm text-zinc-500 dark:text-zinc-400">
{{ $workspace->name }}
</span>
@else
<a href="{{ route('login') }}" class="text-sm text-cyan-600 hover:text-cyan-700 dark:text-cyan-400 dark:hover:text-cyan-300">
Sign in
</a>
@endif
<a href="{{ $appUrl }}" class="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white">
&larr; {{ $appName }}
</a>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{{ $slot }}
</main>
<!-- Footer -->
<footer class="border-t border-zinc-200 dark:border-zinc-800 mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center justify-between">
<p class="text-sm text-zinc-500 dark:text-zinc-400">
&copy; {{ date('Y') }} {{ $appName }}. All rights reserved.
</p>
<div class="flex items-center space-x-6 text-sm text-zinc-500 dark:text-zinc-400">
<a href="{{ $privacyUrl }}" class="hover:text-zinc-900 dark:hover:text-white">Privacy</a>
<a href="{{ $termsUrl }}" class="hover:text-zinc-900 dark:hover:text-white">Terms</a>
</div>
</div>
</div>
</footer>
@fluxScripts
</body>
</html>

View file

@ -6,7 +6,6 @@ namespace Core\Mcp;
use Core\Events\AdminPanelBooting;
use Core\Events\ConsoleBooting;
use Core\Events\McpRoutesRegistering;
use Core\Events\McpToolsRegistering;
use Core\Mcp\Events\ToolExecuted;
use Core\Mcp\Listeners\RecordToolExecution;
@ -19,7 +18,6 @@ use Core\Mcp\Services\ToolDependencyService;
use Core\Mcp\Services\ToolRegistry;
use Core\Mcp\Services\ToolVersionService;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
class Boot extends ServiceProvider
@ -37,7 +35,6 @@ class Boot extends ServiceProvider
public static array $listens = [
AdminPanelBooting::class => 'onAdminPanel',
ConsoleBooting::class => 'onConsole',
McpRoutesRegistering::class => 'onMcpRoutes',
McpToolsRegistering::class => 'onMcpTools',
];
@ -90,42 +87,8 @@ class Boot extends ServiceProvider
$event->livewire('mcp.admin.tool-version-manager', View\Modal\Admin\ToolVersionManager::class);
}
public function onMcpRoutes(McpRoutesRegistering $event): void
{
// Register middleware aliases
$event->middleware('mcp.auth', Middleware\McpApiKeyAuth::class);
$event->middleware('mcp.workspace', Middleware\ValidateWorkspaceContext::class);
$event->middleware('mcp.authenticate', Middleware\McpAuthenticate::class);
$event->middleware('mcp.quota', Middleware\CheckMcpQuota::class);
$event->middleware('mcp.dependencies', Middleware\ValidateToolDependencies::class);
$domain = config('mcp.domain');
$event->routes(fn () => Route::domain($domain)
->middleware('mcp.auth')
->name('mcp.')
->group(function () {
Route::post('tools/call', [Controllers\McpApiController::class, 'callTool'])->name('tools.call');
Route::get('resources/{uri}', [Controllers\McpApiController::class, 'resource'])->name('resources.read')
->where('uri', '.+');
Route::get('servers.json', [Controllers\McpApiController::class, 'servers'])->name('servers.json');
Route::get('servers/{id}.json', [Controllers\McpApiController::class, 'server'])->name('servers.json.show')
->where('id', '[a-z0-9-]+');
Route::get('servers/{id}/tools', [Controllers\McpApiController::class, 'tools'])->name('servers.tools')
->where('id', '[a-z0-9-]+');
})
);
}
public function onConsole(ConsoleBooting $event): void
{
// Middleware aliases for CLI context (artisan route:list etc.)
$event->middleware('mcp.auth', Middleware\McpApiKeyAuth::class);
$event->middleware('mcp.workspace', Middleware\ValidateWorkspaceContext::class);
$event->middleware('mcp.authenticate', Middleware\McpAuthenticate::class);
$event->middleware('mcp.quota', Middleware\CheckMcpQuota::class);
$event->middleware('mcp.dependencies', Middleware\ValidateToolDependencies::class);
$event->command(Console\Commands\McpAgentServerCommand::class);
$event->command(Console\Commands\PruneMetricsCommand::class);
$event->command(Console\Commands\VerifyAuditLogCommand::class);

View file

@ -2,14 +2,14 @@
declare(strict_types=1);
namespace Core\Mcp\Controllers;
namespace Mod\Api\Controllers;
use Core\Front\Controller;
use Core\Mcp\Services\McpQuotaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Core\Api\Models\ApiKey;
use Mod\Api\Models\ApiKey;
use Core\Mcp\Models\McpApiRequest;
use Core\Mcp\Models\McpToolCall;
use Core\Mcp\Services\McpWebhookDispatcher;
@ -119,11 +119,11 @@ class McpApiController extends Controller
$startTime = microtime(true);
try {
// Execute the tool via in-process registry or artisan fallback
$result = $this->executeTool(
// Execute the tool via artisan command
$result = $this->executeToolViaArtisan(
$validated['server'],
$validated['tool'],
$validated['arguments'] ?? [],
$apiKey
$validated['arguments'] ?? []
);
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
@ -201,40 +201,60 @@ class McpApiController extends Controller
}
/**
* Execute a tool via the in-process AgentToolRegistry.
*
* Tools are registered at boot via the McpToolsRegistering lifecycle event.
* This avoids the overhead of spawning artisan sub-processes for each call.
*
* @throws \RuntimeException If tool not found in registry
* Execute tool via artisan MCP server command.
*/
protected function executeTool(string $tool, array $arguments, ?ApiKey $apiKey): mixed
protected function executeToolViaArtisan(string $server, string $tool, array $arguments): mixed
{
$registryClass = \Core\Mod\Agentic\Services\AgentToolRegistry::class;
$commandMap = config('api.mcp.server_commands', []);
if (! app()->bound($registryClass)) {
throw new \RuntimeException("AgentToolRegistry not available — is the agentic module installed?");
$command = $commandMap[$server] ?? null;
if (! $command) {
throw new \RuntimeException("Unknown server: {$server}");
}
$registry = app($registryClass);
// Build MCP request
$mcpRequest = [
'jsonrpc' => '2.0',
'id' => uniqid(),
'method' => 'tools/call',
'params' => [
'name' => $tool,
'arguments' => $arguments,
],
];
if (! $registry->has($tool)) {
throw new \RuntimeException("Tool not found: {$tool}");
}
$context = [];
if ($apiKey?->workspace_id) {
$context['workspace_id'] = $apiKey->workspace_id;
}
return $registry->execute(
name: $tool,
args: $arguments,
context: $context,
apiKey: $apiKey,
validateDependencies: false
// Execute via process
$process = proc_open(
['php', 'artisan', $command],
[
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
],
$pipes,
base_path()
);
if (! is_resource($process)) {
throw new \RuntimeException('Failed to start MCP server process');
}
fwrite($pipes[0], json_encode($mcpRequest)."\n");
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
$response = json_decode($output, true);
if (isset($response['error'])) {
throw new \RuntimeException($response['error']['message'] ?? 'Tool execution failed');
}
return $response['result'] ?? null;
}
/**
@ -313,20 +333,15 @@ class McpApiController extends Controller
bool $success,
?string $error = null
): void {
try {
McpToolCall::log(
serverId: $request['server'],
toolName: $request['tool'],
params: $request['arguments'] ?? [],
success: $success,
durationMs: $durationMs,
errorMessage: $error,
workspaceId: $apiKey?->workspace_id
);
} catch (\Throwable $e) {
// Don't let logging failures affect API response
report($e);
}
McpToolCall::log(
serverId: $request['server'],
toolName: $request['tool'],
params: $request['arguments'] ?? [],
success: $success,
durationMs: $durationMs,
errorMessage: $error,
workspaceId: $apiKey?->workspace_id
);
}
/**

View file

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Core\Mcp\Middleware;
use Core\Api\Models\ApiKey;
use Core\Mod\Api\Models\ApiKey;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

View file

@ -8,31 +8,29 @@ return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('mcp_api_requests')) {
Schema::create('mcp_api_requests', function (Blueprint $table) {
$table->id();
$table->string('request_id', 32)->unique();
$table->foreignId('workspace_id')->nullable()->constrained('workspaces')->nullOnDelete();
$table->foreignId('api_key_id')->nullable()->constrained('api_keys')->nullOnDelete();
$table->string('method', 10);
$table->string('path', 255);
$table->json('headers')->nullable();
$table->json('request_body')->nullable();
$table->unsignedSmallInteger('response_status');
$table->json('response_body')->nullable();
$table->unsignedInteger('duration_ms')->default(0);
$table->string('server_id', 64)->nullable();
$table->string('tool_name', 128)->nullable();
$table->text('error_message')->nullable();
$table->string('ip_address', 45)->nullable();
$table->timestamps();
Schema::create('mcp_api_requests', function (Blueprint $table) {
$table->id();
$table->string('request_id', 32)->unique();
$table->foreignId('workspace_id')->nullable()->constrained('workspaces')->nullOnDelete();
$table->foreignId('api_key_id')->nullable()->constrained('api_keys')->nullOnDelete();
$table->string('method', 10);
$table->string('path', 255);
$table->json('headers')->nullable();
$table->json('request_body')->nullable();
$table->unsignedSmallInteger('response_status');
$table->json('response_body')->nullable();
$table->unsignedInteger('duration_ms')->default(0);
$table->string('server_id', 64)->nullable();
$table->string('tool_name', 128)->nullable();
$table->text('error_message')->nullable();
$table->string('ip_address', 45)->nullable();
$table->timestamps();
$table->index(['workspace_id', 'created_at']);
$table->index(['server_id', 'tool_name']);
$table->index('created_at');
$table->index('response_status');
});
}
$table->index(['workspace_id', 'created_at']);
$table->index(['server_id', 'tool_name']);
$table->index('created_at');
$table->index('response_status');
});
}
public function down(): void

View file

@ -8,40 +8,36 @@ return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('mcp_tool_metrics')) {
Schema::create('mcp_tool_metrics', function (Blueprint $table) {
$table->id();
$table->string('tool_name');
$table->string('workspace_id')->nullable();
$table->unsignedInteger('call_count')->default(0);
$table->unsignedInteger('error_count')->default(0);
$table->unsignedInteger('total_duration_ms')->default(0);
$table->unsignedInteger('min_duration_ms')->nullable();
$table->unsignedInteger('max_duration_ms')->nullable();
$table->date('date');
$table->timestamps();
Schema::create('mcp_tool_metrics', function (Blueprint $table) {
$table->id();
$table->string('tool_name');
$table->string('workspace_id')->nullable();
$table->unsignedInteger('call_count')->default(0);
$table->unsignedInteger('error_count')->default(0);
$table->unsignedInteger('total_duration_ms')->default(0);
$table->unsignedInteger('min_duration_ms')->nullable();
$table->unsignedInteger('max_duration_ms')->nullable();
$table->date('date');
$table->timestamps();
$table->unique(['tool_name', 'workspace_id', 'date']);
$table->index(['date', 'tool_name']);
$table->index('workspace_id');
});
}
$table->unique(['tool_name', 'workspace_id', 'date']);
$table->index(['date', 'tool_name']);
$table->index('workspace_id');
});
// Table for tracking tool combinations (tools used together in sessions)
if (! Schema::hasTable('mcp_tool_combinations')) {
Schema::create('mcp_tool_combinations', function (Blueprint $table) {
$table->id();
$table->string('tool_a');
$table->string('tool_b');
$table->string('workspace_id')->nullable();
$table->unsignedInteger('occurrence_count')->default(0);
$table->date('date');
$table->timestamps();
Schema::create('mcp_tool_combinations', function (Blueprint $table) {
$table->id();
$table->string('tool_a');
$table->string('tool_b');
$table->string('workspace_id')->nullable();
$table->unsignedInteger('occurrence_count')->default(0);
$table->date('date');
$table->timestamps();
$table->unique(['tool_a', 'tool_b', 'workspace_id', 'date']);
$table->index(['date', 'occurrence_count']);
});
}
$table->unique(['tool_a', 'tool_b', 'workspace_id', 'date']);
$table->index(['date', 'occurrence_count']);
});
}
public function down(): void

View file

@ -8,20 +8,18 @@ return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('mcp_usage_quotas')) {
Schema::create('mcp_usage_quotas', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->string('month', 7); // YYYY-MM format
$table->unsignedBigInteger('tool_calls_count')->default(0);
$table->unsignedBigInteger('input_tokens')->default(0);
$table->unsignedBigInteger('output_tokens')->default(0);
$table->timestamps();
Schema::create('mcp_usage_quotas', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->string('month', 7); // YYYY-MM format
$table->unsignedBigInteger('tool_calls_count')->default(0);
$table->unsignedBigInteger('input_tokens')->default(0);
$table->unsignedBigInteger('output_tokens')->default(0);
$table->timestamps();
$table->unique(['workspace_id', 'month']);
$table->index('month');
});
}
$table->unique(['workspace_id', 'month']);
$table->index('month');
});
}
public function down(): void

View file

@ -8,70 +8,66 @@ return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('mcp_audit_logs')) {
Schema::create('mcp_audit_logs', function (Blueprint $table) {
$table->id();
Schema::create('mcp_audit_logs', function (Blueprint $table) {
$table->id();
// Tool execution details
$table->string('server_id')->index();
$table->string('tool_name')->index();
$table->unsignedBigInteger('workspace_id')->nullable()->index();
$table->string('session_id')->nullable()->index();
// Tool execution details
$table->string('server_id')->index();
$table->string('tool_name')->index();
$table->unsignedBigInteger('workspace_id')->nullable()->index();
$table->string('session_id')->nullable()->index();
// Input/output (stored as JSON, may be redacted)
$table->json('input_params')->nullable();
$table->json('output_summary')->nullable();
$table->boolean('success')->default(true);
$table->unsignedInteger('duration_ms')->nullable();
$table->string('error_code')->nullable();
$table->text('error_message')->nullable();
// Input/output (stored as JSON, may be redacted)
$table->json('input_params')->nullable();
$table->json('output_summary')->nullable();
$table->boolean('success')->default(true);
$table->unsignedInteger('duration_ms')->nullable();
$table->string('error_code')->nullable();
$table->text('error_message')->nullable();
// Actor information
$table->string('actor_type')->nullable(); // user, api_key, system
$table->unsignedBigInteger('actor_id')->nullable();
$table->string('actor_ip', 45)->nullable(); // IPv4 or IPv6
// Actor information
$table->string('actor_type')->nullable(); // user, api_key, system
$table->unsignedBigInteger('actor_id')->nullable();
$table->string('actor_ip', 45)->nullable(); // IPv4 or IPv6
// Sensitive tool flagging
$table->boolean('is_sensitive')->default(false)->index();
$table->string('sensitivity_reason')->nullable();
// Sensitive tool flagging
$table->boolean('is_sensitive')->default(false)->index();
$table->string('sensitivity_reason')->nullable();
// Hash chain for tamper detection
$table->string('previous_hash', 64)->nullable(); // SHA-256 of previous entry
$table->string('entry_hash', 64)->index(); // SHA-256 of this entry
// Hash chain for tamper detection
$table->string('previous_hash', 64)->nullable(); // SHA-256 of previous entry
$table->string('entry_hash', 64)->index(); // SHA-256 of this entry
// Agent context
$table->string('agent_type')->nullable();
$table->string('plan_slug')->nullable();
// Agent context
$table->string('agent_type')->nullable();
$table->string('plan_slug')->nullable();
// Timestamps (immutable - no updated_at updates after creation)
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable();
// Timestamps (immutable - no updated_at updates after creation)
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable();
// Foreign key constraint
$table->foreign('workspace_id')
->references('id')
->on('workspaces')
->nullOnDelete();
// Foreign key constraint
$table->foreign('workspace_id')
->references('id')
->on('workspaces')
->nullOnDelete();
// Composite indexes for common queries
$table->index(['workspace_id', 'created_at']);
$table->index(['tool_name', 'created_at']);
$table->index(['is_sensitive', 'created_at']);
$table->index(['actor_type', 'actor_id']);
});
}
// Composite indexes for common queries
$table->index(['workspace_id', 'created_at']);
$table->index(['tool_name', 'created_at']);
$table->index(['is_sensitive', 'created_at']);
$table->index(['actor_type', 'actor_id']);
});
// Table for tracking sensitive tool definitions
if (! Schema::hasTable('mcp_sensitive_tools')) {
Schema::create('mcp_sensitive_tools', function (Blueprint $table) {
$table->id();
$table->string('tool_name')->unique();
$table->string('reason');
$table->json('redact_fields')->nullable(); // Fields to redact in audit logs
$table->boolean('require_explicit_consent')->default(false);
$table->timestamps();
});
}
Schema::create('mcp_sensitive_tools', function (Blueprint $table) {
$table->id();
$table->string('tool_name')->unique();
$table->string('reason');
$table->json('redact_fields')->nullable(); // Fields to redact in audit logs
$table->boolean('require_explicit_consent')->default(false);
$table->timestamps();
});
}
public function down(): void

View file

@ -8,32 +8,30 @@ return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('mcp_tool_versions')) {
Schema::create('mcp_tool_versions', function (Blueprint $table) {
$table->id();
$table->string('server_id', 64)->index();
$table->string('tool_name', 128);
$table->string('version', 32); // semver: 1.0.0, 2.1.0-beta, etc.
$table->json('input_schema')->nullable();
$table->json('output_schema')->nullable();
$table->text('description')->nullable();
$table->text('changelog')->nullable();
$table->text('migration_notes')->nullable(); // guidance for upgrading from previous version
$table->boolean('is_latest')->default(false);
$table->timestamp('deprecated_at')->nullable();
$table->timestamp('sunset_at')->nullable(); // after this date, version is blocked
$table->timestamps();
Schema::create('mcp_tool_versions', function (Blueprint $table) {
$table->id();
$table->string('server_id', 64)->index();
$table->string('tool_name', 128);
$table->string('version', 32); // semver: 1.0.0, 2.1.0-beta, etc.
$table->json('input_schema')->nullable();
$table->json('output_schema')->nullable();
$table->text('description')->nullable();
$table->text('changelog')->nullable();
$table->text('migration_notes')->nullable(); // guidance for upgrading from previous version
$table->boolean('is_latest')->default(false);
$table->timestamp('deprecated_at')->nullable();
$table->timestamp('sunset_at')->nullable(); // after this date, version is blocked
$table->timestamps();
// Unique constraint: one version per tool per server
$table->unique(['server_id', 'tool_name', 'version'], 'mcp_tool_versions_unique');
// Unique constraint: one version per tool per server
$table->unique(['server_id', 'tool_name', 'version'], 'mcp_tool_versions_unique');
// Index for finding latest versions
$table->index(['server_id', 'tool_name', 'is_latest'], 'mcp_tool_versions_latest');
// Index for finding latest versions
$table->index(['server_id', 'tool_name', 'is_latest'], 'mcp_tool_versions_latest');
// Index for finding deprecated/sunset versions
$table->index(['deprecated_at', 'sunset_at'], 'mcp_tool_versions_lifecycle');
});
}
// Index for finding deprecated/sunset versions
$table->index(['deprecated_at', 'sunset_at'], 'mcp_tool_versions_lifecycle');
});
}
public function down(): void

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Core\Website\Mcp;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
@ -26,11 +25,6 @@ class Boot extends ServiceProvider
{
$this->loadViewsFrom(__DIR__.'/View/Blade', 'mcp');
// Register mcp layout into the layouts:: namespace
$layoutsPath = dirname(__DIR__, 2).'/Front/View/Blade/layouts';
$this->loadViewsFrom($layoutsPath, 'layouts');
Blade::anonymousComponentPath($layoutsPath, 'layouts');
$this->registerLivewireComponents();
$this->registerRoutes();
}
@ -49,7 +43,6 @@ class Boot extends ServiceProvider
protected function registerRoutes(): void
{
// HTML portal routes need web middleware (sessions, CSRF for Livewire)
Route::middleware('web')->group(__DIR__.'/Routes/web.php');
}
}

View file

@ -7,8 +7,8 @@ namespace Core\Website\Mcp\Controllers;
use Core\Front\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Core\Mcp\Models\McpToolCall;
use Core\Mcp\Services\OpenApiGenerator;
use Mod\Mcp\Models\McpToolCall;
use Mod\Mcp\Services\OpenApiGenerator;
use Symfony\Component\Yaml\Yaml;
/**

View file

@ -1,41 +1,43 @@
<?php
use Illuminate\Support\Facades\Route;
use Core\Mcp\Middleware\McpAuthenticate;
use Core\Website\Mcp\Controllers\McpRegistryController;
use Mod\Mcp\Middleware\McpAuthenticate;
use Website\Mcp\Controllers\McpRegistryController;
/*
|--------------------------------------------------------------------------
| MCP Portal Routes (HTML)
| MCP Portal Routes (mcp.host.uk.com)
|--------------------------------------------------------------------------
|
| Human-readable documentation portal for the MCP domain.
| Wrapped in 'web' middleware by Website\Mcp\Boot for sessions/CSRF (Livewire).
|
| Functional API routes (tools/call, servers.json, etc.) are registered
| via the McpRoutesRegistering lifecycle event in Core\Mcp\Boot.
| Public routes for the MCP server registry and documentation portal.
| These routes serve both human-readable docs and machine-readable JSON.
|
*/
Route::domain(config('mcp.domain'))->name('mcp.')->group(function () {
// Agent discovery endpoint (always JSON, no auth)
$mcpDomain = config('mcp.domain', 'mcp.host.uk.com');
Route::domain($mcpDomain)->name('mcp.')->group(function () {
// Agent discovery endpoint (always JSON)
Route::get('.well-known/mcp-servers.json', [McpRegistryController::class, 'registry'])
->name('registry');
// ── Human-readable portal (optional auth) ────────────────────
// Landing page
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-]+');
->where('id', '[a-z0-9-]+(?:\.json)?');
// Connection config page
Route::get('connect', [McpRegistryController::class, 'connect'])
->middleware(McpAuthenticate::class.':optional')
->name('connect');

View file

@ -1,4 +1,4 @@
<x-layouts::mcp>
<x-layouts.mcp>
<x-slot:title>{{ $server['name'] }} Analytics</x-slot:title>
<div class="mb-8">
@ -112,4 +112,4 @@
@endforeach
</flux:button.group>
</div>
</x-layouts::mcp>
</x-layouts.mcp>

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://mcp.lthn.ai">Production</option>
<option value="https://mcp.lthn.sh">Homelab</option>
<option value="https://api.host.uk.com">Production</option>
<option value="https://api.staging.host.uk.com">Staging</option>
<option value="http://localhost">Local</option>
</select>
</div>

View file

@ -149,14 +149,13 @@
<p class="text-zinc-600 dark:text-zinc-400 mb-4 text-sm">
Call an MCP tool via HTTP POST:
</p>
@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 \
<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 \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"server": "openbrain",
"tool": "brain_recall",
"arguments": { "query": "recent decisions" }
"server": "commerce",
"tool": "product_list",
"arguments": {}
}'</code></pre>
</div>
</div>

View file

@ -1,15 +1,11 @@
<x-layouts::mcp>
<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 AI agents to MCP servers via HTTP.
Connect to Host UK MCP servers via HTTP API or stdio.
</p>
</div>
@ -32,7 +28,7 @@
</a>
</div>
<!-- HTTP API -->
<!-- HTTP API (Primary) -->
<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">
@ -41,118 +37,143 @@
<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">
All platforms
Recommended
</span>
</div>
</div>
<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 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>
<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">
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.
Sign in to your Host UK account to create an API key from the admin dashboard.
</p>
<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 \
<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 \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"server": "openbrain",
"tool": "brain_recall",
"arguments": { "query": "authentication decisions" }
"server": "commerce",
"tool": "product_list",
"arguments": { "category": "hosting" }
}'</code></pre>
<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 \
<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 \
-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">Endpoints</h4>
<h4 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300">API 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 /.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>
<code class="text-zinc-600 dark:text-zinc-400">GET /api/v1/mcp/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 /servers/{id}</code>
<span class="text-zinc-500">Server details + tools</span>
<code class="text-zinc-600 dark:text-zinc-400">GET /api/v1/mcp/servers/{id}</code>
<span class="text-zinc-500">Server details</span>
</div>
<div class="flex items-center justify-between">
<code class="text-zinc-600 dark:text-zinc-400">POST /tools/call</code>
<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>
<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 /resources/{uri}</code>
<code class="text-zinc-600 dark:text-zinc-400">GET /api/v1/mcp/resources/{uri}</code>
<span class="text-zinc-500">Read a resource</span>
</div>
</div>
</div>
</div>
<!-- Code Examples -->
<!-- Stdio (Secondary) -->
<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.code-bracket class="w-6 h-6 text-violet-600 dark:text-violet-400" />
<flux:icon.command-line 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">Code Examples</h2>
<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>
</div>
</div>
<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
<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>
resp = requests.post(
"{{ $mcpUrl }}/tools/call",
headers={"Authorization": "Bearer hk_your_key"},
json={
"server": "openbrain",
"tool": "brain_recall",
"arguments": {"query": "recent decisions"}
<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"]
}
)
print(resp.json())</code></pre>
}
}</code></pre>
</div>
</div>
<!-- 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>
</details>
</div>
<!-- Authentication Methods -->
@ -178,28 +199,6 @@ const data = await resp.json();</code></pre>
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 -->
@ -209,10 +208,10 @@ const data = await resp.json();</code></pre>
<flux:button href="{{ route('mcp.servers.index') }}" icon="server-stack">
Browse Servers
</flux:button>
<flux:button href="{{ route('mcp.openapi.json') }}" icon="code-bracket" variant="ghost" target="_blank">
OpenAPI Spec
<flux:button href="https://host.uk.com/contact" variant="ghost">
Contact Support
</flux:button>
</div>
</div>
</div>
</x-layouts::mcp>
</x-layouts.mcp>

View file

@ -1,4 +1,4 @@
<x-layouts::mcp>
<x-layouts.mcp>
<x-slot:title>MCP Servers</x-slot:title>
<div class="mb-8">
@ -32,15 +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
@case('eaas')
<flux:icon.shield-check class="w-6 h-6 text-cyan-600 dark:text-cyan-400" />
@break
@default
<flux:icon.server class="w-6 h-6 text-cyan-600 dark:text-cyan-400" />
@endswitch
@ -129,4 +123,4 @@
</div>
</div>
@endif
</x-layouts::mcp>
</x-layouts.mcp>

View file

@ -1,6 +1,6 @@
<x-layouts::mcp>
<x-layouts.mcp>
<x-slot:title>API Keys</x-slot:title>
<x-slot:description>Manage API keys for MCP server access.</x-slot:description>
<livewire:mcp.api-key-manager :workspace="$workspace" />
</x-layouts::mcp>
</x-layouts.mcp>

View file

@ -1,14 +1,14 @@
<x-layouts::mcp>
<x-layouts.mcp>
<x-slot:title>MCP Portal</x-slot:title>
<x-slot:description>Connect AI agents to platform infrastructure via Model Context Protocol. Machine-readable, agent-optimised, human-friendly.</x-slot:description>
<x-slot:description>Connect AI agents to Host UK infrastructure. Machine-readable, agent-optimised, human-friendly.</x-slot:description>
<!-- Hero -->
<div class="text-center mb-16">
<h1 class="text-4xl font-bold text-zinc-900 dark:text-white mb-4">
MCP Ecosystem
Host UK MCP Ecosystem
</h1>
<p class="text-xl text-zinc-600 dark:text-zinc-400 max-w-2xl mx-auto mb-8">
Connect AI agents to platform infrastructure via MCP.<br>
Connect AI agents to Host UK infrastructure.<br>
<span class="text-cyan-600 dark:text-cyan-400">Machine-readable</span> &bull;
<span class="text-cyan-600 dark:text-cyan-400">Agent-optimised</span> &bull;
<span class="text-cyan-600 dark:text-cyan-400">Human-friendly</span>
@ -93,15 +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
@case('eaas')
<flux:icon.shield-check class="w-6 h-6 text-cyan-600 dark:text-cyan-400" />
@break
@default
<flux:icon.server class="w-6 h-6 text-cyan-600 dark:text-cyan-400" />
@endswitch
@ -186,21 +180,18 @@
@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 with your API key:
Call MCP tools via HTTP API 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 {{ $mcpUrl }}/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 https://mcp.host.uk.com/api/v1/mcp/tools/call \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"server": "openbrain",
"tool": "brain_recall",
"arguments": { "query": "recent decisions" }
"server": "commerce",
"tool": "product_list",
"arguments": {}
}'</code></pre>
<div class="flex flex-wrap items-center gap-4">
<flux:button href="{{ route('mcp.connect') }}" icon="document-text" variant="primary">
@ -211,4 +202,4 @@
</flux:button>
</div>
</section>
</x-layouts::mcp>
</x-layouts.mcp>

View file

@ -236,7 +236,7 @@
<div class="space-y-3 text-sm">
<div>
<span class="text-zinc-500 dark:text-zinc-400">Endpoint:</span>
<code class="ml-2 text-zinc-800 dark:text-zinc-200 break-all">{{ request()->getSchemeAndHttpHost() }}/tools/call</code>
<code class="ml-2 text-zinc-800 dark:text-zinc-200 break-all">{{ config('app.url') }}/api/v1/mcp/tools/call</code>
</div>
<div>
<span class="text-zinc-500 dark:text-zinc-400">Method:</span>

View file

@ -1,4 +1,4 @@
<x-layouts::mcp>
<x-layouts.mcp>
<x-slot:title>{{ $server['name'] }}</x-slot:title>
<x-slot:description>{{ $server['tagline'] ?? $server['description'] ?? '' }}</x-slot:description>
@ -29,15 +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
@case('eaas')
<flux:icon.shield-check class="w-8 h-8 text-cyan-600 dark:text-cyan-400" />
@break
@case('upstream')
<flux:icon.arrows-up-down class="w-8 h-8 text-cyan-600 dark:text-cyan-400" />
@break
@ -102,28 +96,18 @@
</div>
<!-- Connection -->
@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>
@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
<!-- Tools -->
@if(!empty($server['tools']))
@ -240,4 +224,4 @@
View Usage Analytics
</a>
</div>
</x-layouts::mcp>
</x-layouts.mcp>

View file

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

View file

@ -13,7 +13,7 @@ use Core\Mcp\Services\McpMetricsService;
*
* Displays analytics and metrics for MCP tool usage.
*/
#[Layout('layouts::mcp')]
#[Layout('components.layouts.mcp')]
class McpMetrics extends Component
{
public int $days = 7;

View file

@ -18,7 +18,7 @@ use Symfony\Component\Yaml\Yaml;
* A browser-based UI for testing MCP tool calls.
* Allows users to select a server, pick a tool, and execute it with custom parameters.
*/
#[Layout('layouts::mcp')]
#[Layout('components.layouts.mcp')]
class McpPlayground extends Component
{
public string $selectedServer = '';

View file

@ -14,7 +14,7 @@ use Symfony\Component\Yaml\Yaml;
/**
* MCP Playground - interactive tool testing in the browser.
*/
#[Layout('layouts::mcp')]
#[Layout('components.layouts.mcp')]
class Playground extends Component
{
public string $selectedServer = '';

View file

@ -12,7 +12,7 @@ use Core\Mcp\Models\McpApiRequest;
/**
* MCP Request Log - view and replay API requests.
*/
#[Layout('layouts::mcp')]
#[Layout('components.layouts.mcp')]
class RequestLog extends Component
{
use WithPagination;

View file

@ -15,7 +15,7 @@ use Livewire\Component;
* Single search interface across all system components:
* MCP tools, API endpoints, patterns, assets, todos, and plans.
*/
#[Layout('layouts::mcp')]
#[Layout('components.layouts.mcp')]
class UnifiedSearch extends Component
{
public string $query = '';

View file

@ -0,0 +1,793 @@
<?php
/*
* Core MCP Package
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
use Core\Mcp\Services\ToolRegistry;
use Illuminate\Support\Facades\Cache;
/*
|--------------------------------------------------------------------------
| Helper: build a ToolRegistry partial mock
|--------------------------------------------------------------------------
|
| The registry loads YAML from disk via protected methods. We mock those
| methods so we can inject deterministic data without touching the
| filesystem. Cache::shouldReceive is used to bypass the cache layer.
|
*/
function makeRegistry(array $registry = [], array $servers = []): ToolRegistry
{
$mock = Mockery::mock(ToolRegistry::class)->makePartial()->shouldAllowMockingProtectedMethods();
$mock->shouldReceive('loadRegistry')->andReturn($registry);
foreach ($servers as $id => $config) {
$mock->shouldReceive('loadServerFull')->with($id)->andReturn($config);
}
// Default: unknown server IDs return null
$mock->shouldReceive('loadServerFull')->andReturn(null)->byDefault();
return $mock;
}
/*
|--------------------------------------------------------------------------
| Fixture data
|--------------------------------------------------------------------------
*/
function sampleRegistry(): array
{
return [
'servers' => [
['id' => 'workspace-tools'],
['id' => 'billing-tools'],
],
];
}
function sampleServer(string $id = 'workspace-tools'): array
{
return match ($id) {
'workspace-tools' => [
'id' => 'workspace-tools',
'name' => 'Workspace Tools',
'tagline' => 'Database and system utilities',
'tools' => [
[
'name' => 'query_database',
'description' => 'Execute a read-only SQL SELECT query',
'category' => 'database',
'inputSchema' => [
'type' => 'object',
'properties' => [
'query' => ['type' => 'string'],
'explain' => ['type' => 'boolean', 'default' => false],
],
],
],
[
'name' => 'list_tables',
'description' => 'List all database tables',
'category' => 'database',
'inputSchema' => ['type' => 'object', 'properties' => []],
],
[
'name' => 'list_routes',
'description' => 'List application routes',
'category' => 'system',
'inputSchema' => ['type' => 'object', 'properties' => []],
],
],
],
'billing-tools' => [
'id' => 'billing-tools',
'name' => 'Billing Tools',
'tagline' => 'Invoice and subscription management',
'tools' => [
[
'name' => 'list_invoices',
'description' => 'List invoices by status',
'category' => 'commerce',
'inputSchema' => [
'type' => 'object',
'properties' => [
'status' => ['type' => 'string', 'enum' => ['paid', 'unpaid', 'overdue']],
'limit' => ['type' => 'integer', 'default' => 25],
],
],
],
[
'name' => 'get_billing_status',
'description' => 'Get current billing status',
'inputSchema' => ['type' => 'object', 'properties' => []],
],
],
],
default => [],
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ToolRegistry', function () {
beforeEach(function () {
// Ensure cache is bypassed for every test — return null so the
// callback inside Cache::remember always executes.
Cache::shouldReceive('remember')->andReturnUsing(
fn (string $key, int $ttl, Closure $callback) => $callback()
);
Cache::shouldReceive('forget')->andReturn(true);
});
// -----------------------------------------------------------------------
// getServers()
// -----------------------------------------------------------------------
describe('getServers', function () {
it('returns a collection of server summaries', function () {
$registry = makeRegistry(
sampleRegistry(),
[
'workspace-tools' => sampleServer('workspace-tools'),
'billing-tools' => sampleServer('billing-tools'),
]
);
$servers = $registry->getServers();
expect($servers)->toHaveCount(2);
expect($servers[0]['id'])->toBe('workspace-tools');
expect($servers[0]['name'])->toBe('Workspace Tools');
expect($servers[0]['tagline'])->toBe('Database and system utilities');
expect($servers[0]['tool_count'])->toBe(3);
expect($servers[1]['id'])->toBe('billing-tools');
expect($servers[1]['tool_count'])->toBe(2);
});
it('returns empty collection when no servers are registered', function () {
$registry = makeRegistry(['servers' => []], []);
$servers = $registry->getServers();
expect($servers)->toBeEmpty();
});
it('returns empty collection when registry has no servers key', function () {
$registry = makeRegistry([], []);
$servers = $registry->getServers();
expect($servers)->toBeEmpty();
});
it('filters out servers whose YAML file is missing', function () {
// billing-tools has no corresponding server config
$registry = makeRegistry(
sampleRegistry(),
['workspace-tools' => sampleServer('workspace-tools')]
);
// loadServerFull for billing-tools will return null (default)
$servers = $registry->getServers();
expect($servers)->toHaveCount(1);
expect($servers[0]['id'])->toBe('workspace-tools');
});
});
// -----------------------------------------------------------------------
// getToolsForServer()
// -----------------------------------------------------------------------
describe('getToolsForServer', function () {
it('returns tools with expected shape', function () {
$registry = makeRegistry(
sampleRegistry(),
['workspace-tools' => sampleServer('workspace-tools')]
);
$tools = $registry->getToolsForServer('workspace-tools');
expect($tools)->toHaveCount(3);
$first = $tools[0];
expect($first)->toHaveKeys(['name', 'description', 'category', 'inputSchema', 'examples', 'version']);
expect($first['name'])->toBe('query_database');
expect($first['description'])->toBe('Execute a read-only SQL SELECT query');
});
it('returns built-in examples for known tools', function () {
$registry = makeRegistry(
sampleRegistry(),
['workspace-tools' => sampleServer('workspace-tools')]
);
$tools = $registry->getToolsForServer('workspace-tools');
$queryDb = $tools->firstWhere('name', 'query_database');
// query_database has predefined examples in the registry
expect($queryDb['examples'])->toBe([
'query' => 'SELECT id, name FROM users LIMIT 10',
]);
});
it('generates examples from schema when no built-in examples exist', function () {
$serverConfig = [
'id' => 'custom-server',
'name' => 'Custom',
'tagline' => '',
'tools' => [
[
'name' => 'custom_tool',
'description' => 'A custom tool',
'inputSchema' => [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string', 'default' => 'hello'],
'count' => ['type' => 'integer', 'minimum' => 5],
'active' => ['type' => 'boolean'],
'tags' => ['type' => 'array'],
],
],
],
],
];
$registry = makeRegistry(
['servers' => [['id' => 'custom-server']]],
['custom-server' => $serverConfig]
);
$tools = $registry->getToolsForServer('custom-server');
$examples = $tools[0]['examples'];
expect($examples['name'])->toBe('hello'); // default
expect($examples['count'])->toBe(5); // minimum
expect($examples['active'])->toBeFalse(); // boolean default
expect($examples['tags'])->toBe([]); // array default
});
it('returns empty collection for unknown server', function () {
$registry = makeRegistry(sampleRegistry(), []);
$tools = $registry->getToolsForServer('nonexistent');
expect($tools)->toBeEmpty();
});
it('extracts category from tool definition', function () {
$registry = makeRegistry(
sampleRegistry(),
['workspace-tools' => sampleServer('workspace-tools')]
);
$tools = $registry->getToolsForServer('workspace-tools');
expect($tools[0]['category'])->toBe('Database');
expect($tools[2]['category'])->toBe('System');
});
it('uses purpose field as description fallback', function () {
$serverConfig = [
'id' => 'test-server',
'name' => 'Test',
'tagline' => '',
'tools' => [
[
'name' => 'my_tool',
'purpose' => 'A fallback description',
'inputSchema' => ['type' => 'object', 'properties' => []],
],
],
];
$registry = makeRegistry(
['servers' => [['id' => 'test-server']]],
['test-server' => $serverConfig]
);
$tools = $registry->getToolsForServer('test-server');
expect($tools[0]['description'])->toBe('A fallback description');
});
it('builds inputSchema from parameters when inputSchema is absent', function () {
$serverConfig = [
'id' => 'test-server',
'name' => 'Test',
'tagline' => '',
'tools' => [
[
'name' => 'legacy_tool',
'description' => 'Uses legacy parameters key',
'parameters' => [
'query' => ['type' => 'string'],
],
],
],
];
$registry = makeRegistry(
['servers' => [['id' => 'test-server']]],
['test-server' => $serverConfig]
);
$tools = $registry->getToolsForServer('test-server');
expect($tools[0]['inputSchema'])->toBe([
'type' => 'object',
'properties' => ['query' => ['type' => 'string']],
]);
});
});
// -----------------------------------------------------------------------
// getTool() — single tool lookup
// -----------------------------------------------------------------------
describe('getTool', function () {
it('returns a single tool by name', function () {
$registry = makeRegistry(
sampleRegistry(),
['workspace-tools' => sampleServer('workspace-tools')]
);
$tool = $registry->getTool('workspace-tools', 'list_tables');
expect($tool)->not->toBeNull();
expect($tool['name'])->toBe('list_tables');
expect($tool['description'])->toBe('List all database tables');
});
it('returns null for unknown tool name', function () {
$registry = makeRegistry(
sampleRegistry(),
['workspace-tools' => sampleServer('workspace-tools')]
);
$tool = $registry->getTool('workspace-tools', 'does_not_exist');
expect($tool)->toBeNull();
});
it('returns null when server does not exist', function () {
$registry = makeRegistry(sampleRegistry(), []);
$tool = $registry->getTool('nonexistent', 'query_database');
expect($tool)->toBeNull();
});
});
// -----------------------------------------------------------------------
// getToolsByCategory()
// -----------------------------------------------------------------------
describe('getToolsByCategory', function () {
it('groups tools by their category', function () {
$registry = makeRegistry(
sampleRegistry(),
['workspace-tools' => sampleServer('workspace-tools')]
);
$categories = $registry->getToolsByCategory('workspace-tools');
expect($categories)->toHaveKey('Database');
expect($categories)->toHaveKey('System');
expect($categories['Database'])->toHaveCount(2);
expect($categories['System'])->toHaveCount(1);
});
it('returns empty collection for unknown server', function () {
$registry = makeRegistry(sampleRegistry(), []);
$categories = $registry->getToolsByCategory('nonexistent');
expect($categories)->toBeEmpty();
});
});
// -----------------------------------------------------------------------
// searchTools()
// -----------------------------------------------------------------------
describe('searchTools', function () {
it('finds tools by name substring', function () {
$registry = makeRegistry(
sampleRegistry(),
['workspace-tools' => sampleServer('workspace-tools')]
);
$results = $registry->searchTools('workspace-tools', 'list');
expect($results)->toHaveCount(2);
expect($results->pluck('name')->toArray())->toContain('list_tables', 'list_routes');
});
it('finds tools by description substring', function () {
$registry = makeRegistry(
sampleRegistry(),
['workspace-tools' => sampleServer('workspace-tools')]
);
$results = $registry->searchTools('workspace-tools', 'SQL');
expect($results)->toHaveCount(1);
expect($results[0]['name'])->toBe('query_database');
});
it('finds tools by category substring', function () {
$registry = makeRegistry(
sampleRegistry(),
['workspace-tools' => sampleServer('workspace-tools')]
);
$results = $registry->searchTools('workspace-tools', 'system');
expect($results)->toHaveCount(1);
expect($results[0]['name'])->toBe('list_routes');
});
it('is case insensitive', function () {
$registry = makeRegistry(
sampleRegistry(),
['workspace-tools' => sampleServer('workspace-tools')]
);
$results = $registry->searchTools('workspace-tools', 'QUERY');
expect($results)->toHaveCount(1);
expect($results[0]['name'])->toBe('query_database');
});
it('returns all tools when query is empty', function () {
$registry = makeRegistry(
sampleRegistry(),
['workspace-tools' => sampleServer('workspace-tools')]
);
$results = $registry->searchTools('workspace-tools', '');
expect($results)->toHaveCount(3);
});
it('returns all tools when query is whitespace', function () {
$registry = makeRegistry(
sampleRegistry(),
['workspace-tools' => sampleServer('workspace-tools')]
);
$results = $registry->searchTools('workspace-tools', ' ');
expect($results)->toHaveCount(3);
});
it('returns empty collection for no matches', function () {
$registry = makeRegistry(
sampleRegistry(),
['workspace-tools' => sampleServer('workspace-tools')]
);
$results = $registry->searchTools('workspace-tools', 'zzz_nonexistent');
expect($results)->toBeEmpty();
});
});
// -----------------------------------------------------------------------
// Example input management
// -----------------------------------------------------------------------
describe('example inputs', function () {
it('returns predefined examples for known tools', function () {
$registry = new ToolRegistry();
expect($registry->getExampleInputs('query_database'))->toBe([
'query' => 'SELECT id, name FROM users LIMIT 10',
]);
expect($registry->getExampleInputs('create_coupon'))->toBe([
'code' => 'SUMMER25',
'discount_type' => 'percentage',
'discount_value' => 25,
'expires_at' => '2025-12-31',
]);
});
it('returns empty array for unknown tools', function () {
$registry = new ToolRegistry();
expect($registry->getExampleInputs('totally_unknown'))->toBe([]);
});
it('allows setting custom examples', function () {
$registry = new ToolRegistry();
$registry->setExampleInputs('my_tool', ['foo' => 'bar', 'count' => 42]);
expect($registry->getExampleInputs('my_tool'))->toBe(['foo' => 'bar', 'count' => 42]);
});
it('overwrites existing examples', function () {
$registry = new ToolRegistry();
$registry->setExampleInputs('query_database', ['query' => 'SELECT 1']);
expect($registry->getExampleInputs('query_database'))->toBe(['query' => 'SELECT 1']);
});
});
// -----------------------------------------------------------------------
// getServerFull()
// -----------------------------------------------------------------------
describe('getServerFull', function () {
it('returns full server configuration', function () {
$registry = makeRegistry(
sampleRegistry(),
['workspace-tools' => sampleServer('workspace-tools')]
);
$server = $registry->getServerFull('workspace-tools');
expect($server)->not->toBeNull();
expect($server['id'])->toBe('workspace-tools');
expect($server['tools'])->toHaveCount(3);
});
it('returns null for unknown server', function () {
$registry = makeRegistry(sampleRegistry(), []);
$server = $registry->getServerFull('nonexistent');
expect($server)->toBeNull();
});
});
// -----------------------------------------------------------------------
// Category inference
// -----------------------------------------------------------------------
describe('category extraction', function () {
it('uses explicit category when provided', function () {
$serverConfig = [
'id' => 'cat-server',
'name' => 'Cat Server',
'tagline' => '',
'tools' => [
[
'name' => 'some_tool',
'description' => 'A tool',
'category' => 'analytics',
'inputSchema' => ['type' => 'object', 'properties' => []],
],
],
];
$registry = makeRegistry(
['servers' => [['id' => 'cat-server']]],
['cat-server' => $serverConfig]
);
$tools = $registry->getToolsForServer('cat-server');
expect($tools[0]['category'])->toBe('Analytics');
});
it('infers commerce category from tool name', function () {
$serverConfig = [
'id' => 'cat-server',
'name' => 'Cat Server',
'tagline' => '',
'tools' => [
[
'name' => 'create_invoice',
'description' => 'Create an invoice',
'inputSchema' => ['type' => 'object', 'properties' => []],
],
],
];
$registry = makeRegistry(
['servers' => [['id' => 'cat-server']]],
['cat-server' => $serverConfig]
);
$tools = $registry->getToolsForServer('cat-server');
expect($tools[0]['category'])->toBe('Commerce');
});
it('infers query category from tool name', function () {
$serverConfig = [
'id' => 'cat-server',
'name' => 'Cat Server',
'tagline' => '',
'tools' => [
[
'name' => 'search_users',
'description' => 'Search users',
'inputSchema' => ['type' => 'object', 'properties' => []],
],
],
];
$registry = makeRegistry(
['servers' => [['id' => 'cat-server']]],
['cat-server' => $serverConfig]
);
$tools = $registry->getToolsForServer('cat-server');
expect($tools[0]['category'])->toBe('Query');
});
it('falls back to General for unrecognised tool names', function () {
$serverConfig = [
'id' => 'cat-server',
'name' => 'Cat Server',
'tagline' => '',
'tools' => [
[
'name' => 'do_something_unique',
'description' => 'A unique tool',
'inputSchema' => ['type' => 'object', 'properties' => []],
],
],
];
$registry = makeRegistry(
['servers' => [['id' => 'cat-server']]],
['cat-server' => $serverConfig]
);
$tools = $registry->getToolsForServer('cat-server');
expect($tools[0]['category'])->toBe('General');
});
});
// -----------------------------------------------------------------------
// Schema-based example generation
// -----------------------------------------------------------------------
describe('generateExampleFromSchema', function () {
it('uses enum first value', function () {
$serverConfig = [
'id' => 'gen-server',
'name' => 'Gen',
'tagline' => '',
'tools' => [
[
'name' => 'enum_tool',
'description' => 'Tool with enum',
'inputSchema' => [
'type' => 'object',
'properties' => [
'status' => ['type' => 'string', 'enum' => ['active', 'inactive']],
],
],
],
],
];
$registry = makeRegistry(
['servers' => [['id' => 'gen-server']]],
['gen-server' => $serverConfig]
);
$tools = $registry->getToolsForServer('gen-server');
expect($tools[0]['examples']['status'])->toBe('active');
});
it('uses example property from schema', function () {
$serverConfig = [
'id' => 'gen-server',
'name' => 'Gen',
'tagline' => '',
'tools' => [
[
'name' => 'example_tool',
'description' => 'Tool with example values',
'inputSchema' => [
'type' => 'object',
'properties' => [
'email' => ['type' => 'string', 'example' => 'user@example.com'],
],
],
],
],
];
$registry = makeRegistry(
['servers' => [['id' => 'gen-server']]],
['gen-server' => $serverConfig]
);
$tools = $registry->getToolsForServer('gen-server');
expect($tools[0]['examples']['email'])->toBe('user@example.com');
});
it('generates type-appropriate defaults for properties without hints', function () {
$serverConfig = [
'id' => 'gen-server',
'name' => 'Gen',
'tagline' => '',
'tools' => [
[
'name' => 'typed_tool',
'description' => 'Tool with various types',
'inputSchema' => [
'type' => 'object',
'properties' => [
'label' => ['type' => 'string'],
'count' => ['type' => 'integer'],
'amount' => ['type' => 'number'],
'active' => ['type' => 'boolean'],
'items' => ['type' => 'array'],
'meta' => ['type' => 'object'],
],
],
],
],
];
$registry = makeRegistry(
['servers' => [['id' => 'gen-server']]],
['gen-server' => $serverConfig]
);
$tools = $registry->getToolsForServer('gen-server');
$examples = $tools[0]['examples'];
expect($examples['label'])->toBe('');
expect($examples['count'])->toBe(0);
expect($examples['amount'])->toBe(0);
expect($examples['active'])->toBeFalse();
expect($examples['items'])->toBe([]);
expect($examples['meta'])->toBeInstanceOf(stdClass::class);
});
});
// -----------------------------------------------------------------------
// clearCache()
// -----------------------------------------------------------------------
describe('clearCache', function () {
it('calls Cache::forget for server keys', function () {
$registry = makeRegistry(
sampleRegistry(),
[
'workspace-tools' => sampleServer('workspace-tools'),
'billing-tools' => sampleServer('billing-tools'),
]
);
// Pre-load servers so clearCache knows which keys to forget
$registry->getServers();
// clearCache should call forget on the main key and each server key
Cache::shouldReceive('forget')->with('mcp:playground:servers')->once()->andReturn(true);
Cache::shouldReceive('forget')->with('mcp:playground:tools:workspace-tools')->once()->andReturn(true);
Cache::shouldReceive('forget')->with('mcp:playground:tools:billing-tools')->once()->andReturn(true);
$registry->clearCache();
});
});
});