agent/php/Mcp/Resources/ContentResource.php
Snider 91551dec9b feat(mcp): implement extended RFC services + transport (#842)
Additive-only — no existing files modified.

Services (php/Mcp/Services/):
- CircuitBreaker (3-state, Cache::add trial lock)
- DataRedactor (28 sensitive + 16 PII keys, partial-redact algorithm)
- McpHealthService (YAML registry + JSON-RPC stdio ping protocolVersion 2024-11-05)
- McpMetricsService (p50/p95/p99 linear interpolation)
- McpWebhookDispatcher (mcp.tool.executed → WebhookEndpoints)
- OpenApiGenerator (OpenAPI 3.0.3)
- ToolRateLimiter (Cache::put first, Cache::increment after — no reset)
- AgentSessionService (php/Mod/Mcp/Services/ namespace per spec)

Transport (php/Mcp/Transport/):
- McpContext (transport-agnostic callbacks)
- Contracts/McpToolHandler interface

Resources (php/Mcp/Resources/):
- AppConfig, ContentResource, DatabaseSchema

Config: php/resources/mcp/registry.yaml.
Pest Feature tests _Good/_Bad/_Ugly per AX-10 for each new class.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=842
2026-04-25 05:50:16 +01:00

222 lines
7.1 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mcp\Resources;
use Core\Tenant\Models\Workspace;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Resource;
use Mod\Content\Models\ContentItem;
use Symfony\Component\Yaml\Yaml;
final class ContentResource extends Resource
{
protected string $description = 'Content items from the CMS - returns markdown for AI context';
public function handle(Request $request): Response
{
$uri = (string) $request->get('uri', '');
$parts = $this->parseUri($uri);
if ($parts === null) {
return Response::text('Invalid URI format. Expected: content://{workspace}/{slug}');
}
[$workspaceIdentifier, $contentIdentifier] = $parts;
$workspace = $this->resolveWorkspace($workspaceIdentifier);
if ($workspace === null) {
return Response::text(sprintf('Workspace not found: %s', $workspaceIdentifier));
}
$item = $this->resolveContentItem($workspace, $contentIdentifier);
if ($item === null) {
return Response::text(sprintf('Content not found: %s', $contentIdentifier));
}
return Response::text($this->contentToMarkdown($item, $workspace));
}
public static function list(): array
{
return (new self)->listResources();
}
protected function listResources(): array
{
if (! class_exists(Workspace::class) || ! class_exists(ContentItem::class)) {
return [];
}
$resources = [];
foreach (Workspace::query()->get(['id', 'slug']) as $workspace) {
foreach ($this->publishedItemsForWorkspace($workspace)->take(50) as $item) {
$resources[] = [
'uri' => sprintf('content://%s/%s', $workspace->slug, $item->slug),
'name' => (string) $item->title,
'description' => sprintf('%s: %s', ucfirst((string) ($item->type ?? 'content')), (string) $item->title),
'mimeType' => 'text/markdown',
];
}
}
return $resources;
}
protected function parseUri(string $uri): ?array
{
if (! str_starts_with($uri, 'content://')) {
return null;
}
$parts = explode('/', substr($uri, 10), 2);
return count($parts) === 2 ? $parts : null;
}
protected function resolveWorkspace(string $identifier): ?Workspace
{
if (! class_exists(Workspace::class)) {
return null;
}
return Workspace::query()
->where('slug', $identifier)
->orWhere('id', $identifier)
->first();
}
protected function resolveContentItem(object $workspace, string $identifier): ?object
{
if (! class_exists(ContentItem::class)) {
return null;
}
$query = ContentItem::query();
if (method_exists($query->getModel(), 'scopeForWorkspace')) {
$query->forWorkspace($workspace->id);
} else {
$query->where('workspace_id', $workspace->id);
}
if (method_exists($query->getModel(), 'scopeNative')) {
$query->native();
}
$item = (clone $query)->where('slug', $identifier)->first();
if ($item === null && is_numeric($identifier)) {
$item = (clone $query)->find((int) $identifier);
}
if ($item !== null && method_exists($item, 'loadMissing')) {
$item->loadMissing(['author', 'categories', 'tags', 'taxonomies']);
}
return $item;
}
protected function publishedItemsForWorkspace(object $workspace)
{
$query = ContentItem::query();
if (method_exists($query->getModel(), 'scopeForWorkspace')) {
$query->forWorkspace($workspace->id);
} else {
$query->where('workspace_id', $workspace->id);
}
if (method_exists($query->getModel(), 'scopeNative')) {
$query->native();
}
if (method_exists($query->getModel(), 'scopePublished')) {
$query->published();
} else {
$query->where('status', 'publish');
}
return $query
->orderByDesc('updated_at')
->limit(50)
->get(['id', 'slug', 'title', 'type']);
}
protected function contentToMarkdown(object $item, object $workspace): string
{
$frontMatter = [
'title' => (string) ($item->title ?? ''),
'slug' => (string) ($item->slug ?? ''),
'workspace' => (string) ($workspace->slug ?? $workspace->id ?? ''),
'type' => (string) ($item->type ?? ''),
'status' => (string) ($item->status ?? ''),
'author' => data_get($item, 'author.name'),
'categories' => $this->taxonomyNames($item, 'categories', 'category'),
'tags' => $this->taxonomyNames($item, 'tags', 'tag'),
'publish_at' => $item->publish_at?->toIso8601String(),
'created_at' => $item->created_at?->toIso8601String(),
'updated_at' => $item->updated_at?->toIso8601String(),
'seo_title' => data_get($item, 'seo_meta.title') ?? data_get($item, 'seo_title'),
'seo_description' => data_get($item, 'seo_meta.description') ?? data_get($item, 'seo_description'),
];
$frontMatter = array_filter($frontMatter, static fn (mixed $value): bool => $value !== null && $value !== []);
$markdown = "---\n".Yaml::dump($frontMatter, 3, 2)."---\n\n";
$excerpt = trim((string) ($item->excerpt ?? ''));
if ($excerpt !== '') {
$markdown .= collect(preg_split('/\R/', $excerpt) ?: [])
->map(static fn (string $line): string => '> '.$line)
->implode("\n")."\n\n";
}
$markdown .= $this->contentBody($item);
return $markdown;
}
protected function taxonomyNames(object $item, string $relation, string $fallbackType): array
{
$names = collect(data_get($item, $relation, []))
->map(static fn (mixed $taxonomy): ?string => is_object($taxonomy) ? ($taxonomy->name ?? null) : null)
->filter()
->values()
->all();
if ($names !== []) {
return $names;
}
return collect(data_get($item, 'taxonomies', []))
->filter(static fn (mixed $taxonomy): bool => is_object($taxonomy) && (($taxonomy->type ?? null) === $fallbackType || ($taxonomy->taxonomy ?? null) === $fallbackType))
->map(static fn (object $taxonomy): ?string => $taxonomy->name ?? null)
->filter()
->values()
->all();
}
protected function contentBody(object $item): string
{
$markdown = trim((string) ($item->content_markdown ?? ''));
if ($markdown !== '') {
return $markdown;
}
$cleanHtml = trim((string) ($item->content_html_clean ?? ''));
if ($cleanHtml !== '') {
return trim(strip_tags($cleanHtml));
}
return trim(strip_tags((string) ($item->content_html_original ?? '')));
}
}