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
17 changes: 15 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ ContextKit is a CLI tool and MCP (Model Context Protocol) client for creating sp
- `init.py` - Project initialization (creates `.cxk/` config directory)
- `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
- **MCP configuration**: `util/mcp/config.py` - Pydantic models for MCP server configs
- **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
- **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
- **User prompts**: `prompt/` - Interactive variable collection

## Core Concepts
Expand All @@ -24,6 +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

## Common Commands

Expand All @@ -35,6 +39,9 @@ uv sync
# Run tests
uv run pytest

# Run specific test
uv run pytest tests/test_specific.py


# Linting and formatting
uv run ruff check
Expand All @@ -54,6 +61,12 @@ python cxk.py mcp add-http server-name http://localhost:8000
# Create spec from template
python cxk.py create-spec path/to/template.md
uv run cxk.py create-spec tests/templates/spec1.md --var additional_context=aa --var ticket='{"id":1}'

# Create spec with output file
uv run cxk.py create-spec tests/templates/spec1.md --output result.md

# Pipe template content
cat tests/templates/spec1.md | uv run cxk.py create-spec --var ticket='{"id":1}'
```

## Key Files
Expand Down
27 changes: 10 additions & 17 deletions commands/create_spec.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import json
import logging
import os
import sys

from engine import TemplateEngine, TemplateParseError
from prompt import collect_var_value
from prompt import PromptHelper
from state import State
from util.parse import parse_input_string


async def handle_create_spec(
spec_template: str | None,
state: State,
output_file: str | None = None,
var_overrides: list[str] | None = None,
verbose: bool = False,
):
log_level = logging.DEBUG if verbose else logging.WARNING
logging.basicConfig(level=log_level, format="%(message)s", force=True)
prompt_helper = PromptHelper(state)

