Compare commits
2 commits
main
...
feat/test-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f11d7d435 | ||
|
|
fa867cfa7d |
39 changed files with 1635 additions and 1192 deletions
|
|
@ -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
|
||||
|
|
@ -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}"
|
||||
236
FINDINGS.md
236
FINDINGS.md
|
|
@ -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.
|
||||
|
|
@ -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
53
TODO.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
← {{ $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">
|
||||
© {{ 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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
727
src/Mcp/Tests/Unit/AuditLogServiceTest.php
Normal file
727
src/Mcp/Tests/Unit/AuditLogServiceTest.php
Normal file
|
|
@ -0,0 +1,727 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Unit: Audit Log Service
|
||||
*
|
||||
* Tests for MCP audit log service covering:
|
||||
* - record() method and log entry creation
|
||||
* - Security audit fields (actor, IP, sensitivity)
|
||||
* - Sensitive tool registration and field redaction
|
||||
* - Hash chain integrity and tamper detection
|
||||
* - Query/retrieval via export and stats
|
||||
* - Consent requirements
|
||||
*
|
||||
* @see https://forge.lthn.ai/core/php-mcp/issues/5
|
||||
*/
|
||||
|
||||
use Core\Mcp\Models\McpAuditLog;
|
||||
use Core\Mcp\Models\McpSensitiveTool;
|
||||
use Core\Mcp\Services\AuditLogService;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
// =============================================================================
|
||||
// record() — Basic Log Entry Creation
|
||||
// =============================================================================
|
||||
|
||||
describe('record() creates audit log entries', function () {
|
||||
beforeEach(function () {
|
||||
$this->service = new AuditLogService();
|
||||
});
|
||||
|
||||
it('creates an audit log entry with required fields', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'query_database',
|
||||
);
|
||||
|
||||
expect($entry)->toBeInstanceOf(McpAuditLog::class);
|
||||
expect($entry->exists)->toBeTrue();
|
||||
expect($entry->server_id)->toBe('mcp-server-1');
|
||||
expect($entry->tool_name)->toBe('query_database');
|
||||
expect($entry->success)->toBeTrue();
|
||||
});
|
||||
|
||||
it('stores input parameters as JSON', function () {
|
||||
$params = ['query' => 'SELECT * FROM users', 'limit' => 10];
|
||||
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'query_database',
|
||||
inputParams: $params,
|
||||
);
|
||||
|
||||
expect($entry->input_params)->toBe($params);
|
||||
});
|
||||
|
||||
it('stores output summary', function () {
|
||||
$output = ['rows_returned' => 5, 'columns' => ['id', 'name']];
|
||||
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'query_database',
|
||||
outputSummary: $output,
|
||||
);
|
||||
|
||||
expect($entry->output_summary)->toBe($output);
|
||||
});
|
||||
|
||||
it('records a failed execution', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'query_database',
|
||||
success: false,
|
||||
errorCode: 'QUERY_BLOCKED',
|
||||
errorMessage: 'INSERT statements are not allowed',
|
||||
);
|
||||
|
||||
expect($entry->success)->toBeFalse();
|
||||
expect($entry->error_code)->toBe('QUERY_BLOCKED');
|
||||
expect($entry->error_message)->toBe('INSERT statements are not allowed');
|
||||
});
|
||||
|
||||
it('records execution duration', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'query_database',
|
||||
durationMs: 250,
|
||||
);
|
||||
|
||||
expect($entry->duration_ms)->toBe(250);
|
||||
});
|
||||
|
||||
it('records session and workspace context', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'query_database',
|
||||
sessionId: 'session-abc-123',
|
||||
workspaceId: null,
|
||||
);
|
||||
|
||||
expect($entry->session_id)->toBe('session-abc-123');
|
||||
});
|
||||
|
||||
it('records agent type and plan slug', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'deploy_service',
|
||||
agentType: 'charon',
|
||||
planSlug: 'deploy-api-v2',
|
||||
);
|
||||
|
||||
expect($entry->agent_type)->toBe('charon');
|
||||
expect($entry->plan_slug)->toBe('deploy-api-v2');
|
||||
});
|
||||
|
||||
it('persists entries to the database', function () {
|
||||
$this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'tool_a',
|
||||
);
|
||||
|
||||
$this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'tool_b',
|
||||
);
|
||||
|
||||
expect(McpAuditLog::count())->toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Security Audit Fields (Actor, IP, Sensitivity)
|
||||
// =============================================================================
|
||||
|
||||
describe('Security audit fields', function () {
|
||||
beforeEach(function () {
|
||||
$this->service = new AuditLogService();
|
||||
});
|
||||
|
||||
it('records actor type and actor ID for user actors', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'query_database',
|
||||
actorType: McpAuditLog::ACTOR_USER,
|
||||
actorId: 42,
|
||||
);
|
||||
|
||||
expect($entry->actor_type)->toBe('user');
|
||||
expect($entry->actor_id)->toBe(42);
|
||||
});
|
||||
|
||||
it('records actor type for API key actors', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'query_database',
|
||||
actorType: McpAuditLog::ACTOR_API_KEY,
|
||||
actorId: 7,
|
||||
);
|
||||
|
||||
expect($entry->actor_type)->toBe('api_key');
|
||||
expect($entry->actor_id)->toBe(7);
|
||||
});
|
||||
|
||||
it('records actor type for system actors', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'system_cleanup',
|
||||
actorType: McpAuditLog::ACTOR_SYSTEM,
|
||||
);
|
||||
|
||||
expect($entry->actor_type)->toBe('system');
|
||||
});
|
||||
|
||||
it('records actor IP address', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'query_database',
|
||||
actorType: McpAuditLog::ACTOR_USER,
|
||||
actorId: 1,
|
||||
actorIp: '192.168.1.100',
|
||||
);
|
||||
|
||||
expect($entry->actor_ip)->toBe('192.168.1.100');
|
||||
});
|
||||
|
||||
it('records IPv6 actor IP address', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'query_database',
|
||||
actorIp: '2001:0db8:85a3:0000:0000:8a2e:0370:7334',
|
||||
);
|
||||
|
||||
expect($entry->actor_ip)->toBe('2001:0db8:85a3:0000:0000:8a2e:0370:7334');
|
||||
});
|
||||
|
||||
it('marks non-sensitive tools as not sensitive', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'list_files',
|
||||
);
|
||||
|
||||
expect($entry->is_sensitive)->toBeFalse();
|
||||
expect($entry->sensitivity_reason)->toBeNull();
|
||||
});
|
||||
|
||||
it('marks registered sensitive tools as sensitive', function () {
|
||||
McpSensitiveTool::register(
|
||||
'database_admin',
|
||||
'Administrative database access',
|
||||
['connection_string'],
|
||||
);
|
||||
|
||||
Cache::forget('mcp:audit:sensitive_tools');
|
||||
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'database_admin',
|
||||
);
|
||||
|
||||
expect($entry->is_sensitive)->toBeTrue();
|
||||
expect($entry->sensitivity_reason)->toBe('Administrative database access');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Sensitive Tool Registration and Field Redaction
|
||||
// =============================================================================
|
||||
|
||||
describe('Sensitive tool registration', function () {
|
||||
beforeEach(function () {
|
||||
$this->service = new AuditLogService();
|
||||
});
|
||||
|
||||
it('registers a sensitive tool', function () {
|
||||
$this->service->registerSensitiveTool(
|
||||
toolName: 'payment_process',
|
||||
reason: 'Handles financial transactions',
|
||||
redactFields: ['card_number', 'cvv'],
|
||||
requireConsent: true,
|
||||
);
|
||||
|
||||
$tool = McpSensitiveTool::where('tool_name', 'payment_process')->first();
|
||||
|
||||
expect($tool)->not->toBeNull();
|
||||
expect($tool->reason)->toBe('Handles financial transactions');
|
||||
expect($tool->redact_fields)->toBe(['card_number', 'cvv']);
|
||||
expect($tool->require_explicit_consent)->toBeTrue();
|
||||
});
|
||||
|
||||
it('unregisters a sensitive tool', function () {
|
||||
$this->service->registerSensitiveTool(
|
||||
'temp_tool',
|
||||
'Temporary sensitive tool',
|
||||
);
|
||||
|
||||
$result = $this->service->unregisterSensitiveTool('temp_tool');
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
expect(McpSensitiveTool::where('tool_name', 'temp_tool')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false when unregistering non-existent tool', function () {
|
||||
$result = $this->service->unregisterSensitiveTool('nonexistent_tool');
|
||||
|
||||
expect($result)->toBeFalse();
|
||||
});
|
||||
|
||||
it('lists all registered sensitive tools', function () {
|
||||
$this->service->registerSensitiveTool('tool_a', 'Reason A');
|
||||
$this->service->registerSensitiveTool('tool_b', 'Reason B');
|
||||
|
||||
$tools = $this->service->getSensitiveTools();
|
||||
|
||||
expect($tools)->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('clears cache when registering a sensitive tool', function () {
|
||||
Cache::put('mcp:audit:sensitive_tools', ['stale' => 'data'], 300);
|
||||
|
||||
$this->service->registerSensitiveTool('new_tool', 'New reason');
|
||||
|
||||
expect(Cache::has('mcp:audit:sensitive_tools'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('clears cache when unregistering a sensitive tool', function () {
|
||||
$this->service->registerSensitiveTool('cached_tool', 'Cached reason');
|
||||
Cache::put('mcp:audit:sensitive_tools', ['stale' => 'data'], 300);
|
||||
|
||||
$this->service->unregisterSensitiveTool('cached_tool');
|
||||
|
||||
expect(Cache::has('mcp:audit:sensitive_tools'))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Field redaction', function () {
|
||||
beforeEach(function () {
|
||||
$this->service = new AuditLogService();
|
||||
});
|
||||
|
||||
it('redacts default sensitive fields from input params', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'api_call',
|
||||
inputParams: [
|
||||
'url' => 'https://api.example.com',
|
||||
'password' => 'super-secret-123',
|
||||
'api_key' => 'sk-abc123',
|
||||
],
|
||||
);
|
||||
|
||||
expect($entry->input_params['url'])->toBe('https://api.example.com');
|
||||
expect($entry->input_params['password'])->toBe('[REDACTED]');
|
||||
expect($entry->input_params['api_key'])->toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts default sensitive fields from output summary', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'auth_check',
|
||||
outputSummary: [
|
||||
'status' => 'authenticated',
|
||||
'access_token' => 'eyJhbG...',
|
||||
'refresh_token' => 'dGhpcyBp...',
|
||||
],
|
||||
);
|
||||
|
||||
expect($entry->output_summary['status'])->toBe('authenticated');
|
||||
expect($entry->output_summary['access_token'])->toBe('[REDACTED]');
|
||||
expect($entry->output_summary['refresh_token'])->toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts nested sensitive fields recursively', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'config_read',
|
||||
inputParams: [
|
||||
'config' => [
|
||||
'database' => [
|
||||
'host' => 'localhost',
|
||||
'password' => 'db-secret',
|
||||
],
|
||||
'api' => [
|
||||
'token' => 'bearer-xyz',
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
expect($entry->input_params['config']['database']['host'])->toBe('localhost');
|
||||
expect($entry->input_params['config']['database']['password'])->toBe('[REDACTED]');
|
||||
expect($entry->input_params['config']['api']['token'])->toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts additional tool-specific fields for sensitive tools', function () {
|
||||
McpSensitiveTool::register(
|
||||
'payment_process',
|
||||
'Handles payments',
|
||||
['merchant_id', 'routing_number'],
|
||||
);
|
||||
|
||||
Cache::forget('mcp:audit:sensitive_tools');
|
||||
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'payment_process',
|
||||
inputParams: [
|
||||
'amount' => 99.99,
|
||||
'merchant_id' => 'MERCH-001',
|
||||
'routing_number' => '123456789',
|
||||
'password' => 'also-redacted',
|
||||
],
|
||||
);
|
||||
|
||||
expect($entry->input_params['amount'])->toBe(99.99);
|
||||
expect($entry->input_params['merchant_id'])->toBe('[REDACTED]');
|
||||
expect($entry->input_params['routing_number'])->toBe('[REDACTED]');
|
||||
expect($entry->input_params['password'])->toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts fields using case-insensitive matching', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'mixed_case_tool',
|
||||
inputParams: [
|
||||
'Password' => 'secret1',
|
||||
'API_KEY' => 'secret2',
|
||||
'apiKey' => 'secret3',
|
||||
'CreditCard' => '4111111111111111',
|
||||
],
|
||||
);
|
||||
|
||||
expect($entry->input_params['Password'])->toBe('[REDACTED]');
|
||||
expect($entry->input_params['API_KEY'])->toBe('[REDACTED]');
|
||||
expect($entry->input_params['apiKey'])->toBe('[REDACTED]');
|
||||
expect($entry->input_params['CreditCard'])->toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
it('does not redact non-sensitive fields', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'safe_tool',
|
||||
inputParams: [
|
||||
'name' => 'John',
|
||||
'email' => 'john@example.com',
|
||||
'query' => 'SELECT 1',
|
||||
],
|
||||
);
|
||||
|
||||
expect($entry->input_params['name'])->toBe('John');
|
||||
expect($entry->input_params['email'])->toBe('john@example.com');
|
||||
expect($entry->input_params['query'])->toBe('SELECT 1');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Hash Chain Integrity
|
||||
// =============================================================================
|
||||
|
||||
describe('Hash chain integrity', function () {
|
||||
beforeEach(function () {
|
||||
$this->service = new AuditLogService();
|
||||
});
|
||||
|
||||
it('generates an entry hash for each record', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'query_database',
|
||||
);
|
||||
|
||||
expect($entry->entry_hash)->not->toBeNull();
|
||||
expect($entry->entry_hash)->toHaveLength(64); // SHA-256 hex
|
||||
});
|
||||
|
||||
it('first entry has null previous_hash', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'query_database',
|
||||
);
|
||||
|
||||
expect($entry->previous_hash)->toBeNull();
|
||||
});
|
||||
|
||||
it('links entries via previous_hash chain', function () {
|
||||
$first = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'tool_a',
|
||||
);
|
||||
|
||||
$second = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'tool_b',
|
||||
);
|
||||
|
||||
expect($second->previous_hash)->toBe($first->entry_hash);
|
||||
});
|
||||
|
||||
it('builds a chain across multiple entries', function () {
|
||||
$entries = [];
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$entries[] = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: "tool_{$i}",
|
||||
);
|
||||
}
|
||||
|
||||
for ($i = 1; $i < 5; $i++) {
|
||||
expect($entries[$i]->previous_hash)->toBe($entries[$i - 1]->entry_hash);
|
||||
}
|
||||
});
|
||||
|
||||
it('produces a valid hash that can be verified', function () {
|
||||
$entry = $this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'query_database',
|
||||
inputParams: ['query' => 'SELECT 1'],
|
||||
durationMs: 50,
|
||||
);
|
||||
|
||||
expect($entry->verifyHash())->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Chain Verification
|
||||
// =============================================================================
|
||||
|
||||
describe('verifyChain()', function () {
|
||||
beforeEach(function () {
|
||||
$this->service = new AuditLogService();
|
||||
});
|
||||
|
||||
it('reports valid chain for properly linked entries', function () {
|
||||
$this->service->record('mcp-server-1', 'tool_a');
|
||||
$this->service->record('mcp-server-1', 'tool_b');
|
||||
$this->service->record('mcp-server-1', 'tool_c');
|
||||
|
||||
$result = $this->service->verifyChain();
|
||||
|
||||
expect($result['valid'])->toBeTrue();
|
||||
expect($result['total'])->toBe(3);
|
||||
expect($result['verified'])->toBe(3);
|
||||
expect($result['issues'])->toBeEmpty();
|
||||
});
|
||||
|
||||
it('supports verifying a subset of the chain by ID range', function () {
|
||||
$first = $this->service->record('mcp-server-1', 'tool_a');
|
||||
$second = $this->service->record('mcp-server-1', 'tool_b');
|
||||
$third = $this->service->record('mcp-server-1', 'tool_c');
|
||||
|
||||
$result = $this->service->verifyChain(fromId: $second->id, toId: $third->id);
|
||||
|
||||
expect($result['valid'])->toBeTrue();
|
||||
expect($result['total'])->toBe(2);
|
||||
expect($result['verified'])->toBe(2);
|
||||
});
|
||||
|
||||
it('reports valid for empty audit log', function () {
|
||||
$result = $this->service->verifyChain();
|
||||
|
||||
expect($result['valid'])->toBeTrue();
|
||||
expect($result['total'])->toBe(0);
|
||||
expect($result['verified'])->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Consent Requirements
|
||||
// =============================================================================
|
||||
|
||||
describe('Consent requirements', function () {
|
||||
beforeEach(function () {
|
||||
$this->service = new AuditLogService();
|
||||
});
|
||||
|
||||
it('returns true for tools requiring consent', function () {
|
||||
$this->service->registerSensitiveTool(
|
||||
'dangerous_tool',
|
||||
'Can modify production data',
|
||||
[],
|
||||
requireConsent: true,
|
||||
);
|
||||
|
||||
expect($this->service->requiresConsent('dangerous_tool'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns false for tools not requiring consent', function () {
|
||||
$this->service->registerSensitiveTool(
|
||||
'monitored_tool',
|
||||
'Just needs logging',
|
||||
[],
|
||||
requireConsent: false,
|
||||
);
|
||||
|
||||
expect($this->service->requiresConsent('monitored_tool'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false for unregistered tools', function () {
|
||||
expect($this->service->requiresConsent('unknown_tool'))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Export and Retrieval
|
||||
// =============================================================================
|
||||
|
||||
describe('export()', function () {
|
||||
beforeEach(function () {
|
||||
$this->service = new AuditLogService();
|
||||
});
|
||||
|
||||
it('exports all entries when no filters given', function () {
|
||||
$this->service->record('mcp-server-1', 'tool_a');
|
||||
$this->service->record('mcp-server-1', 'tool_b');
|
||||
$this->service->record('mcp-server-1', 'tool_c');
|
||||
|
||||
$exported = $this->service->export();
|
||||
|
||||
expect($exported)->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('filters by tool name', function () {
|
||||
$this->service->record('mcp-server-1', 'query_database');
|
||||
$this->service->record('mcp-server-1', 'file_read');
|
||||
$this->service->record('mcp-server-1', 'query_database');
|
||||
|
||||
$exported = $this->service->export(toolName: 'query_database');
|
||||
|
||||
expect($exported)->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('filters sensitive-only entries', function () {
|
||||
McpSensitiveTool::register('secret_tool', 'Top secret');
|
||||
Cache::forget('mcp:audit:sensitive_tools');
|
||||
|
||||
$this->service->record('mcp-server-1', 'secret_tool');
|
||||
$this->service->record('mcp-server-1', 'normal_tool');
|
||||
|
||||
$exported = $this->service->export(sensitiveOnly: true);
|
||||
|
||||
expect($exported)->toHaveCount(1);
|
||||
expect($exported->first()['tool_name'])->toBe('secret_tool');
|
||||
});
|
||||
|
||||
it('returns entries in export array format', function () {
|
||||
$this->service->record(
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'query_database',
|
||||
actorType: 'user',
|
||||
actorId: 5,
|
||||
);
|
||||
|
||||
$exported = $this->service->export();
|
||||
$entry = $exported->first();
|
||||
|
||||
expect($entry)->toHaveKeys([
|
||||
'id', 'timestamp', 'server_id', 'tool_name',
|
||||
'success', 'entry_hash', 'actor_type', 'actor_id',
|
||||
]);
|
||||
expect($entry['server_id'])->toBe('mcp-server-1');
|
||||
expect($entry['tool_name'])->toBe('query_database');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportToJson()', function () {
|
||||
beforeEach(function () {
|
||||
$this->service = new AuditLogService();
|
||||
});
|
||||
|
||||
it('produces valid JSON with integrity metadata', function () {
|
||||
$this->service->record('mcp-server-1', 'tool_a');
|
||||
$this->service->record('mcp-server-1', 'tool_b');
|
||||
|
||||
$json = $this->service->exportToJson();
|
||||
$data = json_decode($json, true);
|
||||
|
||||
expect($data)->toHaveKeys(['exported_at', 'integrity', 'filters', 'entries']);
|
||||
expect($data['integrity']['valid'])->toBeTrue();
|
||||
expect($data['integrity']['total_entries'])->toBe(2);
|
||||
expect($data['entries'])->toHaveCount(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportToCsv()', function () {
|
||||
beforeEach(function () {
|
||||
$this->service = new AuditLogService();
|
||||
});
|
||||
|
||||
it('produces CSV with headers', function () {
|
||||
$this->service->record('mcp-server-1', 'tool_a');
|
||||
|
||||
$csv = $this->service->exportToCsv();
|
||||
|
||||
expect($csv)->toContain('server_id');
|
||||
expect($csv)->toContain('tool_name');
|
||||
expect($csv)->toContain('mcp-server-1');
|
||||
expect($csv)->toContain('tool_a');
|
||||
});
|
||||
|
||||
it('returns empty string when no entries', function () {
|
||||
$csv = $this->service->exportToCsv();
|
||||
|
||||
expect($csv)->toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Statistics
|
||||
// =============================================================================
|
||||
|
||||
describe('getStats()', function () {
|
||||
beforeEach(function () {
|
||||
$this->service = new AuditLogService();
|
||||
});
|
||||
|
||||
it('returns correct totals', function () {
|
||||
$this->service->record('mcp-server-1', 'tool_a', success: true);
|
||||
$this->service->record('mcp-server-1', 'tool_a', success: true);
|
||||
$this->service->record('mcp-server-1', 'tool_a', success: false, errorCode: 'ERR');
|
||||
|
||||
$stats = $this->service->getStats();
|
||||
|
||||
expect($stats['total'])->toBe(3);
|
||||
expect($stats['successful'])->toBe(2);
|
||||
expect($stats['failed'])->toBe(1);
|
||||
expect($stats['success_rate'])->toBe(66.67);
|
||||
});
|
||||
|
||||
it('counts sensitive calls', function () {
|
||||
McpSensitiveTool::register('secret_tool', 'Classified');
|
||||
Cache::forget('mcp:audit:sensitive_tools');
|
||||
|
||||
$this->service->record('mcp-server-1', 'secret_tool');
|
||||
$this->service->record('mcp-server-1', 'normal_tool');
|
||||
|
||||
$stats = $this->service->getStats();
|
||||
|
||||
expect($stats['sensitive_calls'])->toBe(1);
|
||||
});
|
||||
|
||||
it('lists top tools by usage', function () {
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->service->record('mcp-server-1', 'popular_tool');
|
||||
}
|
||||
for ($i = 0; $i < 2; $i++) {
|
||||
$this->service->record('mcp-server-1', 'rare_tool');
|
||||
}
|
||||
|
||||
$stats = $this->service->getStats();
|
||||
|
||||
expect($stats['top_tools'])->toHaveKey('popular_tool');
|
||||
expect($stats['top_tools']['popular_tool'])->toBe(5);
|
||||
expect($stats['top_tools']['rare_tool'])->toBe(2);
|
||||
});
|
||||
|
||||
it('returns zero stats for empty log', function () {
|
||||
$stats = $this->service->getStats();
|
||||
|
||||
expect($stats['total'])->toBe(0);
|
||||
expect($stats['successful'])->toBe(0);
|
||||
expect($stats['failed'])->toBe(0);
|
||||
expect($stats['success_rate'])->toBe(0);
|
||||
expect($stats['sensitive_calls'])->toBe(0);
|
||||
expect($stats['top_tools'])->toBeEmpty();
|
||||
});
|
||||
});
|
||||
556
src/Mcp/Tests/Unit/CircuitBreakerTest.php
Normal file
556
src/Mcp/Tests/Unit/CircuitBreakerTest.php
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mcp\Tests\Unit;
|
||||
|
||||
use Core\Mcp\Exceptions\CircuitOpenException;
|
||||
use Core\Mcp\Services\CircuitBreaker;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CircuitBreakerTest extends TestCase
|
||||
{
|
||||
protected CircuitBreaker $breaker;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->breaker = new CircuitBreaker;
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Closed state (default) - requests pass through
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public function test_default_state_is_closed(): void
|
||||
{
|
||||
$state = $this->breaker->getState('test-service');
|
||||
|
||||
$this->assertSame(CircuitBreaker::STATE_CLOSED, $state);
|
||||
}
|
||||
|
||||
public function test_closed_circuit_passes_operation_through(): void
|
||||
{
|
||||
$result = $this->breaker->call('test-service', fn () => 'success');
|
||||
|
||||
$this->assertSame('success', $result);
|
||||
}
|
||||
|
||||
public function test_closed_circuit_returns_operation_result(): void
|
||||
{
|
||||
$result = $this->breaker->call('test-service', fn () => ['key' => 'value']);
|
||||
|
||||
$this->assertSame(['key' => 'value'], $result);
|
||||
}
|
||||
|
||||
public function test_closed_circuit_propagates_exceptions(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('operation failed');
|
||||
|
||||
$this->breaker->call('test-service', function () {
|
||||
throw new RuntimeException('operation failed');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_closed_circuit_uses_fallback_on_recoverable_error(): void
|
||||
{
|
||||
$result = $this->breaker->call(
|
||||
'test-service',
|
||||
function () {
|
||||
throw new RuntimeException('Connection refused');
|
||||
},
|
||||
fn () => 'fallback-value',
|
||||
);
|
||||
|
||||
$this->assertSame('fallback-value', $result);
|
||||
}
|
||||
|
||||
public function test_closed_circuit_does_not_use_fallback_on_non_recoverable_error(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('logic error');
|
||||
|
||||
$this->breaker->call(
|
||||
'test-service',
|
||||
function () {
|
||||
throw new RuntimeException('logic error');
|
||||
},
|
||||
fn () => 'fallback-value',
|
||||
);
|
||||
}
|
||||
|
||||
public function test_service_is_available_when_closed(): void
|
||||
{
|
||||
$this->assertTrue($this->breaker->isAvailable('test-service'));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Failure threshold - trips circuit after N failures
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public function test_circuit_trips_after_reaching_failure_threshold(): void
|
||||
{
|
||||
// Default threshold is 5
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
try {
|
||||
$this->breaker->call('test-service', function () {
|
||||
throw new RuntimeException('Connection refused');
|
||||
});
|
||||
} catch (RuntimeException) {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('test-service'));
|
||||
}
|
||||
|
||||
public function test_circuit_does_not_trip_below_threshold(): void
|
||||
{
|
||||
// Default threshold is 5, so 4 failures should not trip
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
try {
|
||||
$this->breaker->call('test-service', function () {
|
||||
throw new RuntimeException('Connection refused');
|
||||
});
|
||||
} catch (RuntimeException) {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('test-service'));
|
||||
}
|
||||
|
||||
public function test_circuit_respects_custom_threshold(): void
|
||||
{
|
||||
Config::set('mcp.circuit_breaker.test-service.threshold', 2);
|
||||
|
||||
for ($i = 0; $i < 2; $i++) {
|
||||
try {
|
||||
$this->breaker->call('test-service', function () {
|
||||
throw new RuntimeException('Connection refused');
|
||||
});
|
||||
} catch (RuntimeException) {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('test-service'));
|
||||
}
|
||||
|
||||
public function test_service_is_not_available_when_open(): void
|
||||
{
|
||||
$this->tripCircuit('test-service');
|
||||
|
||||
$this->assertFalse($this->breaker->isAvailable('test-service'));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Open state - fails fast
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public function test_open_circuit_throws_circuit_open_exception(): void
|
||||
{
|
||||
$this->tripCircuit('test-service');
|
||||
|
||||
$this->expectException(CircuitOpenException::class);
|
||||
|
||||
$this->breaker->call('test-service', fn () => 'should not run');
|
||||
}
|
||||
|
||||
public function test_open_circuit_exception_contains_service_name(): void
|
||||
{
|
||||
$this->tripCircuit('my-api');
|
||||
|
||||
try {
|
||||
$this->breaker->call('my-api', fn () => 'should not run');
|
||||
$this->fail('Expected CircuitOpenException');
|
||||
} catch (CircuitOpenException $e) {
|
||||
$this->assertSame('my-api', $e->service);
|
||||
$this->assertStringContainsString('my-api', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_open_circuit_uses_fallback_when_provided(): void
|
||||
{
|
||||
$this->tripCircuit('test-service');
|
||||
|
||||
$result = $this->breaker->call(
|
||||
'test-service',
|
||||
fn () => 'should not run',
|
||||
fn () => 'fallback-result',
|
||||
);
|
||||
|
||||
$this->assertSame('fallback-result', $result);
|
||||
}
|
||||
|
||||
public function test_open_circuit_does_not_execute_operation(): void
|
||||
{
|
||||
$this->tripCircuit('test-service');
|
||||
|
||||
$executed = false;
|
||||
|
||||
try {
|
||||
$this->breaker->call('test-service', function () use (&$executed) {
|
||||
$executed = true;
|
||||
});
|
||||
} catch (CircuitOpenException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
$this->assertFalse($executed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Half-open state - probe / recovery
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public function test_circuit_transitions_to_half_open_after_reset_timeout(): void
|
||||
{
|
||||
$this->tripCircuit('test-service');
|
||||
|
||||
// Simulate time passing beyond the reset timeout (default 60s)
|
||||
$this->simulateTimePassing('test-service', 61);
|
||||
|
||||
$state = $this->breaker->getState('test-service');
|
||||
|
||||
$this->assertSame(CircuitBreaker::STATE_HALF_OPEN, $state);
|
||||
}
|
||||
|
||||
public function test_circuit_stays_open_before_reset_timeout(): void
|
||||
{
|
||||
$this->tripCircuit('test-service');
|
||||
|
||||
// Simulate time passing but not enough (default reset_timeout is 60s)
|
||||
$this->simulateTimePassing('test-service', 30);
|
||||
|
||||
$state = $this->breaker->getState('test-service');
|
||||
|
||||
$this->assertSame(CircuitBreaker::STATE_OPEN, $state);
|
||||
}
|
||||
|
||||
public function test_half_open_circuit_allows_probe_request(): void
|
||||
{
|
||||
$this->tripCircuit('test-service');
|
||||
$this->simulateTimePassing('test-service', 61);
|
||||
|
||||
$result = $this->breaker->call('test-service', fn () => 'probe-success');
|
||||
|
||||
$this->assertSame('probe-success', $result);
|
||||
}
|
||||
|
||||
public function test_half_open_circuit_closes_on_successful_probe(): void
|
||||
{
|
||||
$this->tripCircuit('test-service');
|
||||
$this->simulateTimePassing('test-service', 61);
|
||||
|
||||
// Successful probe should close the circuit
|
||||
$this->breaker->call('test-service', fn () => 'ok');
|
||||
|
||||
$this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('test-service'));
|
||||
}
|
||||
|
||||
public function test_half_open_circuit_reopens_on_failed_probe(): void
|
||||
{
|
||||
Config::set('mcp.circuit_breaker.test-service.threshold', 1);
|
||||
|
||||
$this->tripCircuit('test-service');
|
||||
$this->simulateTimePassing('test-service', 61);
|
||||
|
||||
// Failed probe should re-trip the circuit
|
||||
try {
|
||||
$this->breaker->call('test-service', function () {
|
||||
throw new RuntimeException('still broken');
|
||||
});
|
||||
} catch (RuntimeException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
$this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('test-service'));
|
||||
}
|
||||
|
||||
public function test_half_open_uses_fallback_when_trial_lock_taken(): void
|
||||
{
|
||||
$this->tripCircuit('test-service');
|
||||
$this->simulateTimePassing('test-service', 61);
|
||||
|
||||
// Simulate another request holding the trial lock
|
||||
Cache::put('circuit_breaker:test-service:trial_lock', true, 30);
|
||||
|
||||
$result = $this->breaker->call(
|
||||
'test-service',
|
||||
fn () => 'should not run',
|
||||
fn () => 'locked-fallback',
|
||||
);
|
||||
|
||||
$this->assertSame('locked-fallback', $result);
|
||||
}
|
||||
|
||||
public function test_half_open_throws_when_trial_lock_taken_and_no_fallback(): void
|
||||
{
|
||||
$this->tripCircuit('test-service');
|
||||
$this->simulateTimePassing('test-service', 61);
|
||||
|
||||
// Simulate another request holding the trial lock
|
||||
Cache::put('circuit_breaker:test-service:trial_lock', true, 30);
|
||||
|
||||
$this->expectException(CircuitOpenException::class);
|
||||
|
||||
$this->breaker->call('test-service', fn () => 'should not run');
|
||||
}
|
||||
|
||||
public function test_custom_reset_timeout(): void
|
||||
{
|
||||
Config::set('mcp.circuit_breaker.test-service.reset_timeout', 120);
|
||||
|
||||
$this->tripCircuit('test-service');
|
||||
|
||||
// 61 seconds is not enough with custom 120s timeout
|
||||
$this->simulateTimePassing('test-service', 61);
|
||||
$this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('test-service'));
|
||||
|
||||
// 121 seconds should trigger half-open
|
||||
$this->simulateTimePassing('test-service', 121);
|
||||
$this->assertSame(CircuitBreaker::STATE_HALF_OPEN, $this->breaker->getState('test-service'));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Manual reset
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public function test_manual_reset_closes_circuit(): void
|
||||
{
|
||||
$this->tripCircuit('test-service');
|
||||
|
||||
$this->breaker->reset('test-service');
|
||||
|
||||
$this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('test-service'));
|
||||
}
|
||||
|
||||
public function test_manual_reset_clears_failure_counters(): void
|
||||
{
|
||||
$this->tripCircuit('test-service');
|
||||
|
||||
$this->breaker->reset('test-service');
|
||||
|
||||
$stats = $this->breaker->getStats('test-service');
|
||||
|
||||
$this->assertSame(0, $stats['failures']);
|
||||
$this->assertSame(0, $stats['successes']);
|
||||
$this->assertNull($stats['last_failure']);
|
||||
$this->assertNull($stats['opened_at']);
|
||||
}
|
||||
|
||||
public function test_reset_allows_operations_again(): void
|
||||
{
|
||||
$this->tripCircuit('test-service');
|
||||
|
||||
$this->breaker->reset('test-service');
|
||||
|
||||
$result = $this->breaker->call('test-service', fn () => 'working again');
|
||||
|
||||
$this->assertSame('working again', $result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Statistics
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public function test_stats_track_failures(): void
|
||||
{
|
||||
try {
|
||||
$this->breaker->call('test-service', function () {
|
||||
throw new RuntimeException('Connection refused');
|
||||
});
|
||||
} catch (RuntimeException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
$stats = $this->breaker->getStats('test-service');
|
||||
|
||||
$this->assertSame(1, $stats['failures']);
|
||||
$this->assertSame('test-service', $stats['service']);
|
||||
$this->assertNotNull($stats['last_failure']);
|
||||
$this->assertSame(RuntimeException::class, $stats['last_failure']['class']);
|
||||
}
|
||||
|
||||
public function test_stats_track_successes(): void
|
||||
{
|
||||
$this->breaker->call('test-service', fn () => 'ok');
|
||||
|
||||
$stats = $this->breaker->getStats('test-service');
|
||||
|
||||
$this->assertSame(1, $stats['successes']);
|
||||
}
|
||||
|
||||
public function test_stats_report_threshold_and_timeout(): void
|
||||
{
|
||||
$stats = $this->breaker->getStats('test-service');
|
||||
|
||||
$this->assertSame(5, $stats['threshold']);
|
||||
$this->assertSame(60, $stats['reset_timeout']);
|
||||
}
|
||||
|
||||
public function test_stats_report_custom_config(): void
|
||||
{
|
||||
Config::set('mcp.circuit_breaker.custom-svc.threshold', 10);
|
||||
Config::set('mcp.circuit_breaker.custom-svc.reset_timeout', 180);
|
||||
|
||||
$stats = $this->breaker->getStats('custom-svc');
|
||||
|
||||
$this->assertSame(10, $stats['threshold']);
|
||||
$this->assertSame(180, $stats['reset_timeout']);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Recoverable error detection
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public function test_sqlstate_error_is_recoverable(): void
|
||||
{
|
||||
$result = $this->breaker->call(
|
||||
'test-service',
|
||||
function () {
|
||||
throw new RuntimeException('SQLSTATE[HY000]: General error');
|
||||
},
|
||||
fn () => 'recovered',
|
||||
);
|
||||
|
||||
$this->assertSame('recovered', $result);
|
||||
}
|
||||
|
||||
public function test_connection_timeout_is_recoverable(): void
|
||||
{
|
||||
$result = $this->breaker->call(
|
||||
'test-service',
|
||||
function () {
|
||||
throw new RuntimeException('Connection timed out after 30 seconds');
|
||||
},
|
||||
fn () => 'recovered',
|
||||
);
|
||||
|
||||
$this->assertSame('recovered', $result);
|
||||
}
|
||||
|
||||
public function test_too_many_connections_is_recoverable(): void
|
||||
{
|
||||
$result = $this->breaker->call(
|
||||
'test-service',
|
||||
function () {
|
||||
throw new RuntimeException('Too many connections');
|
||||
},
|
||||
fn () => 'recovered',
|
||||
);
|
||||
|
||||
$this->assertSame('recovered', $result);
|
||||
}
|
||||
|
||||
public function test_table_not_found_is_recoverable(): void
|
||||
{
|
||||
$result = $this->breaker->call(
|
||||
'test-service',
|
||||
function () {
|
||||
throw new RuntimeException("Base table or view not found: 1146 Table 'db.table' doesn't exist");
|
||||
},
|
||||
fn () => 'recovered',
|
||||
);
|
||||
|
||||
$this->assertSame('recovered', $result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Service isolation
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public function test_circuits_are_isolated_per_service(): void
|
||||
{
|
||||
$this->tripCircuit('service-a');
|
||||
|
||||
// service-a should be open
|
||||
$this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('service-a'));
|
||||
|
||||
// service-b should still be closed
|
||||
$this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('service-b'));
|
||||
}
|
||||
|
||||
public function test_resetting_one_service_does_not_affect_another(): void
|
||||
{
|
||||
$this->tripCircuit('service-a');
|
||||
$this->tripCircuit('service-b');
|
||||
|
||||
$this->breaker->reset('service-a');
|
||||
|
||||
$this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('service-a'));
|
||||
$this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('service-b'));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Success decay
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public function test_successful_calls_decay_failure_count(): void
|
||||
{
|
||||
// Record 3 failures
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
try {
|
||||
$this->breaker->call('test-service', function () {
|
||||
throw new RuntimeException('Connection refused');
|
||||
});
|
||||
} catch (RuntimeException) {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(3, $this->breaker->getStats('test-service')['failures']);
|
||||
|
||||
// A successful call should decrement the failure count
|
||||
$this->breaker->call('test-service', fn () => 'ok');
|
||||
|
||||
$this->assertSame(2, $this->breaker->getStats('test-service')['failures']);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Trip the circuit by reaching the failure threshold.
|
||||
*/
|
||||
protected function tripCircuit(string $service): void
|
||||
{
|
||||
$threshold = (int) config(
|
||||
"mcp.circuit_breaker.{$service}.threshold",
|
||||
config('mcp.circuit_breaker.default_threshold', 5),
|
||||
);
|
||||
|
||||
for ($i = 0; $i < $threshold; $i++) {
|
||||
try {
|
||||
$this->breaker->call($service, function () {
|
||||
throw new RuntimeException('Connection refused');
|
||||
});
|
||||
} catch (RuntimeException) {
|
||||
// Expected - Connection refused is recoverable so fallback would be used
|
||||
// but without a fallback it re-throws after recording the failure
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the circuit actually tripped
|
||||
$this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState($service));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate time passing by adjusting the opened_at timestamp in cache.
|
||||
*/
|
||||
protected function simulateTimePassing(string $service, int $seconds): void
|
||||
{
|
||||
Cache::put(
|
||||
"circuit_breaker:{$service}:opened_at",
|
||||
time() - $seconds,
|
||||
86400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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> •
|
||||
<span class="text-cyan-600 dark:text-cyan-400">Agent-optimised</span> •
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
Reference in a new issue