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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -81,21 +81,33 @@ 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 }}
```

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]') }}
```

Expand Down
7 changes: 4 additions & 3 deletions engine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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":
Expand Down
26 changes: 25 additions & 1 deletion engine/globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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 {}

Copy link

Copilot AI Aug 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pass statement at the end of the function is unnecessary and should be removed as it serves no purpose after the return statement.

Suggested change

Copilot uses AI. Check for mistakes.
return get_mcp_resource
9 changes: 9 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
44 changes: 41 additions & 3 deletions tests/e2e/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."}

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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

2 changes: 1 addition & 1 deletion tests/templates/spec2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
4 changes: 2 additions & 2 deletions tests/templates/spec3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand All @@ -15,4 +15,4 @@

## Some math...

{{ mcp('test-mcp', 'add', {'a': 5}) }}
{{ call_tool('test-mcp', 'add', {'a': 5}) }}
2 changes: 1 addition & 1 deletion tests/templates/spec4.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
5 changes: 5 additions & 0 deletions tests/templates/spec5.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Task Template

## Information from greeting service

{{ get_resource('test-mcp', 'greeting://foobar') }}
5 changes: 5 additions & 0 deletions tests/templates/spec6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Task Template

## Information from greeting service

{{ get_resource('test-mcp', 'greeting://'+name) }}