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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@ You can also filter resources to mask sensitive information:
{{ support_ticket_info | regex_replace(r'\b[\w.+-]+@[\w.-]+\.\w+\b', '[EMAIL_REDACTED]') }}
```

### Interactively selecting MCP resources and tools

Template variables can be given values either directly or by selecting an MCP tool to call. For example:

```
# Spec Template (spec.md)

## Task description
{{ task }}
```

Running create-spec will then prompt you to either provide a direct value for `task` or select an MCP server, tool and args to call to fetch the task description.


### Initialize a project
```
cxk init
Expand Down
21 changes: 18 additions & 3 deletions engine/globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
from mcp.shared.metadata_utils import get_display_name
from pydantic import AnyUrl

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


def create_mcp_tool_function(prompt_helper: PromptHelper):
def create_mcp_tool_function(prompt_helper: PromptHelper, state: State):
"""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]:
Expand All @@ -26,6 +27,13 @@ async def call_mcp_tool(server: str, tool_name: str, args: dict) -> str | dict[s
result_unstructured = result.content[0]
if isinstance(result_unstructured, types.TextContent):
return parse_input_string(result_unstructured.text)
elif isinstance(result_unstructured, types.ImageContent | types.AudioContent):
if state.config_dir:
file_path = handle_binary_content(state.config_dir, result_unstructured)
return file_path if file_path else ""
else:
logging.error("Config directory not available for binary data storage")
return ""
else:
return ""
except Exception as e:
Expand All @@ -38,7 +46,7 @@ async def call_mcp_tool(server: str, tool_name: str, args: dict) -> str | dict[s
return call_mcp_tool


def create_mcp_resource_function():
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]:
Expand All @@ -50,6 +58,13 @@ async def get_mcp_resource(server: str, resource_uri: str) -> str | dict[str, An
result_unstructured = resource.contents[0]
if isinstance(result_unstructured, types.TextResourceContents):
return parse_input_string(result_unstructured.text)
elif isinstance(result_unstructured, types.BlobResourceContents):
if state.config_dir:
file_path = handle_binary_content(state.config_dir, result_unstructured)
return file_path if file_path else ""
else:
logging.error("Config directory not available for binary data storage")
return ""
else:
logging.debug(f"Resource {resource_uri} returned non-text content")
return ""
Expand Down
4 changes: 2 additions & 2 deletions engine/template_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ def __init__(
self._prompt_helper = prompt_helper

# Add global functions to env to support MCP tools and resources
self.env.globals["call_tool"] = create_mcp_tool_function(self._prompt_helper)
self.env.globals["get_resource"] = create_mcp_resource_function()
self.env.globals["call_tool"] = create_mcp_tool_function(self._prompt_helper, self._state)
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
3 changes: 3 additions & 0 deletions mcp_client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""MCP (Model Context Protocol) client implementation."""

from .binary_data_handler import handle_binary_content, save_binary_data_to_file
from .client_session_provider import get_client_session_by_server
from .config import HTTPServerConfig, MCPServersConfig, SSEServerConfig, StdioServerConfig
from .session_manager import MCPSessionManager, get_session_manager
Expand All @@ -14,4 +15,6 @@
"KeychainTokenStorageWithFallback",
"get_session_manager",
"get_client_session_by_server",
"handle_binary_content",
"save_binary_data_to_file",
]
76 changes: 76 additions & 0 deletions mcp_client/binary_data_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import base64
import logging
import mimetypes
from pathlib import Path
from uuid import uuid4

from mcp import types


def save_binary_data_to_file(config_dir: Path, data: str, mime_type: str, file_prefix: str = "binary") -> str:
"""
Save binary data (base64-encoded) to a file in the config directory.

Args:
config_dir: The .cxk config directory path
data: Base64-encoded binary data
mime_type: MIME type of the data (e.g., 'image/png', 'audio/wav')
file_prefix: Prefix for the generated filename

Returns:
Relative path to the saved file from the project root

Raises:
Exception: If file save fails (errors are logged but not re-raised)
"""
try:
# Create files directory if it doesn't exist
files_dir = config_dir / "files"
files_dir.mkdir(exist_ok=True)

# Generate unique filename with appropriate extension
extension = mimetypes.guess_extension(mime_type) or ""
filename = f"{file_prefix}_{uuid4().hex[:8]}{extension}"
file_path = files_dir / filename

# Decode base64 data and write to file
binary_data = base64.b64decode(data)
with open(file_path, "wb") as f:
f.write(binary_data)

# Return relative path from project root
relative_path = file_path.relative_to(config_dir.parent)
logging.info(f"Saved binary data to {relative_path}")
return str(relative_path)

except Exception as e:
error_msg = f"Failed to save binary data: {e}"
logging.error(error_msg)
raise Exception(error_msg) from e