# Detect piped input (stdin not a TTY) and ensure there's data before using it
stdin_piped = not sys.stdin.isatty()
Expand All @@ -30,7 +30,7 @@ async def handle_create_spec(
logging.error(f"Error: Template file '{spec_template}' not found")
sys.exit(1)

template_engine = TemplateEngine.from_file(template_path)
template_engine = TemplateEngine.from_file(template_path, state, prompt_helper)
elif stdin_piped:
try:
template_str = sys.stdin.read()
Expand All @@ -41,7 +41,7 @@ async def handle_create_spec(
logging.error("Error: No data received on stdin for template")
sys.exit(1)

template_engine = TemplateEngine.from_string(template_str)
template_engine = TemplateEngine.from_string(template_str, state, prompt_helper)
else:
logging.error("Error: Missing spec_template argument (or provide template via stdin)")
sys.exit(1)
Expand All @@ -68,18 +68,11 @@ async def handle_create_spec(
raw_value = provided_vars[var]

else:
raw_value = await collect_var_value(var)
raw_value = await prompt_helper.collect_var_value(var)
logging.info(f" {var}: {raw_value}")

# Try to parse as JSON if it looks like JSON
if raw_value and (raw_value.strip().startswith("{") or raw_value.strip().startswith("[")):
try:
collected_vars[var] = json.loads(raw_value)
except json.JSONDecodeError:
# If it's not valid JSON, use as string
collected_vars[var] = raw_value
else:
collected_vars[var] = raw_value
collected_vars[var] = parse_input_string(raw_value)

else:
logging.info("No variables found in template")

Expand Down
2 changes: 1 addition & 1 deletion commands/mcp.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from pydantic import BaseModel

from mcp_client.config import SSEServerConfig, StdioServerConfig
from state import State
from util.mcp.config import SSEServerConfig, StdioServerConfig


class MCPAddSSEContext(BaseModel):
Expand Down
7 changes: 5 additions & 2 deletions cxk.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import argparse
import asyncio
import logging
import sys

from commands.create_spec import handle_create_spec
Expand Down Expand Up @@ -61,7 +62,9 @@ async def main():
await handle_init(state)

elif args.command == "create-spec":
await handle_create_spec(args.spec_template, args.output, args.var, args.verbose)
log_level = logging.DEBUG if args.verbose else logging.WARNING
logging.basicConfig(level=log_level, format="%(message)s", force=True)
await handle_create_spec(args.spec_template, state, args.output, args.var)

elif args.command == "mcp":
if not args.mcp_command:
Expand Down Expand Up @@ -94,7 +97,7 @@ async def main():
await handle_mcp(state, mcp_context)

except Exception as e:
print(f"Error: {e}", file=sys.stderr)
logging.exception(f"Error: {e}")
sys.exit(1)


Expand Down
38 changes: 33 additions & 5 deletions engine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@

from jinja2 import Environment, FileSystemLoader, Template, meta, select_autoescape

from engine.globals import create_mcp_tool_function
from prompt import PromptHelper
from state import State


class TemplateParseError(Exception):
"""Raised when template parsing fails"""
Expand All @@ -14,20 +18,32 @@ class TemplateEngine:
"""Abstract away the jinja2 template engine with clean factory methods"""

def __init__(
self, env: Environment, template: Template, source_path: Path | None = None, source_string: str | None = None
self,
env: Environment,
template: Template,
state: State,
prompt_helper: PromptHelper,
source_path: Path | None = None,
source_string: str | None = None,
):
"""Private constructor - use from_file() or from_string() instead"""
self.env = env
self.template = template
self._source_path = source_path
self._source_string = source_string
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)

@classmethod
def from_file(cls, path: str | Path) -> "TemplateEngine":
def from_file(cls, path: str | Path, state: State, prompt_helper: PromptHelper) -> "TemplateEngine":
"""Create a TemplateEngine from a template file.

Args:
path: Path to the template file
state: State object containing project configuration

Returns:
TemplateEngine instance
Expand Down Expand Up @@ -55,14 +71,19 @@ def from_file(cls, path: str | Path) -> "TemplateEngine":
except Exception as e:
raise TemplateParseError(f"Failed to load template from {path}: {e}") from e

return cls(env=env, template=template, source_path=path, source_string=None)
return cls(
env=env, template=template, state=state, source_path=path, source_string=None, prompt_helper=prompt_helper
)

@classmethod
def from_string(cls, template_string: str, name: str = "<stdio>") -> "TemplateEngine":
def from_string(
cls, template_string: str, state: State, prompt_helper: PromptHelper, name: str = "<stdio>"
) -> "TemplateEngine":
"""Create a TemplateEngine from a template string.

Args:
template_string: The template content as a string
state: State object containing project configuration
name: Optional name for the template (for debugging)

Returns:
Expand All @@ -85,7 +106,14 @@ def from_string(cls, template_string: str, name: str = "<stdio>") -> "TemplateEn
# Store the name in the template for better error messages
template.name = name

return cls(env=env, template=template, source_path=None, source_string=template_string)
return cls(
env=env,
template=template,
state=state,
source_path=None,
source_string=template_string,
prompt_helper=prompt_helper,
)

@property
def source(self) -> str:
Expand Down
41 changes: 41 additions & 0 deletions engine/globals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import logging
from typing import Any

from mcp import types
from mcp.shared.metadata_utils import get_display_name

from mcp_client.client_session_provider import get_client_session_by_server
from prompt import PromptHelper
from state import State
from util.parse import parse_input_string


def create_mcp_tool_function(state: State, prompt_helper: PromptHelper):
"""Create a call_mcp_tool function with state bound."""

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()
# Call the tool with collected input
try:
full_arguments = await prompt_helper.get_full_args(tools, tool_name, args)

logging.debug(f"Full arguments for tool {tool_name}: {full_arguments}")
result = await session.call_tool(tool_name, arguments=full_arguments)
result_unstructured = result.content[0]
if isinstance(result_unstructured, types.TextContent):
return parse_input_string(result_unstructured.text)
else:
return ""
except Exception as e:
logging.error(f"Error calling tool {tool_name}: {e}")
logging.error("Available tools:")
for tool in tools.tools:
logging.error(f" - {tool.name}: {get_display_name(tool)}")
return ""

return call_mcp_tool
Empty file added mcp_client/__init__.py
Empty file.
Loading