From 6897c5b249cf29c97e79758b8278e3f773d90df5 Mon Sep 17 00:00:00 2001 From: eyalz Date: Tue, 12 Aug 2025 16:48:41 +0300 Subject: [PATCH 1/3] Change mcp to call_tool before adding get_resource --- CLAUDE.md | 4 ++-- README.md | 6 +++--- engine/__init__.py | 2 +- tests/README.md | 5 +++++ tests/e2e/test_e2e.py | 6 +++--- tests/templates/spec2.md | 2 +- tests/templates/spec3.md | 4 ++-- tests/templates/spec4.md | 2 +- tests/templates/spec5.md | 5 +++++ 9 files changed, 23 insertions(+), 13 deletions(-) create mode 100644 tests/templates/spec5.md 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..ca747ff 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 }} @@ -87,7 +87,7 @@ MCP resources can quickly oversaturate the context. With the template engine, yo ``` ## 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 +95,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..fb61a7e 100644 --- a/engine/__init__.py +++ b/engine/__init__.py @@ -35,7 +35,7 @@ def __init__( self._prompt_helper = prompt_helper # Add global functions to env - self.env.globals["mcp"] = create_mcp_tool_function(self._state, self._prompt_helper) + self.env.globals["call_tool"] = create_mcp_tool_function(self._state, self._prompt_helper) @classmethod def from_file(cls, path: str | Path, state: State, prompt_helper: PromptHelper) -> "TemplateEngine": diff --git a/tests/README.md b/tests/README.md index 30c5dc6..3d14c15 100644 --- a/tests/README.md +++ b/tests/README.md @@ -52,4 +52,9 @@ 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 ``` \ No newline at end of file diff --git a/tests/e2e/test_e2e.py b/tests/e2e/test_e2e.py index 09efa9d..53981e1 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", 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..8964814 --- /dev/null +++ b/tests/templates/spec5.md @@ -0,0 +1,5 @@ +# Task Template + +## Information from greeting service + +{{ get_resource('greeting://foobar') }} From fb2e1b32103dd2846b1dcaf81177ae9417773435 Mon Sep 17 00:00:00 2001 From: eyalz Date: Thu, 14 Aug 2025 13:46:19 +0300 Subject: [PATCH 2/3] Add MCP resource support (text only) --- README.md | 12 ++++++++++++ engine/__init__.py | 5 +++-- engine/globals.py | 27 ++++++++++++++++++++++++++- tests/README.md | 4 ++++ tests/e2e/test_e2e.py | 38 ++++++++++++++++++++++++++++++++++++++ tests/templates/spec5.md | 2 +- tests/templates/spec6.md | 5 +++++ 7 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 tests/templates/spec6.md diff --git a/README.md b/README.md index ca747ff..7dbee3f 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,18 @@ 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: diff --git a/engine/__init__.py b/engine/__init__.py index fb61a7e..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 + # 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..8263f8f 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,28 @@ 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 {} + pass + + return get_mcp_resource diff --git a/tests/README.md b/tests/README.md index 3d14c15..010a9cd 100644 --- a/tests/README.md +++ b/tests/README.md @@ -57,4 +57,8 @@ 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 53981e1..23520a6 100644 --- a/tests/e2e/test_e2e.py +++ b/tests/e2e/test_e2e.py @@ -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/spec5.md b/tests/templates/spec5.md index 8964814..6ffa72e 100644 --- a/tests/templates/spec5.md +++ b/tests/templates/spec5.md @@ -2,4 +2,4 @@ ## Information from greeting service -{{ get_resource('greeting://foobar') }} +{{ 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) }} From c3755144b4a5022976b31f02859c972a0f7f3d9e Mon Sep 17 00:00:00 2001 From: eyalz Date: Thu, 14 Aug 2025 14:44:43 +0300 Subject: [PATCH 3/3] CR fix --- engine/globals.py | 1 - 1 file changed, 1 deletion(-) diff --git a/engine/globals.py b/engine/globals.py index 8263f8f..1ab5243 100644 --- a/engine/globals.py +++ b/engine/globals.py @@ -61,6 +61,5 @@ async def get_mcp_resource(server: str, resource_uri: str) -> str | dict[str, An except Exception as e: logging.error(f"Error fetching resource {resource_uri}: {e}") return {} - pass return get_mcp_resource