def handle_binary_content(config_dir: Path, content) -> str:
"""
Handle binary content from MCP responses, saving to file and returning path.

Args:
config_dir: The .cxk config directory path
content: MCP content object (ImageContent, AudioContent, or BlobResourceContents)

Returns:
Relative path to the saved file or empty string if handling fails
"""
try:
if isinstance(content, types.ImageContent):
return save_binary_data_to_file(config_dir, content.data, content.mimeType, "image")
elif isinstance(content, types.AudioContent):
return save_binary_data_to_file(config_dir, content.data, content.mimeType, "audio")
elif isinstance(content, types.BlobResourceContents):
# For blob resources, we don't have mime type info, so we'll save as generic file
return save_binary_data_to_file(config_dir, content.blob, "application/octet-stream", "blob")
Comment on lines +69 to +70
Copy link

Copilot AI Aug 27, 2025

Choose a reason for hiding this comment

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

The comment is incorrect. BlobResourceContents has a mimeType field that should be used instead of hardcoding 'application/octet-stream'.

Suggested change
# For blob resources, we don't have mime type info, so we'll save as generic file
return save_binary_data_to_file(config_dir, content.blob, "application/octet-stream", "blob")
# For blob resources, use the provided mimeType field
return save_binary_data_to_file(config_dir, content.blob, content.mimeType, "blob")

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +70
Copy link

Copilot AI Aug 27, 2025

Choose a reason for hiding this comment

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

Should use content.mimeType instead of hardcoded 'application/octet-stream'. BlobResourceContents has a mimeType field available.

Suggested change
# For blob resources, we don't have mime type info, so we'll save as generic file
return save_binary_data_to_file(config_dir, content.blob, "application/octet-stream", "blob")
# Use the mimeType field from BlobResourceContents if available
return save_binary_data_to_file(config_dir, content.blob, content.mimeType, "blob")

Copilot uses AI. Check for mistakes.
else:
logging.warning(f"Unsupported binary content type: {type(content)}")
return ""
except Exception as e:
logging.error(f"Error handling binary content: {e}")
return ""
24 changes: 17 additions & 7 deletions prompt/prompt_helper.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import logging

import questionary
from mcp import types

from mcp_client import get_session_manager
from mcp_client import get_session_manager, handle_binary_content
from state import State


Expand Down Expand Up @@ -221,11 +222,20 @@ async def _collect_var_value_from_mcp(self, var_name: str) -> str:
# Handle list of content items
content_parts = []
for item in result.content:
# Try to get text content first (safely)
text_content = getattr(item, "text", None)
if text_content:
content_parts.append(str(text_content))
# For non-text content, convert to string
# Handle binary content types
if isinstance(item, types.ImageContent | types.AudioContent):
Copy link

Copilot AI Aug 27, 2025

Choose a reason for hiding this comment

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

The isinstance check should include types.BlobResourceContents to handle all binary content types consistently with the binary_data_handler module.

Suggested change
if isinstance(item, types.ImageContent | types.AudioContent):
if isinstance(item, (types.ImageContent, types.AudioContent, types.BlobResourceContents)):

Copilot uses AI. Check for mistakes.
if self._state.config_dir:
file_path = handle_binary_content(self._state.config_dir, item)
if file_path:
content_parts.append(file_path)
else:
logging.error(f"Failed to save binary content for variable '{var_name}'")
else:
logging.error("Config directory not available for binary data storage")
# Handle text content
elif isinstance(item, types.TextContent):
content_parts.append(str(item.text))
# For other content types, convert to string
else:
content_parts.append(str(item))
content = "\n".join(content_parts)
Expand Down Expand Up @@ -279,6 +289,6 @@ async def _select_mcp_tool(self, tools) -> str | None:
choice_title = f"{tool.name} - {description}"
choices.append(questionary.Choice(choice_title, tool.name))

tool_name = await questionary.select("Select a tool:", choices=choices).ask_async()
tool_name = await questionary.select("Select a tool:", choices=choices, use_search_filter=True).ask_async()

return tool_name
Binary file added tests/files/image1.avif
Binary file not shown.
10 changes: 9 additions & 1 deletion tests/mcp_test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from typing import Any

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp import FastMCP, Image

# Create an MCP server
mcp = FastMCP("Test-Server", "1.0.0")
Expand Down Expand Up @@ -32,3 +32,11 @@ def jsonTest(cloudId: str, ticketId: str, optional_other: str | None = None) ->
def get_greeting(name: str) -> str:
"""Get a personalized greeting"""
return f"Hello, {name}!"


# Add a binary tool (tests/files/image1.avif)
@mcp.tool()
def get_blob() -> Image:
"""Get a binary blob test tool"""
with open("tests/files/image1.avif", "rb") as f:
return Image(data=f.read(), format="avif")
7 changes: 7 additions & 0 deletions tests/templates/spec10.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Task Template

## Image links

{% set image = call_tool('test-mcp', 'get_blob', {}) %}

{{ image }}
2 changes: 1 addition & 1 deletion tests/templates/spec9.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Task Template

## Issue description
## Image links

{% set issue = call_tool('github', 'get_issue', {'issue_number': 17, 'owner': 'eyalzh', 'repo': 'browser-control-mcp'}) %}

Expand Down
Loading