diff --git a/app/Http/Controllers/McpController.php b/app/Http/Controllers/McpController.php index f07754a74..0f2df1fd6 100644 --- a/app/Http/Controllers/McpController.php +++ b/app/Http/Controllers/McpController.php @@ -101,11 +101,26 @@ public function pageApi(string $platform, string $version, string $path): JsonRe public function edgeComponentsApi(string $platform, string $version): JsonResponse { - $components = $this->docsSearch->listEdgeComponents($platform, $version); + $tagsByPage = $this->edgeTagsByPage($platform, $version); + + $components = collect($this->docsSearch->listEdgeComponents($platform, $version)) + ->map(fn (array $component) => $component + ['tags' => $tagsByPage[$component['id']] ?? []]) + ->all(); return response()->json(['edge_components' => $components]); } + public function edgeComponentApi(string $platform, string $version, string $tag): JsonResponse + { + $element = $this->docsSearch->getEdgeElement($platform, $version, $tag); + + if (! $element) { + return response()->json(['error' => 'Component not found'], 404); + } + + return response()->json(['edge_component' => $element]); + } + public function navigationApi(string $platform, string $version): JsonResponse { $nav = $this->docsSearch->getNavigation($platform, $version); @@ -202,6 +217,30 @@ protected function getToolDefinitions(): array ], ], ], + [ + 'name' => 'get_edge_component', + 'description' => 'Get the props, events, children and fluent PHP API for one EDGE element, e.g. "button", "list-item" or "outlined-text-input" (also accepts "" or "ListItem"). Taken from the same docs page get_page returns, minus the examples. Use list_edge_components to see every tag.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'tag' => [ + 'type' => 'string', + 'description' => 'Element tag without the native: prefix, e.g. "list-item"', + ], + 'platform' => [ + 'type' => 'string', + 'enum' => ['desktop', 'mobile'], + 'description' => 'Platform (default: mobile)', + 'default' => 'mobile', + ], + 'version' => [ + 'type' => 'string', + 'description' => 'Version number (optional; defaults to the latest for the platform)', + ], + ], + 'required' => ['tag'], + ], + ], [ 'name' => 'get_navigation', 'description' => 'Get the docs navigation structure for a platform/version', @@ -292,6 +331,7 @@ protected function handleToolCall(string $name, array $args): array 'search_docs' => $this->toolSearchDocs($args), 'get_page' => $this->toolGetPage($args), 'list_edge_components' => $this->toolListEdgeComponents($args), + 'get_edge_component' => $this->toolGetEdgeComponent($args), 'get_navigation' => $this->toolGetNavigation($args), 'search_plugins' => $this->toolSearchPlugins($args), 'get_plugin' => $this->toolGetPlugin($args), @@ -370,18 +410,79 @@ protected function toolListEdgeComponents(array $args): array ]; } - $formatted = collect($components)->map(function ($component) { + $tagsByPage = $this->edgeTagsByPage($platform, $version); + + $formatted = collect($components)->map(function ($component) use ($tagsByPage) { $desc = $component['description'] ?: 'No description'; $path = $component['id']; + $text = "- **{$component['title']}** ({$component['slug']})\n Path: {$path}"; + + if (! empty($tagsByPage[$path])) { + $tags = collect($tagsByPage[$path])->map(fn ($tag) => "")->join(', '); + $text .= "\n Tags: {$tags}"; + } - return "- **{$component['title']}** ({$component['slug']})\n Path: {$path}\n {$desc}"; + return "{$text}\n {$desc}"; })->join("\n"); + $footer = "\n\nCall get_edge_component with a tag (e.g. \"list-item\") for its props, events and PHP API."; + + return [ + 'content' => [['type' => 'text', 'text' => "# {$platform} v{$version} EDGE components\n\n{$formatted}{$footer}"]], + ]; + } + + protected function toolGetEdgeComponent(array $args): array + { + $platform = (string) ($args['platform'] ?? 'mobile'); + $version = (string) ($args['version'] ?? ($this->docsSearch->getLatestVersions()[$platform] ?? '')); + $tag = (string) ($args['tag'] ?? ''); + + $element = $this->docsSearch->getEdgeElement($platform, $version, $tag); + + if (! $element) { + $known = collect(array_keys($this->docsSearch->edgeElements($platform, $version)))->join(', '); + + return [ + 'content' => [['type' => 'text', 'text' => "No EDGE component \"{$tag}\" documented for {$platform} v{$version}.".($known ? " Known tags: {$known}" : '')]], + 'isError' => true, + ]; + } + + $text = "# \n\n"; + $text .= "Documented at: {$element['path']}".($element['section'] ? " (section \"{$element['section']}\")" : '')."\n"; + + if ($element['php_classes'] !== []) { + $text .= 'PHP element class: '.implode(', ', $element['php_classes'])."\n"; + } + + if ($element['other_tags'] !== []) { + $text .= 'Also on this page: '.collect($element['other_tags'])->map(fn ($other) => "")->join(', ')."\n"; + } + + if ($platform === 'mobile' && (int) $version >= 4) { + $text .= "Requires the nativephp/mobile-ui plugin, installed and registered in app/Providers/NativeServiceProvider.php. Without it the tag throws \"Unknown native element type\" or renders nothing.\n"; + } + + $text .= "Examples are left out here; get_page {$element['path']} has them.\n\n"; + $text .= $element['reference']; + return [ - 'content' => [['type' => 'text', 'text' => "# {$platform} v{$version} EDGE components\n\n{$formatted}"]], + 'content' => [['type' => 'text', 'text' => $text]], ]; } + /** + * @return array> + */ + protected function edgeTagsByPage(string $platform, string $version): array + { + return collect($this->docsSearch->edgeElements($platform, $version)) + ->groupBy(fn ($element) => $element['page']['id']) + ->map(fn ($elements) => $elements->pluck('tag')->all()) + ->all(); + } + protected function toolGetNavigation(array $args): array { $platform = $args['platform'] ?? ''; diff --git a/app/Services/DocsSearchService.php b/app/Services/DocsSearchService.php index bf41d6203..1174ae9ac 100644 --- a/app/Services/DocsSearchService.php +++ b/app/Services/DocsSearchService.php @@ -99,6 +99,196 @@ public function listEdgeComponents(string $platform, string $version): array ->toArray(); } + /** + * Every EDGE element tag documented for a platform/version, keyed by tag + * (`list-item`), with the page that documents it. Built from the same + * markdown the docs render, so it can't drift from the published pages. + * + * A tag belongs to the H2 section named after it (`## List Item` on the + * list page), else to the page whose slug matches it, else to the page + * that names it most often in prose (`` on the + * text-input page). + * + * @return array, section: ?string}> + */ + public function edgeElements(string $platform, string $version): array + { + $claims = []; + + foreach ($this->listEdgeComponents($platform, $version) as $page) { + $sections = $this->splitH2Sections($page['content']); + $tagsOnPage = $this->tagsIn($page['content']); + + foreach (array_keys($sections) as $heading) { + $tag = Str::slug($heading); + + if ($heading !== '' && in_array($tag, $tagsOnPage, true)) { + $claims[$tag][] = ['page' => $page, 'section' => $heading, 'weight' => PHP_INT_MAX]; + } + } + + foreach ($tagsOnPage as $tag) { + if (str_replace('-', '', $tag) === str_replace('-', '', $page['slug'])) { + $claims[$tag][] = ['page' => $page, 'section' => null, 'weight' => PHP_INT_MAX - 1]; + } + } + + preg_match_all('/```[\s\S]*?```/', $page['content'], $codeBlocks); + $tagsInCode = $this->tagsIn(implode("\n", $codeBlocks[0])); + $prose = preg_replace('/```[\s\S]*?```/', '', $page['content']); + preg_match_all('/`\/`]/', $prose, $mentions); + + foreach (array_count_values($mentions[1]) as $tag => $count) { + if (in_array($tag, $tagsInCode, true)) { + $claims[$tag][] = ['page' => $page, 'section' => null, 'weight' => $count]; + } + } + } + + $elements = []; + + foreach ($claims as $tag => $tagClaims) { + usort($tagClaims, fn ($a, $b) => $b['weight'] <=> $a['weight']); + + $elements[$tag] = [ + 'tag' => $tag, + 'page' => $tagClaims[0]['page'], + 'section' => $tagClaims[0]['section'], + ]; + } + + ksort($elements); + + return $elements; + } + + /** + * The reference for one EDGE element: its props, events, children and + * fluent API as documented, without the examples. Accepts `list-item`, + * ``, `list_item` or `ListItem`. + * + * @return array{tag: string, title: string, path: string, section: ?string, other_tags: array, php_classes: array, reference: string}|null + */ + public function getEdgeElement(string $platform, string $version, string $tag): ?array + { + $tag = $this->normalizeTag($tag); + $elements = $this->edgeElements($platform, $version); + + if ($tag === '' || ! isset($elements[$tag])) { + return null; + } + + $element = $elements[$tag]; + $page = $element['page']; + $sections = $this->splitH2Sections($page['content']); + + $siblings = collect($elements) + ->filter(fn ($other) => $other['page']['id'] === $page['id'] && $other['tag'] !== $tag); + + if ($element['section'] !== null) { + $content = "## {$element['section']}\n".$sections[$element['section']] + .$this->fluentApiFor(Str::studly($tag), $sections); + + preg_match_all('/Native\\\\Mobile\\\\[A-Za-z\\\\]+\\\\Elements\\\\'.Str::studly($tag).'\b/', $page['content'], $classes); + } else { + $ownedElsewhere = $siblings->pluck('section')->filter()->all(); + + $content = collect($sections) + ->reject(fn ($body, $heading) => $heading === 'Examples' || in_array($heading, $ownedElsewhere, true)) + ->map(fn ($body, $heading) => $heading === '' ? $body : "## {$heading}\n{$body}") + ->implode("\n"); + + preg_match_all('/Native\\\\Mobile\\\\[A-Za-z\\\\]+\\\\Elements\\\\\w+/', $content, $classes); + } + + $reference = preg_replace('/```[\s\S]*?```\n?/', '', $content); + $reference = trim(preg_replace("/\n{3,}/", "\n\n", $reference)); + + return [ + 'tag' => $tag, + 'title' => $page['title'], + 'path' => $page['id'], + 'section' => $element['section'], + 'other_tags' => $siblings->keys()->values()->all(), + 'php_classes' => array_values(array_unique($classes[0])), + 'reference' => $reference, + ]; + } + + /** + * The H3 block documenting a sub-element's fluent methods, which lives in + * the page's Element section (`### \`ListItem\` methods`) rather than in + * the sub-element's own section. + * + * @param array $sections + */ + protected function fluentApiFor(string $class, array $sections): string + { + $pattern = '/^###\s+`'.preg_quote($class, '/').'`[^\n]*\n[\s\S]*?(?=^###?\s|\z)/m'; + + foreach ($sections as $body) { + if (preg_match($pattern, $body, $match)) { + return "\n".$match[0]; + } + } + + return ''; + } + + /** + * Split markdown into its H2 sections, keyed by heading text. Text before + * the first H2 is keyed ''. Headings inside fenced code are ignored. + * + * @return array + */ + protected function splitH2Sections(string $content): array + { + $sections = ['' => '']; + $current = ''; + $inFence = false; + + foreach (explode("\n", $content) as $line) { + if (str_starts_with(ltrim($line), '```')) { + $inFence = ! $inFence; + } + + if (! $inFence && preg_match('/^##\s+(.+?)\s*$/', $line, $match)) { + $current = $match[1]; + $sections[$current] = ''; + + continue; + } + + $sections[$current] .= $line."\n"; + } + + if (trim($sections['']) === '') { + unset($sections['']); + } + + return $sections; + } + + /** + * @return array + */ + protected function tagsIn(string $content): array + { + preg_match_all('/'); + $tag = Str::kebab(str_replace('_', '-', $tag)); + + return preg_match('/^[a-z][a-z0-9-]*$/', $tag) ? $tag : ''; + } + public function getNavigation(string $platform, string $version): array { if (! $this->sanitizePlatform($platform) || ! $this->sanitizeVersion($version)) { @@ -212,9 +402,9 @@ protected function getAllPages(?string $platform = null, ?string $version = null return []; } - // v2 keys: page ids now carry the full section path, so entries cached - // under the old shape must not be reused after a deploy. - $cacheKey = 'mcp_docs_pages_v2_'.($platform ?? 'all').'_'.($version ?? 'all'); + // v3 keys: prose now keeps inline code such as `@press`, so pages cached + // with those event names stripped must not be reused after a deploy. + $cacheKey = 'mcp_docs_pages_v3_'.($platform ?? 'all').'_'.($version ?? 'all'); if (config('app.env') !== 'local') { $cached = Cache::get($cacheKey); @@ -301,20 +491,44 @@ protected function stripBladeComponents(string $content): string if (str_starts_with($segment, '```')) { continue; // code block — leave untouched } - // Remove ... tags - $segment = preg_replace('/]+>[\s\S]*?<\/x-[^>]+>/s', '', $segment); - // Remove self-closing tags - $segment = preg_replace('//s', '', $segment); + + $segments[$i] = $this->stripBladeFromProse($segment); + } + + return implode('', $segments); + } + + /** + * Clean Blade out of prose while keeping inline code spans. Inline code is + * where the docs name event attributes (`@press`, `@change`) and show + * escaped echoes (`@{{ $name }}`), so it is unescaped the way Blade would + * render it instead of being stripped with the surrounding directives. + */ + protected function stripBladeFromProse(string $prose): string + { + // Remove ... tags + $prose = preg_replace('/]+>[\s\S]*?<\/x-[^>]+>/s', '', $prose); + // Remove self-closing tags + $prose = preg_replace('//s', '', $prose); + + $parts = preg_split('/(`[^`\n]+`)/', $prose, -1, PREG_SPLIT_DELIM_CAPTURE); + + foreach ($parts as $i => $part) { + if (str_starts_with($part, '`')) { + $parts[$i] = str_replace(['@{{', '@@'], ['{{', '@'], $part); + + continue; + } + // Remove {{ }} blade echoes - $segment = preg_replace('/\{\{.*?\}\}/s', '', $segment); + $part = preg_replace('/\{\{.*?\}\}/s', '', $part); // Remove {!! !!} unescaped echoes - $segment = preg_replace('/\{!![\s\S]*?!!\}/s', '', $segment); + $part = preg_replace('/\{!![\s\S]*?!!\}/s', '', $part); // Remove @directives (@verbatim wrappers, @php, etc.) - $segment = preg_replace('/@\w+(\([^)]*\))?/', '', $segment); - $segments[$i] = $segment; + $parts[$i] = preg_replace('/@\w+(\([^)]*\))?/', '', $part); } - return implode('', $segments); + return implode('', $parts); } protected function extractHeadings(string $content): array diff --git a/resources/views/docs/mobile/4/getting-started/mcp.md b/resources/views/docs/mobile/4/getting-started/mcp.md index dc3c89dee..687f1770c 100644 --- a/resources/views/docs/mobile/4/getting-started/mcp.md +++ b/resources/views/docs/mobile/4/getting-started/mcp.md @@ -55,6 +55,7 @@ Once connected, your agent gets these tools: - **`get_page`** — fetch a full page by path (e.g. `mobile/4/plugins/core/camera` or `mobile/4/edge-components/button`) - **`get_navigation`** — the sidebar for a platform and version - **`list_edge_components`** — list EDGE / SuperNative UI components for a platform (defaults to latest mobile) so agents build native UI via Blade EDGE components +- **`get_edge_component`** — the props, events and PHP API for one element, e.g. `list-item` or `outlined-text-input` - **`search_plugins`** — search the public plugin marketplace (where Mobile v3+ native APIs live) - **`get_plugin`** — fetch one marketplace plugin by composer name diff --git a/resources/views/mcp-content.md b/resources/views/mcp-content.md index 2a8f7125a..5ad9f2d5a 100644 --- a/resources/views/mcp-content.md +++ b/resources/views/mcp-content.md @@ -128,6 +128,17 @@ hand straight to `get_page` (for example `mobile/4/edge-components/button`). Mobile v2+ ships an `edge-components` section; Desktop currently has none, so the list is empty there. +Each page also lists the tags it documents, so the list page shows +``, `` and ``. + +### `get_edge_component` + +Returns the reference for one element: its props, events, children and fluent +PHP API, for a `tag` such as `button`, `list-item` or `outlined-text-input`. +``, `list_item` and `ListItem` work too. It comes from the same +docs page `get_page` returns, with the examples left out, so it can't drift from +the site. `platform` defaults to `mobile` and `version` to the latest. + ### `search_plugins` Search the public plugin marketplace the same way the directory does: approved, @@ -165,6 +176,7 @@ MCP client: - `/api/mcp/page/{platform}/{version}/{section}/{slug}` — a single page - `/api/mcp/navigation/{platform}/{version}` — the docs navigation tree - `/api/mcp/edge-components/{platform}/{version}` — EDGE / SuperNative component listing +- `/api/mcp/edge-components/{platform}/{version}/{tag}` — one EDGE component's reference - `/api/mcp/plugins?q=camera&type=free&limit=10` — marketplace plugin search - `/api/mcp/plugins/{vendor}/{package}` — one marketplace plugin - `/api/mcp/health` — liveness check, and the versions currently published diff --git a/routes/api.php b/routes/api.php index e2a7b3d3c..a22b8a1f0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -32,6 +32,7 @@ ->where('path', '.*') ->name('mcp.api.page'); Route::get('edge-components/{platform}/{version}', [McpController::class, 'edgeComponentsApi'])->name('mcp.api.edge-components'); + Route::get('edge-components/{platform}/{version}/{tag}', [McpController::class, 'edgeComponentApi'])->name('mcp.api.edge-component'); Route::get('navigation/{platform}/{version}', [McpController::class, 'navigationApi'])->name('mcp.api.navigation'); Route::get('plugins', [McpController::class, 'pluginsSearchApi'])->name('mcp.api.plugins.search'); diff --git a/tests/Feature/DocsMcpServerPageTest.php b/tests/Feature/DocsMcpServerPageTest.php index dca6adc54..b9e44341d 100644 --- a/tests/Feature/DocsMcpServerPageTest.php +++ b/tests/Feature/DocsMcpServerPageTest.php @@ -75,7 +75,7 @@ public function the_documented_message_endpoint_lists_the_documented_tools(): vo $tools = collect($response->json('result.tools'))->pluck('name')->all(); $this->assertEqualsCanonicalizing( - ['search_docs', 'get_page', 'list_edge_components', 'get_navigation', 'search_plugins', 'get_plugin'], + ['search_docs', 'get_page', 'list_edge_components', 'get_edge_component', 'get_navigation', 'search_plugins', 'get_plugin'], $tools, ); } @@ -244,4 +244,153 @@ public function the_legacy_apis_rest_endpoint_is_gone(): void { $this->getJson('/api/mcp/apis/mobile/2')->assertNotFound(); } + + #[Test] + public function list_edge_components_names_the_tags_each_page_documents(): void + { + $text = $this->callTool('list_edge_components', ['platform' => 'mobile', 'version' => '4']); + + $this->assertStringContainsString('Tags: , , ', $text); + $this->assertStringContainsString('', $text); + $this->assertStringContainsString('get_edge_component', $text); + } + + #[Test] + public function get_edge_component_returns_the_props_and_events_for_a_sub_element(): void + { + $text = $this->callTool('get_edge_component', ['tag' => 'list-item', 'version' => '4']); + + $this->assertStringContainsString('# ', $text); + $this->assertStringContainsString('Documented at: mobile/4/edge-components/list (section "List Item")', $text); + $this->assertStringContainsString('Native\\Mobile\\UI\\Elements\\ListItem', $text); + $this->assertStringContainsString('`leadingCheckbox`', $text); + $this->assertStringContainsString('`on-leading-change`', $text); + $this->assertStringContainsString('`on-swipe-delete`', $text); + $this->assertStringContainsString('`@press`', $text); + $this->assertStringContainsString('onTrailingPress(string $method)', $text, 'The fluent API from the Element section belongs with the element.'); + $this->assertStringContainsString('nativephp/mobile-ui', $text); + + $this->assertStringNotContainsString('`on-refresh`', $text, 'Props of the parent should not leak in.'); + $this->assertStringNotContainsString('```', $text, 'Examples are left to get_page.'); + } + + #[Test] + public function get_edge_component_returns_the_whole_page_reference_for_a_page_level_element(): void + { + $text = $this->callTool('get_edge_component', ['tag' => 'button']); + + $this->assertStringContainsString('Documented at: mobile/4/edge-components/button', $text); + $this->assertStringContainsString('## Props', $text); + $this->assertStringContainsString('## Events', $text); + $this->assertStringContainsString('- `@press` - Component method to call when tapped', $text); + $this->assertStringContainsString('## Element', $text); + $this->assertStringNotContainsString('## Examples', $text); + } + + #[Test] + public function get_edge_component_leaves_sub_element_sections_out_of_the_parent(): void + { + $text = $this->callTool('get_edge_component', ['tag' => 'list']); + + $this->assertStringContainsString('`on-end-reached`', $text); + $this->assertStringNotContainsString('## List Item', $text); + $this->assertStringNotContainsString('## List Section', $text); + $this->assertStringContainsString('Also on this page: , ', $text); + } + + #[Test] + public function get_edge_component_accepts_the_spellings_agents_use(): void + { + foreach (['', 'native:list-item', 'list_item', 'ListItem', ''] as $spelling) { + $this->assertStringContainsString( + '# ', + $this->callTool('get_edge_component', ['tag' => $spelling]), + "The tag should resolve when spelled {$spelling}.", + ); + } + + $this->assertStringContainsString( + 'Documented at: mobile/4/edge-components/text-input', + $this->callTool('get_edge_component', ['tag' => 'outlined-text-input']), + ); + } + + #[Test] + public function get_edge_component_lists_the_known_tags_when_it_cannot_find_one(): void + { + $response = $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => 'get_edge_component', 'arguments' => ['tag' => 'text-input']], + ]); + + $this->assertTrue($response->json('result.isError')); + $this->assertStringContainsString('No EDGE component "text-input"', $response->json('result.content.0.text')); + $this->assertStringContainsString('outlined-text-input', $response->json('result.content.0.text')); + } + + /** + * The catalogue is derived from the docs pages, so every tag it hands out + * must resolve to a non-empty reference that get_page can also open. + */ + #[Test] + public function every_catalogued_edge_component_resolves_to_a_reference(): void + { + $service = app(DocsSearchService::class); + $elements = $service->edgeElements('mobile', '4'); + + $this->assertGreaterThan(40, count($elements)); + + foreach (array_keys($elements) as $tag) { + $element = $service->getEdgeElement('mobile', '4', $tag); + + $this->assertNotNull($element, "{$tag} is catalogued but does not resolve."); + $this->assertNotSame('', trim($element['reference']), "{$tag} resolved to an empty reference."); + $this->assertNotNull($service->getPageByPath($element['path']), "{$tag} points at a page get_page can't open."); + } + } + + #[Test] + public function get_page_keeps_event_names_written_in_inline_code(): void + { + $text = $this->callTool('get_page', ['path' => 'mobile/4/edge-components/button']); + + $this->assertStringContainsString('- `@press` - Component method to call when tapped', $text); + $this->assertStringNotContainsString('- `` -', $text); + } + + #[Test] + public function the_edge_components_rest_endpoints_expose_tags_and_references(): void + { + $list = $this->getJson('/api/mcp/edge-components/mobile/4')->assertOk(); + + $listPage = collect($list->json('edge_components'))->firstWhere('slug', 'list'); + $this->assertSame(['list', 'list-item', 'list-section'], $listPage['tags']); + + $this->getJson('/api/mcp/edge-components/mobile/4/list-item') + ->assertOk() + ->assertJsonPath('edge_component.tag', 'list-item') + ->assertJsonPath('edge_component.path', 'mobile/4/edge-components/list') + ->assertJsonPath('edge_component.section', 'List Item'); + + $this->getJson('/api/mcp/edge-components/mobile/4/not-a-component')->assertNotFound(); + } + + /** + * @param array $arguments + */ + protected function callTool(string $name, array $arguments): string + { + $response = $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => $name, 'arguments' => $arguments], + ]); + + $response->assertOk(); + + return (string) $response->json('result.content.0.text'); + } } diff --git a/tests/Feature/McpSecurityTest.php b/tests/Feature/McpSecurityTest.php index 41e9200f9..6b1eb767a 100644 --- a/tests/Feature/McpSecurityTest.php +++ b/tests/Feature/McpSecurityTest.php @@ -78,6 +78,12 @@ public function test_edge_components_endpoint_rejects_invalid_platform(): void $response->assertJson(['edge_components' => []]); } + public function test_edge_component_endpoint_rejects_a_traversal_tag(): void + { + $this->getJson('/api/mcp/edge-components/mobile/4/..')->assertNotFound(); + $this->getJson('/api/mcp/edge-components/mobile/..%2F..%2F4/button')->assertNotFound(); + } + public function test_navigation_endpoint_rejects_invalid_version(): void { $response = $this->getJson('/api/mcp/navigation/mobile/..');