Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion json-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,8 @@
"git_diff",
"tree",
"mcp",
"composer"
"composer",
"docs"
],
"description": "Type of content source"
},
Expand Down Expand Up @@ -812,6 +813,18 @@
"then": {
"$ref": "#/definitions/treeSource"
}
},
{
"if": {
"properties": {
"type": {
"const": "docs"
}
}
},
"then": {
"$ref": "#/definitions/docsSource"
}
}
]
},
Expand Down Expand Up @@ -1345,6 +1358,49 @@
],
"description": "Configuration for rendering diffs"
},
"docsSource": {
"type": "object",
"description": "Source for documentation content",
"required": [
"type",
"library",
"topic"
],
"properties": {
"type": {
"type": "string",
"enum": [
"docs"
],
"description": "Source type - docs"
},
"library": {
"type": "string",
"description": "Library identifier (e.g., \"laravel/docs\")"
},
"topic": {
"type": "string",
"description": "Topic to search for within the library"
},
"tokens": {
"type": "integer",
"default": 500,
"description": "Maximum token count to retrieve"
},
"description": {
"type": "string",
"description": "Human-readable description of the source"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of tags for this source"
}
},
"additionalProperties": false
},
"treeSource": {
"description": "Generates a hierarchical visualization of directory structures",
"properties": {
Expand Down
2 changes: 2 additions & 0 deletions src/Application/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use Butschster\ContextGenerator\Modifier\PhpSignature\PhpSignatureModifierBootloader;
use Butschster\ContextGenerator\Modifier\Sanitizer\SanitizerModifierBootloader;
use Butschster\ContextGenerator\Source\Composer\ComposerSourceBootloader;
use Butschster\ContextGenerator\Source\Docs\DocsSourceBootloader;
use Butschster\ContextGenerator\Source\File\FileSourceBootloader;
use Butschster\ContextGenerator\Source\GitDiff\GitDiffSourceBootloader;
use Butschster\ContextGenerator\Source\Github\GithubSourceBootloader;
Expand Down Expand Up @@ -73,6 +74,7 @@ protected function defineBootloaders(): array
GitDiffSourceBootloader::class,
TreeSourceBootloader::class,
McpSourceBootloader::class,
DocsSourceBootloader::class,

// Modifiers
PhpContentFilterBootloader::class,
Expand Down
136 changes: 136 additions & 0 deletions src/McpServer/Action/Tools/Docs/DocsSearchAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

declare(strict_types=1);

namespace Butschster\ContextGenerator\McpServer\Action\Tools\Docs;

use Butschster\ContextGenerator\Lib\HttpClient\HttpClientInterface;
use Butschster\ContextGenerator\McpServer\Attribute\InputSchema;
use Butschster\ContextGenerator\McpServer\Attribute\Tool;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
use Mcp\Types\TextContent;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;

#[Tool(
name: 'docs-search',
description: 'Search for documentation libraries available. Use "context-request" tool to get the context documents.',
)]
#[InputSchema(
name: 'query',
type: 'string',
description: 'The search query to find documentation libraries',
required: true,
)]
final readonly class DocsSearchAction
{
private const string CONTEXT7_SEARCH_URL = 'https://context7.com/api/v1/search';

public function __construct(
private LoggerInterface $logger,
private HttpClientInterface $httpClient,
) {}

#[Post(path: '/tools/call/docs-search', name: 'tools.docs-search')]
public function __invoke(ServerRequestInterface $request): CallToolResult
{
$this->logger->info('Processing docs-search tool');

// Get params from the parsed body for POST requests
$parsedBody = $request->getParsedBody();
$query = \trim($parsedBody['query'] ?? '');

if (empty($query)) {
return new CallToolResult([
new TextContent(
text: 'Error: Missing query parameter',
),
], isError: true);
}

try {
$url = self::CONTEXT7_SEARCH_URL . '?' . \http_build_query(['query' => $query]);

$this->logger->debug('Sending request to Context7 search API', [
'url' => $url,
]);

$headers = [
'User-Agent' => 'CTX Bot',
'Accept' => 'application/json',
];

$response = $this->httpClient->get($url, $headers);

if (!$response->isSuccess()) {
$statusCode = $response->getStatusCode();
$this->logger->error('Context7 search request failed', [
'statusCode' => $statusCode,
]);
return new CallToolResult([
new TextContent(
text: "Context7 search request failed with status code {$statusCode}",
),
], isError: true);
}

$data = $response->getJson(true);

if (!isset($data['results']) || !\is_array($data['results'])) {
$this->logger->warning('Unexpected response format from Context7', [
'response' => $data,
]);
return new CallToolResult([
new TextContent(
text: 'Unexpected response format from Context7 search API',
),
], isError: true);
}

// Limit the number of results if needed
$results = $data['results'];

$this->logger->info('Documentation libraries found', [
'count' => \count($results),
'query' => $query,
]);

// Format the results for display
$formattedResults = \array_map(static fn(array $library) => [
'id' => $library['id'] ?? '',
'title' => $library['title'] ?? '',
'description' => $library['description'] ?? '',
'branch' => $library['branch'] ?? 'main',
'lastUpdateDate' => $library['lastUpdateDate'] ?? '',
'totalTokens' => $library['totalTokens'] ?? 0,
'totalPages' => $library['totalPages'] ?? 0,
'usage' => \sprintf(
"Use in your context config: { type: 'docs', library: '%s', topic: 'your-topic' }",
\ltrim($library['id'] ?? '', '/'),
),
], $results);

return new CallToolResult([
new TextContent(
text: \json_encode([
'count' => \count($formattedResults),
'results' => $formattedResults,
], JSON_PRETTY_PRINT),
),
]);
} catch (\Throwable $e) {
$this->logger->error('Error searching documentation libraries', [
'query' => $query,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);

return new CallToolResult([
new TextContent(
text: 'Error: ' . $e->getMessage(),
),
], isError: true);
}
}
}
8 changes: 8 additions & 0 deletions src/McpServer/McpConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ final class McpConfig extends InjectableConfig
'prompt_operations' => [
'enable' => true,
],
'docs_tools' => [
'enable' => false,
],
'custom_tools' => [
'enable' => true,
'max_runtime' => 30,
Expand Down Expand Up @@ -72,6 +75,11 @@ public function isPromptOperationsEnabled(): bool
return $this->config['prompt_operations']['enable'] ?? false;
}

public function isDocsToolsEnabled(): bool
{
return $this->config['docs_tools']['enable'] ?? true;
}

public function commonPromptsEnabled(): bool
{
return $this->config['common_prompts']['enable'] ?? true;
Expand Down
13 changes: 11 additions & 2 deletions src/McpServer/McpServerBootloader.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Butschster\ContextGenerator\McpServer\Action\Prompts\ProjectStructurePromptAction;
use Butschster\ContextGenerator\McpServer\Action\Resources\GetDocumentContentResourceAction;
use Butschster\ContextGenerator\McpServer\Action\Resources\JsonSchemaResourceAction;
use Butschster\ContextGenerator\McpServer\Action\Tools\Docs\DocsSearchAction;
use Butschster\ContextGenerator\McpServer\Action\Resources\ListResourcesAction;
use Butschster\ContextGenerator\McpServer\Action\Tools\Context\ContextAction;
use Butschster\ContextGenerator\McpServer\Action\Tools\Context\ContextGetAction;
Expand Down Expand Up @@ -80,6 +81,9 @@ public function init(EnvironmentInterface $env): void
'context_operations' => [
'enable' => (bool) $env->get('MCP_CONTEXT_OPERATIONS', !$isCommonProject),
],
'docs_tools' => [
'enable' => (bool) $env->get('MCP_DOCS_TOOLS_ENABLED', false),
],
'prompt_operations' => [
'enable' => (bool) $env->get('MCP_PROMPT_OPERATIONS', false),
],
Expand All @@ -88,7 +92,7 @@ public function init(EnvironmentInterface $env): void
'max_runtime' => (int) $env->get('MCP_TOOL_MAX_RUNTIME', 30),
],
'common_prompts' => [
'enable' => (bool) $env->get('MCP_COMMON_PROMPTS', true),
'enable' => (bool) $env->get('MCP_COMMON_PROMPTS', true),
],
],
);
Expand Down Expand Up @@ -132,7 +136,6 @@ interface: ProjectServiceInterface::class,

