diff --git a/CLAUDE.md b/CLAUDE.md index 78cad78..3bcdff1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ ContextKit is a CLI tool and MCP (Model Context Protocol) client for creating sp - `mcp.py` - MCP server management (add-sse, add-stdio, add-http) - `create_spec.py` - Template rendering with variable collection - **Template engine**: `engine/` - Jinja2-based template processing with async support and MCP tool integration - - `engine/globals.py` - Global Jinja2 functions including `mcp()` for calling MCP tools from templates + - `engine/globals.py` - Global Jinja2 functions including `call_tool()` for calling MCP tools from templates - **MCP client**: `mcp_client/` - MCP protocol client implementation - `mcp_client/config.py` - Pydantic models for MCP server configs (stdio, SSE, HTTP) - `mcp_client/client_session_provider.py` - Connection management for MCP servers @@ -27,7 +27,7 @@ ContextKit is a CLI tool and MCP (Model Context Protocol) client for creating sp 2. **MCP servers**: Configured via CLI commands, stored in `.cxk/mcp.json` 3. **Spec templates**: Jinja2 templates with variables that get filled from MCP resources 4. **Context variables**: Can be automatic MCP resources or user-provided values -5. **MCP tool functions**: Templates can call MCP tools directly using `{{ mcp('server', 'tool', args) }}` syntax +5. **MCP tool functions**: Templates can call MCP tools directly using `{{ call_tool('server', 'tool', args) }}` syntax ## Common Commands diff --git a/README.md b/README.md index b903563..7dbee3f 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ TBD (show template, generation of two specs, and the spec files) ``` # Spec Template (spec.md) -{% set ticket = mcp('jira', 'getJiraIssue', {'cloudId': '1234', 'issueKey': ticket_id}) %} +{% set ticket = call_tool('jira', 'getJiraIssue', {'cloudId': '1234', 'issueKey': ticket_id}) %} ### Description {{ ticket.description }} @@ -81,13 +81,25 @@ cxk create-spec spec.md --var ticket_id=ACME-123 This will fetch the ticket and add its description to the spec file. +### Add MCP resources with variables +``` +# Spec Template (spec.md) +## PRD +{{ get_resource('doc-storage-service', 'docs://'+prd_id) }} +``` + +Generating the spec with a PRD ID: +``` +cxk create-spec spec.md --var prd_id=PRD-456 +``` + ### Filtering context MCP resources can quickly oversaturate the context. With the template engine, you can apply filters and selectors to include only relevant parts of resources. For example: ``` ## Ticket description -{% set ticket_info = mcp('jira', 'getJiraIssue', {'issueKey': 'ACME-4432'}) %} +{% set ticket_info = call_tool('jira', 'getJiraIssue', {'issueKey': 'ACME-4432'}) %} {{ ticket_info.fields.description }} ``` @@ -95,7 +107,7 @@ You can also filter resources to mask sensitive information: ``` ## Support ticket -{% set support_ticket_info = mcp('support', 'getTicket', 'ACME-9912') %} +{% set support_ticket_info = call_tool('support', 'getTicket', 'ACME-9912') %} {{ support_ticket_info | regex_replace(r'\b[\w.+-]+@[\w.-]+\.\w+\b', '[EMAIL_REDACTED]') }} ``` diff --git a/engine/__init__.py b/engine/__init__.py index 025b703..6273f49 100644 --- a/engine/__init__.py +++ b/engine/__init__.py @@ -3,7 +3,7 @@ from jinja2 import Environment, FileSystemLoader, Template, meta, select_autoescape -from engine.globals import create_mcp_tool_function +from engine.globals import create_mcp_resource_function, create_mcp_tool_function from prompt import PromptHelper from state import State @@ -34,8 +34,9 @@ def __init__( self._state = state self._prompt_helper = prompt_helper - # Add global functions to env - self.env.globals["mcp"] = create_mcp_tool_function(self._state, self._prompt_helper) + # Add global functions to env to support MCP tools and resources + self.env.globals["call_tool"] = create_mcp_tool_function(self._state, self._prompt_helper) + self.env.globals["get_resource"] = create_mcp_resource_function(self._state) @classmethod def from_file(cls, path: str | Path, state: State, prompt_helper: PromptHelper) -> "TemplateEngine": diff --git a/engine/globals.py b/engine/globals.py index 3ffc3f9..1ab5243 100644 --- a/engine/globals.py +++ b/engine/globals.py @@ -3,6 +3,7 @@ from mcp import types from mcp.shared.metadata_utils import get_display_name +from pydantic import AnyUrl from mcp_client.client_session_provider import get_client_session_by_server from prompt import PromptHelper @@ -16,7 +17,6 @@ def create_mcp_tool_function(state: State, prompt_helper: PromptHelper): async def call_mcp_tool(server: str, tool_name: str, args: dict) -> str | dict[str, Any]: logging.info(f"Calling MCP tool: {tool_name} on server: {server} with args: {args}") async with get_client_session_by_server(server, state) as session: - # Initialize the connection await session.initialize() tools = await session.list_tools() @@ -39,3 +39,27 @@ async def call_mcp_tool(server: str, tool_name: str, args: dict) -> str | dict[s return "" return call_mcp_tool + + +def create_mcp_resource_function(state: State): + """Create a get_mcp_resource function with state bound.""" + + async def get_mcp_resource(server: str, resource_uri: str) -> str | dict[str, Any]: + logging.info(f"Getting MCP resource: {resource_uri} in server: {server}") + async with get_client_session_by_server(server, state) as session: + await session.initialize() + + # Fetch the resource (first content item for now) + try: + resource = await session.read_resource(AnyUrl(resource_uri)) + result_unstructured = resource.contents[0] + if isinstance(result_unstructured, types.TextResourceContents): + return parse_input_string(result_unstructured.text) + else: + logging.debug(f"Resource {resource_uri} returned non-text content") + return "" + except Exception as e: + logging.error(f"Error fetching resource {resource_uri}: {e}") + return {} + + return get_mcp_resource diff --git a/tests/README.md b/tests/README.md index 30c5dc6..010a9cd 100644 --- a/tests/README.md +++ b/tests/README.md @@ -52,4 +52,13 @@ uv run cxk.py create-spec tests/templates/spec3.md --var additional_context=aa ``` uv run cxk.py create-spec tests/templates/spec4.md --var ticket_id=ACME-123 +``` + +### With MCP resources: +``` +uv run cxk.py create-spec tests/templates/spec5.md +``` + +``` +uv run cxk.py create-spec tests/templates/spec6.md --var name=MrBean ``` \ No newline at end of file diff --git a/tests/e2e/test_e2e.py b/tests/e2e/test_e2e.py index 09efa9d..23520a6 100644 --- a/tests/e2e/test_e2e.py +++ b/tests/e2e/test_e2e.py @@ -690,7 +690,7 @@ def test_create_spec_with_mcp_call(self, temp_git_repo): assert result.returncode == 0 # Verify that MCP tool was called and returned expected data - # The template uses: mcp('test-mcp', 'jsonTest', {'cloudId': '1234', 'ticketId': 'ACME-123'}) + # The template uses: call_tool('test-mcp', 'jsonTest', {'cloudId': '1234', 'ticketId': 'ACME-123'}) # The jsonTest tool returns: {"id": "1234 - ACME-123", "summary": "Summary for ACME-123", # "description": "This is a mock Jira ticket description."} @@ -724,8 +724,8 @@ def test_create_spec_with_partial_mcp_call(self, temp_git_repo): # Run create-spec command with test runner and additional_context variable # The template has: - # - mcp('test-mcp', 'jsonTest', {'cloudId': '1234'}) - missing 'ticketId' parameter - # - mcp('test-mcp', 'add', {'a': 5}) - missing 'b' parameter + # - call_tool('test-mcp', 'jsonTest', {'cloudId': '1234'}) - missing 'ticketId' parameter + # - call_tool('test-mcp', 'add', {'a': 5}) - missing 'b' parameter result = self.run_cli( [ "create-spec", @@ -761,3 +761,41 @@ def test_create_spec_with_partial_mcp_call(self, temp_git_repo): assert "## Additional context" in result.stdout assert "## Some math..." in result.stdout + def test_create_spec_with_mcp_resource(self, temp_git_repo): + """Test create-spec with template that includes MCP resource calls.""" + # Initialize project first + init_result = self.run_cli(["init"], cwd=temp_git_repo) + assert init_result.returncode == 0 + + # Add test MCP server + server_path = Path(__file__).parent.parent / "mcp_test_server.py" + add_server_result = self.run_cli( + ["mcp", "add-stdio", "test-mcp", "--", "uv", "run", "mcp", "run", str(server_path)], + cwd=temp_git_repo, + ) + assert add_server_result.returncode == 0 + + # Use the existing spec5.md template that has MCP resource calls + template_path = Path(__file__).parent.parent / "templates" / "spec5.md" + + # Run create-spec command + result = self.run_cli( + [ + "create-spec", + "--verbose", + str(template_path), + ], + cwd=temp_git_repo, + ) + + assert result.returncode == 0 + + # Verify that MCP resource was called and returned expected data + # The template uses: get_resource('test-mcp', 'greeting://foobar') + # The get_greeting resource returns: "Hello, foobar!" + assert "Hello, foobar!" in result.stdout + + # Verify template structure is preserved + assert "# Task Template" in result.stdout + assert "## Information from greeting service" in result.stdout + diff --git a/tests/templates/spec2.md b/tests/templates/spec2.md index 8b15073..9d465fe 100644 --- a/tests/templates/spec2.md +++ b/tests/templates/spec2.md @@ -2,7 +2,7 @@ ## Ticket description -{% set ticket = mcp('test-mcp', 'jsonTest', {'cloudId': '1234', 'ticketId': 'ACME-123'}) %} +{% set ticket = call_tool('test-mcp', 'jsonTest', {'cloudId': '1234', 'ticketId': 'ACME-123'}) %} {{ ticket.id }} diff --git a/tests/templates/spec3.md b/tests/templates/spec3.md index 93778c9..526b657 100644 --- a/tests/templates/spec3.md +++ b/tests/templates/spec3.md @@ -2,7 +2,7 @@ ## Ticket description -{% set ticket = mcp('test-mcp', 'jsonTest', {'cloudId': '1234'}) %} +{% set ticket = call_tool('test-mcp', 'jsonTest', {'cloudId': '1234'}) %} {{ ticket.id }} @@ -15,4 +15,4 @@ ## Some math... -{{ mcp('test-mcp', 'add', {'a': 5}) }} +{{ call_tool('test-mcp', 'add', {'a': 5}) }} diff --git a/tests/templates/spec4.md b/tests/templates/spec4.md index 8bc5775..82caea0 100644 --- a/tests/templates/spec4.md +++ b/tests/templates/spec4.md @@ -2,7 +2,7 @@ ## Ticket description -{% set ticket = mcp('test-mcp', 'jsonTest', {'cloudId': '1234', 'ticketId': ticket_id}) %} +{% set ticket = call_tool('test-mcp', 'jsonTest', {'cloudId': '1234', 'ticketId': ticket_id}) %} {{ ticket.id }} diff --git a/tests/templates/spec5.md b/tests/templates/spec5.md new file mode 100644 index 0000000..6ffa72e --- /dev/null +++ b/tests/templates/spec5.md @@ -0,0 +1,5 @@ +# Task Template + +## Information from greeting service + +{{ get_resource('test-mcp', 'greeting://foobar') }} diff --git a/tests/templates/spec6.md b/tests/templates/spec6.md new file mode 100644 index 0000000..4a1bb9c --- /dev/null +++ b/tests/templates/spec6.md @@ -0,0 +1,5 @@ +# Task Template + +## Information from greeting service + +{{ get_resource('test-mcp', 'greeting://'+name) }}