diff --git a/examples/everything/colin.toml b/examples/everything/colin.toml index d5c4e79..f636f29 100644 --- a/examples/everything/colin.toml +++ b/examples/everything/colin.toml @@ -1,10 +1,7 @@ [project] name = "everything" -version = "0.1.0" - -[paths] -models = "models" -target = "target" +model-path = "models" +output-path = "output" [[providers.llm]] model = "anthropic:claude-haiku-4-5" diff --git a/examples/everything/models/analysis.md b/examples/everything/models/analysis.md index 09ae88a..3b17aaa 100644 --- a/examples/everything/models/analysis.md +++ b/examples/everything/models/analysis.md @@ -7,13 +7,17 @@ description: Extracted insights from product data {{ colin.mcp.demo.resource('demo://greeting/Analyst') }} ## Key Pain Points -{{ ref('data') | llm_extract('List the top 3 user pain points mentioned in the feedback, as bullet points') }} + +{{ ref('data.md') | llm_extract('List the top 3 user pain points mentioned in the feedback, as bullet points') }} ## Performance Summary -{{ ref('data') | llm_extract('Summarize the technical performance in one sentence') }} + +{{ ref('data.md') | llm_extract('Summarize the technical performance in one sentence') }} ## Strengths -{{ ref('data') | llm_extract('What are users happy about? List as bullet points') }} + +{{ ref('data.md') | llm_extract('What are users happy about? List as bullet points') }} ## Analysis Guidance + {{ colin.mcp.demo.prompt('summarize', style='detailed') }} diff --git a/examples/everything/models/executive_brief.md b/examples/everything/models/executive_brief.md index 4f64a4d..32eff6f 100644 --- a/examples/everything/models/executive_brief.md +++ b/examples/everything/models/executive_brief.md @@ -5,13 +5,13 @@ description: High-level summary for leadership # Executive Brief: Q1 2025 Planning ## TL;DR -{{ ref('recommendations') | llm_extract('Summarize the top 3 priorities in one sentence each') }} +{{ ref('recommendations.md') | llm_extract('Summarize the top 3 priorities in one sentence each') }} ## Current State -{{ ref('goal_status') | llm_extract('Give a one-paragraph executive summary of goal progress') }} +{{ ref('goal_status.md') | llm_extract('Give a one-paragraph executive summary of goal progress') }} ## Detailed Recommendations -{{ ref('recommendations').content }} +{{ ref('recommendations.md').content }} --- @@ -19,5 +19,5 @@ description: High-level summary for leadership
Click to expand source data -{{ ref('data').content }} +{{ ref('data.md').content }}
diff --git a/examples/everything/models/goal_status.md b/examples/everything/models/goal_status.md index cef8818..6869eb3 100644 --- a/examples/everything/models/goal_status.md +++ b/examples/everything/models/goal_status.md @@ -7,10 +7,10 @@ description: Assessment of progress toward goals Based on current metrics and stated objectives: **Current Data:** -{{ ref('data').content }} +{{ ref('data.md').content }} **Goals:** -{{ ref('goals').content }} +{{ ref('goals.md').content }} {% llm %} Compare the current metrics from the data above against the stated goals. diff --git a/examples/everything/models/metrics_api.md b/examples/everything/models/metrics_api.md index 9d890d5..435f3d2 100644 --- a/examples/everything/models/metrics_api.md +++ b/examples/everything/models/metrics_api.md @@ -2,7 +2,8 @@ name: Metrics API description: Structured JSON output for API consumption colin: - output: json + output: + format: json --- ## product diff --git a/examples/everything/models/recommendations.md b/examples/everything/models/recommendations.md index fa85efe..7b98f2f 100644 --- a/examples/everything/models/recommendations.md +++ b/examples/everything/models/recommendations.md @@ -5,10 +5,10 @@ description: Prioritized action items based on analysis # Strategic Recommendations ## Analysis Summary -{{ ref('analysis').content }} +{{ ref('analysis.md').content }} ## Goal Status -{{ ref('goal_status') | llm_extract('Which goals are at risk or behind?') }} +{{ ref('goal_status.md') | llm_extract('Which goals are at risk or behind?') }} ## Recommended Actions diff --git a/examples/hello_world/models/summary.md b/examples/hello_world/models/summary.md index 6dec424..749119a 100644 --- a/examples/hello_world/models/summary.md +++ b/examples/hello_world/models/summary.md @@ -7,19 +7,19 @@ description: Demonstrates LLM blocks and extract filter Here's some content to work with: -{{ ref('greeting').content }} +{{ ref('greeting.md').content }} ## Extracted Info -{{ ref('greeting') | llm_extract('the main message in one sentence') }} +{{ ref('greeting.md') | llm_extract('the main message in one sentence') }} ## LLM-Generated Content {% llm %} Given this greeting: -{{ ref('greeting').content }} +{{ ref('greeting.md').content }} Write a haiku about being welcomed. {% endllm %} diff --git a/examples/hello_world/models/welcome.md b/examples/hello_world/models/welcome.md index 19249aa..beedcca 100644 --- a/examples/hello_world/models/welcome.md +++ b/examples/hello_world/models/welcome.md @@ -5,7 +5,7 @@ description: Demonstrates ref() and LLM blocks # Welcome -{{ ref('greeting').content }} +{{ ref('greeting.md').content }} --- @@ -17,7 +17,7 @@ Colin automatically compiles documents in the right order based on their depende {% llm %} Translate this greeting message for French users of the Colin library: -{{ ref('greeting').content }} +{{ ref('greeting.md').content }} The translation should feel welcoming and appropriate for a technical audience. {% endllm %} diff --git a/examples/mcp/.colin/compiled/greeting.md b/examples/mcp/.colin/compiled/greeting.md new file mode 100644 index 0000000..bf9578d --- /dev/null +++ b/examples/mcp/.colin/compiled/greeting.md @@ -0,0 +1,9 @@ +# MCP Demo + +## Resource Example + +Hello, World! Welcome to the demo. + +## Prompt Example + +Summarize the following in a detailed style. \ No newline at end of file diff --git a/examples/mcp/.colin/manifest.json b/examples/mcp/.colin/manifest.json new file mode 100644 index 0000000..1cc2515 --- /dev/null +++ b/examples/mcp/.colin/manifest.json @@ -0,0 +1,55 @@ +{ + "version": "1", + "project_name": "mcp", + "config_hash": "f6a9b770bee3be5d", + "compiled_at": "2026-01-07T21:57:28.998543Z", + "documents": { + "project://greeting.md": { + "uri": "project://greeting.md", + "source_path": null, + "source_hash": "44d23c70d58c7e85", + "output_hash": "326780bff734af74", + "output_path": "greeting.md", + "is_published": true, + "compiled_at": "2026-01-07T21:53:49.341310Z", + "refs": [ + { + "provider": "mcp", + "connection": "demo", + "method": "config", + "args": {} + }, + { + "provider": "mcp", + "connection": "demo", + "method": "resource", + "args": { + "uri": "demo://greeting/World" + } + }, + { + "provider": "mcp", + "connection": "demo", + "method": "prompt", + "args": { + "name": "summarize", + "arguments": { + "style": "detailed" + } + } + } + ], + "ref_versions": { + "{\"args\": {}, \"connection\": \"demo\", \"method\": \"config\", \"provider\": \"mcp\"}": "e0569178996485f6", + "{\"args\": {\"uri\": \"demo://greeting/World\"}, \"connection\": \"demo\", \"method\": \"resource\", \"provider\": \"mcp\"}": "6236c65af4fdeed5", + "{\"args\": {\"arguments\": {\"style\": \"detailed\"}, \"name\": \"summarize\"}, \"connection\": \"demo\", \"method\": \"prompt\", \"provider\": \"mcp\"}": "e31eb0daa2d308b8" + }, + "llm_calls": {}, + "total_cost_usd": 0.0, + "artifacts": [], + "sections": {}, + "config_hash": "f6a9b770bee3be5d" + } + }, + "cache": {} +} \ No newline at end of file diff --git a/examples/mcp/colin.toml b/examples/mcp/colin.toml new file mode 100644 index 0000000..1d17256 --- /dev/null +++ b/examples/mcp/colin.toml @@ -0,0 +1,18 @@ +[project] +name = "mcp" +model-path = "models" +output-path = "output" + +[[providers.mcp]] +name = "demo" +command = "uvx" +args = [ + "--with", + "fastmcp", + "fastmcp", + "run", + "--no-banner", + "--log-level", + "ERROR", + "examples/mcp/mcp_server.py", +] diff --git a/examples/mcp/mcp_server.py b/examples/mcp/mcp_server.py new file mode 100644 index 0000000..0a0d807 --- /dev/null +++ b/examples/mcp/mcp_server.py @@ -0,0 +1,21 @@ +"""Simple MCP server for the mcp example.""" + +from fastmcp import FastMCP + +mcp = FastMCP("demo") + + +@mcp.resource(uri="demo://greeting/{name}") +def greeting(name: str) -> str: + """Get a personalized greeting.""" + return f"Hello, {name}! Welcome to the demo." + + +@mcp.prompt() +def summarize(style: str = "brief") -> str: + """Summarization guidance.""" + return f"Summarize the following in a {style} style." + + +if __name__ == "__main__": + mcp.run() diff --git a/examples/mcp/models/greeting.md b/examples/mcp/models/greeting.md new file mode 100644 index 0000000..43a5a88 --- /dev/null +++ b/examples/mcp/models/greeting.md @@ -0,0 +1,13 @@ +--- +name: MCP Greeting +description: Demonstrates MCP resource and prompt usage +--- +# MCP Demo + +## Resource Example + +{{ colin.mcp.demo.resource('demo://greeting/World') }} + +## Prompt Example + +{{ colin.mcp.demo.prompt('summarize', style='detailed') }} diff --git a/examples/mcp/output/greeting.md b/examples/mcp/output/greeting.md new file mode 100644 index 0000000..bf9578d --- /dev/null +++ b/examples/mcp/output/greeting.md @@ -0,0 +1,9 @@ +# MCP Demo + +## Resource Example + +Hello, World! Welcome to the demo. + +## Prompt Example + +Summarize the following in a detailed style. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 86623b0..3694bf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "rich>=14.2.0", "tomli>=2.3.0", "tomli-w>=1.2.0", - "fastmcp>=2.14.1", + "fastmcp @ git+https://github.com/jlowin/fastmcp@main", "pydantic-ai>=1.39.0", "httpx>=0.27.0", ] @@ -42,7 +42,7 @@ env = [ "COLIN_DEFAULT_LLM_MODEL=test", ] filterwarnings = [ - "ignore::pytest.PytestUnraisableExceptionWarning:.*Event loop is closed.*", + "ignore:.*BaseSubprocessTransport.*:pytest.PytestUnraisableExceptionWarning", ] [tool.ty.terminal] @@ -59,6 +59,9 @@ select = ["E", "F", "I", "UP"] requires = ["hatchling", "uv-dynamic-versioning"] build-backend = "hatchling.build" +[tool.hatch.metadata] +allow-direct-references = true + [tool.hatch.version] source = "uv-dynamic-versioning" diff --git a/src/colin/compiler/engine.py b/src/colin/compiler/engine.py index 2d7ec02..4e59e1c 100644 --- a/src/colin/compiler/engine.py +++ b/src/colin/compiler/engine.py @@ -485,17 +485,20 @@ def _load_document(self, path: Path) -> ColinDocument: content = path.read_text(encoding="utf-8") post = fm_parser.loads(content) + # Build relative path for URIs and error messages + relative = path.relative_to(self.config.model_path) + # Extract colin config raw_colin = post.metadata.pop("colin", {}) colin_data = cast(dict[str, Any], raw_colin) if isinstance(raw_colin, dict) else {} - colin_config = ColinConfig.model_validate(colin_data) + try: + colin_config = ColinConfig.model_validate(colin_data) + except Exception as e: + raise ValueError(f"Invalid frontmatter in {relative}:\n{e}") from e # Rest is document metadata metadata = cast(dict[str, Any], post.metadata) frontmatter = Frontmatter(colin=colin_config, metadata=metadata) - - # Build URI from path - relative = path.relative_to(self.config.model_path) uri = f"project://{relative}" # Hash the FULL content (including frontmatter) for change detection diff --git a/src/colin/providers/base.py b/src/colin/providers/base.py index e2c4096..5f9b2f2 100644 --- a/src/colin/providers/base.py +++ b/src/colin/providers/base.py @@ -1,5 +1,7 @@ """Provider base class.""" +import hashlib +import json from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any, ClassVar @@ -94,8 +96,6 @@ def from_config(cls, name: str | None, config: dict[str, Any]) -> Self: Returns: Configured provider instance. """ - import hashlib - import json instance = cls(**config) instance._connection = name or "" diff --git a/src/colin/providers/mcp.py b/src/colin/providers/mcp.py index a692e3b..083cc69 100644 --- a/src/colin/providers/mcp.py +++ b/src/colin/providers/mcp.py @@ -112,6 +112,10 @@ def from_config(cls, name: str | None, config: dict[str, Any]) -> Self: """ if not name: raise ValueError("MCP provider requires an instance name") + # Set keep_alive=False for stdio servers to ensure proper subprocess cleanup + # and avoid "Event loop is closed" warnings during shutdown. + if "command" in config and "keep_alive" not in config: + config = {**config, "keep_alive": False} server = MCPServerAdapter.validate_python(config) return cls(name, server) diff --git a/uv.lock b/uv.lock index 7771973..7ba7eb8 100644 --- a/uv.lock +++ b/uv.lock @@ -568,7 +568,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "cyclopts", specifier = ">=4.0.0" }, - { name = "fastmcp", specifier = ">=2.14.1" }, + { name = "fastmcp", git = "https://github.com/jlowin/fastmcp?rev=main" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "jinja2", specifier = ">=3.1.0" }, { name = "pydantic", specifier = ">=2.0.0" }, @@ -862,8 +862,8 @@ wheels = [ [[package]] name = "fastmcp" -version = "2.14.1" -source = { registry = "https://pypi.org/simple" } +version = "2.14.2.dev97+a117316b" +source = { git = "https://github.com/jlowin/fastmcp?rev=main#a117316ba1df3f422be4c846ca70311fa2d1f1cf" } dependencies = [ { name = "authlib" }, { name = "cyclopts" }, @@ -882,10 +882,6 @@ dependencies = [ { name = "uvicorn" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/50/d38e4371bdc34e709f4731b1e882cb7bc50e51c1a224859d4cd381b3a79b/fastmcp-2.14.1.tar.gz", hash = "sha256:132725cbf77b68fa3c3d165eff0cfa47e40c1479457419e6a2cfda65bd84c8d6", size = 8263331, upload-time = "2025-12-15T02:26:27.102Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/82/72401d09dc27c27fdf72ad6c2fe331e553e3c3646e01b5ff16473191033d/fastmcp-2.14.1-py3-none-any.whl", hash = "sha256:fb3e365cc1d52573ab89caeba9944dd4b056149097be169bce428e011f0a57e5", size = 412176, upload-time = "2025-12-15T02:26:25.356Z" }, -] [[package]] name = "filelock"