private function actions(McpConfig $config): array
{

$actions = [
// Prompts controllers
GetPromptAction::class,
Expand Down Expand Up @@ -172,6 +175,12 @@ private function actions(McpConfig $config): array
];
}

if ($config->isDocsToolsEnabled()) {
$actions = [
...$actions,
DocsSearchAction::class,
];
}
if ($config->isFileOperationsEnabled()) {
$actions = [
...$actions,
Expand Down
1 change: 1 addition & 0 deletions src/McpServer/context.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ documents:
- type: file
sourcePaths:
- ./Tool
- ./Action/Tools

- description: "MCP Server routing"
outputPath: "mcp/routing.md"
Expand Down
42 changes: 42 additions & 0 deletions src/Source/Docs/DocsSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Butschster\ContextGenerator\Source\Docs;

use Butschster\ContextGenerator\Source\BaseSource;

/**
* Source for retrieving documentation from Context7 service
*/
final class DocsSource extends BaseSource
{
/**
* @param string $library Library identifier (e.g., "laravel/docs")
* @param string $topic Topic to search for within the library
* @param string $description Human-readable description
* @param int $tokens Maximum token count to retrieve
* @param array<non-empty-string> $tags
*/
public function __construct(
public readonly string $library,
public readonly string $topic,
string $description = '',
public readonly int $tokens = 2000,
array $tags = [],
) {
parent::__construct(description: $description, tags: $tags);
}

#[\Override]
public function jsonSerialize(): array
{
return \array_filter([
'type' => 'docs',
...parent::jsonSerialize(),
'library' => $this->library,
'topic' => $this->topic,
'tokens' => $this->tokens,
], static fn($value) => $value !== null && $value !== '' && $value !== []);
}
}
Loading
Loading