Skip to main content
Glama
test_ollama_tools.py34.3 kB
""" Integration test: Ollama qwen3:8b calls each MCP tool. This test verifies that a real LLM (Ollama with qwen3:8b) can correctly invoke each tool exposed by the markdown-editor-mcp-server. Requirements: - Ollama running locally with qwen3:8b model pulled - Run: ollama pull qwen3:8b Usage: pytest tests/test_ollama_tools.py -v -s """ import os import shutil import tempfile import pytest import httpx from typing import Any, Dict, List # Import MCP tools from markdown_editor.tools.file_ops import ( create_file, list_directory, create_directory, delete_item, ) from markdown_editor.tools.edit_tools import ( get_document_structure, read_element, replace_content, insert_element, delete_element, undo_changes, search_in_document, get_element_context, move_document_element, update_document_metadata, _instance as edit_tool_instance, ) from markdown_editor.core.path_utils import PathResolver OLLAMA_URL = "http://127.0.0.1:11434" MODEL = "qwen3:8b" # Define all tools for Ollama TOOLS_SCHEMA = [ { "type": "function", "function": { "name": "search_tools", "description": "Find the right tool for your task among all available tools.", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Description of what you want to do", } }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "list_directory", "description": "List files and folders in a directory.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Directory path", "default": ".", } }, "required": [], }, }, }, { "type": "function", "function": { "name": "create_file", "description": "Create a new file with content.", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "File path"}, "content": { "type": "string", "description": "File content", "default": "", }, }, "required": ["path"], }, }, }, { "type": "function", "function": { "name": "create_directory", "description": "Create a new directory.", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "Directory path"} }, "required": ["path"], }, }, }, { "type": "function", "function": { "name": "delete_item", "description": "Delete a file or directory.", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "Path to delete"} }, "required": ["path"], }, }, }, { "type": "function", "function": { "name": "get_document_structure", "description": "Parse a Markdown file and return its structure (headings, paragraphs, etc.).", "parameters": { "type": "object", "properties": { "file_path": { "type": "string", "description": "Path to the .md file", }, "depth": { "type": "integer", "description": "Max depth of headings", "default": 2, }, }, "required": ["file_path"], }, }, }, { "type": "function", "function": { "name": "read_element", "description": "Read the content of a specific element by its path.", "parameters": { "type": "object", "properties": { "file_path": {"type": "string", "description": "Path to .md file"}, "path": { "type": "string", "description": "Element path like 'Intro > paragraph 1'", }, }, "required": ["file_path", "path"], }, }, }, { "type": "function", "function": { "name": "replace_content", "description": "Replace the content of a specific element.", "parameters": { "type": "object", "properties": { "file_path": {"type": "string", "description": "Path to .md file"}, "path": {"type": "string", "description": "Element path"}, "new_content": {"type": "string", "description": "New content"}, }, "required": ["file_path", "path", "new_content"], }, }, }, { "type": "function", "function": { "name": "insert_element", "description": "Insert a new element (heading, paragraph, etc.) relative to an existing one.", "parameters": { "type": "object", "properties": { "file_path": {"type": "string", "description": "Path to .md file"}, "path": {"type": "string", "description": "Reference element path"}, "element_type": { "type": "string", "enum": [ "heading", "paragraph", "list", "code_block", "blockquote", ], }, "content": { "type": "string", "description": "Content of new element", }, "where": { "type": "string", "enum": ["before", "after"], "default": "after", }, "heading_level": {"type": "integer", "default": 1}, }, "required": ["file_path", "path", "element_type", "content"], }, }, }, { "type": "function", "function": { "name": "delete_element", "description": "Delete an element from the document.", "parameters": { "type": "object", "properties": { "file_path": {"type": "string", "description": "Path to .md file"}, "path": {"type": "string", "description": "Element path to delete"}, }, "required": ["file_path", "path"], }, }, }, { "type": "function", "function": { "name": "move_element", "description": "Move an element to a new location in the document.", "parameters": { "type": "object", "properties": { "file_path": {"type": "string", "description": "Path to .md file"}, "source_path": { "type": "string", "description": "Path of element to move", }, "target_path": { "type": "string", "description": "Destination path", }, "where": { "type": "string", "enum": ["before", "after"], "default": "after", }, }, "required": ["file_path", "source_path", "target_path"], }, }, }, { "type": "function", "function": { "name": "search_text", "description": "Search for text in a Markdown document.", "parameters": { "type": "object", "properties": { "file_path": {"type": "string", "description": "Path to .md file"}, "query": {"type": "string", "description": "Text to search for"}, }, "required": ["file_path", "query"], }, }, }, { "type": "function", "function": { "name": "get_context", "description": "Get an element along with its neighboring elements.", "parameters": { "type": "object", "properties": { "file_path": {"type": "string", "description": "Path to .md file"}, "path": {"type": "string", "description": "Element path"}, }, "required": ["file_path", "path"], }, }, }, { "type": "function", "function": { "name": "update_metadata", "description": "Update the YAML frontmatter metadata of a document.", "parameters": { "type": "object", "properties": { "file_path": {"type": "string", "description": "Path to .md file"}, "metadata": {"type": "object", "description": "Metadata to update"}, }, "required": ["file_path", "metadata"], }, }, }, { "type": "function", "function": { "name": "undo", "description": "Undo the last N changes to a document.", "parameters": { "type": "object", "properties": { "file_path": {"type": "string", "description": "Path to .md file"}, "count": { "type": "integer", "description": "Number of operations to undo", "default": 1, }, }, "required": ["file_path"], }, }, }, ] async def check_ollama_available() -> bool: """Check if Ollama is running and model is available.""" try: async with httpx.AsyncClient() as client: resp = await client.get(f"{OLLAMA_URL}/api/tags", timeout=5.0) if resp.status_code == 200: models = resp.json().get("models", []) return any(MODEL in m.get("name", "") for m in models) except Exception: pass return False async def call_ollama( messages: List[Dict[str, Any]], tools: List[Dict[str, Any]] ) -> Dict[str, Any]: """Call Ollama API with tool support.""" async with httpx.AsyncClient(timeout=120.0) as client: payload = { "model": MODEL, "messages": messages, "tools": tools, "stream": False, } resp = await client.post(f"{OLLAMA_URL}/api/chat", json=payload) resp.raise_for_status() return resp.json() async def execute_tool(name: str, args: Dict[str, Any]) -> Any: """Execute the actual MCP tool and return result.""" if name == "search_tools": # Simplified search_tools implementation query = args.get("query", "").lower() all_tool_names = [t["function"]["name"] for t in TOOLS_SCHEMA] relevant = [n for n in all_tool_names if query in n] return {"tools": relevant} elif name == "list_directory": return await list_directory(args.get("path", ".")) elif name == "create_file": return await create_file(args["path"], args.get("content", "")) elif name == "create_directory": return await create_directory(args["path"]) elif name == "delete_item": return await delete_item(args["path"]) elif name == "get_document_structure": return await get_document_structure(args["file_path"], args.get("depth", 2)) elif name == "read_element": return await read_element(args["file_path"], args["path"]) elif name == "replace_content": return await replace_content( args["file_path"], args["path"], args["new_content"] ) elif name == "insert_element": return await insert_element( args["file_path"], args["path"], args["element_type"], args["content"], args.get("where", "after"), args.get("heading_level", 1), ) elif name == "delete_element": return await delete_element(args["file_path"], args["path"]) elif name == "move_element": return await move_document_element( args["file_path"], args["source_path"], args["target_path"], args.get("where", "after"), ) elif name == "search_text": return await search_in_document(args["file_path"], args["query"]) elif name == "get_context": return await get_element_context(args["file_path"], args["path"]) elif name == "update_metadata": return await update_document_metadata(args["file_path"], args["metadata"]) elif name == "undo": return await undo_changes(args["file_path"], args.get("count", 1)) return {"error": f"Unknown tool: {name}"} class TestOllamaToolCalls: """Test each tool with real Ollama calls.""" @pytest.fixture(autouse=True) def setup_test_env(self, tmp_path): """Setup temporary test environment.""" self.test_dir = tmp_path self.original_cwd = os.getcwd() os.chdir(self.test_dir) # Set base path for the path resolver PathResolver.set_base_path(str(self.test_dir)) # Clear edit tool cache edit_tool_instance.invalidate_all_cache() yield os.chdir(self.original_cwd) PathResolver.set_base_path(None) @pytest.fixture(autouse=True) def check_ollama(self): """Skip tests if Ollama is not available.""" try: with httpx.Client(timeout=2.0) as client: resp = client.get(f"{OLLAMA_URL}/api/tags") if resp.status_code == 200: models = resp.json().get("models", []) if any(MODEL in m.get("name", "") for m in models): return except Exception: pass pytest.skip(f"Ollama with {MODEL} not available") async def ask_ollama_to_call_tool(self, prompt: str) -> Dict[str, Any]: """Ask Ollama to call a tool based on the prompt.""" messages = [ { "role": "system", "content": ( "You are a helpful assistant that uses tools to complete tasks. " "When asked to perform an action, use the appropriate tool. " "Always use the exact file paths and parameters provided in the user request. " "Do not add any extra text or thinking - just call the tool." ), }, {"role": "user", "content": prompt}, ] response = await call_ollama(messages, TOOLS_SCHEMA) return response @pytest.mark.asyncio async def test_01_list_directory(self): """Test: Ollama calls list_directory.""" # Create some test files (self.test_dir / "file1.md").write_text("# Test 1") (self.test_dir / "file2.md").write_text("# Test 2") response = await self.ask_ollama_to_call_tool( "List all files in the current directory using the list_directory tool with path '.'" ) message = response.get("message", {}) tool_calls = message.get("tool_calls", []) assert len(tool_calls) > 0, f"No tool calls in response: {response}" tool_call = tool_calls[0] assert tool_call["function"]["name"] == "list_directory" # Execute the tool args = tool_call["function"].get("arguments", {}) result = await execute_tool("list_directory", args) assert result is not None assert isinstance(result, list) file_names = [item["name"] for item in result] assert "file1.md" in file_names assert "file2.md" in file_names print(f"[OK] list_directory: found {len(result)} items") @pytest.mark.asyncio async def test_02_create_file(self): """Test: Ollama calls create_file.""" response = await self.ask_ollama_to_call_tool( "Create a new file called 'notes.md' with the content '# My Notes\\n\\nThis is my notes file.' " "using the create_file tool." ) message = response.get("message", {}) tool_calls = message.get("tool_calls", []) assert len(tool_calls) > 0, f"No tool calls: {response}" tool_call = tool_calls[0] assert tool_call["function"]["name"] == "create_file" args = tool_call["function"].get("arguments", {}) result = await execute_tool("create_file", args) assert result.get("success") is True assert (self.test_dir / "notes.md").exists() print(f"[OK] create_file: created {args.get('path')}") @pytest.mark.asyncio async def test_03_create_directory(self): """Test: Ollama calls create_directory.""" response = await self.ask_ollama_to_call_tool( "Create a new directory called 'docs' using the create_directory tool." ) message = response.get("message", {}) tool_calls = message.get("tool_calls", []) assert len(tool_calls) > 0, f"No tool calls: {response}" tool_call = tool_calls[0] assert tool_call["function"]["name"] == "create_directory" args = tool_call["function"].get("arguments", {}) result = await execute_tool("create_directory", args) assert result.get("success") is True assert (self.test_dir / "docs").is_dir() print(f"[OK] create_directory: created {args.get('path')}") @pytest.mark.asyncio async def test_04_get_document_structure(self): """Test: Ollama calls get_document_structure.""" # Create a test markdown file md_content = """# Introduction This is the intro paragraph. ## Section One Content of section one. ### Subsection More details here. ## Section Two Final section content. """ (self.test_dir / "test.md").write_text(md_content) response = await self.ask_ollama_to_call_tool( "Get the structure of the file 'test.md' using the get_document_structure tool." ) message = response.get("message", {}) tool_calls = message.get("tool_calls", []) assert len(tool_calls) > 0, f"No tool calls: {response}" tool_call = tool_calls[0] assert tool_call["function"]["name"] == "get_document_structure" args = tool_call["function"].get("arguments", {}) result = await execute_tool("get_document_structure", args) assert isinstance(result, list) assert len(result) > 0 print(f"[OK] get_document_structure: found {len(result)} top-level elements") @pytest.mark.asyncio async def test_05_read_element(self): """Test: Ollama calls read_element.""" md_content = """# Header This is a test paragraph with some content. """ (self.test_dir / "read_test.md").write_text(md_content) response = await self.ask_ollama_to_call_tool( "Read the element at path 'Header > paragraph 1' from file 'read_test.md' using the read_element tool." ) message = response.get("message", {}) tool_calls = message.get("tool_calls", []) assert len(tool_calls) > 0, f"No tool calls: {response}" tool_call = tool_calls[0] assert tool_call["function"]["name"] == "read_element" args = tool_call["function"].get("arguments", {}) result = await execute_tool("read_element", args) assert "content" in result assert "test paragraph" in result["content"] print(f"[OK] read_element: content = '{result['content'][:50]}...'") @pytest.mark.asyncio async def test_06_replace_content(self): """Test: Ollama calls replace_content.""" md_content = """# Title Old content here. """ (self.test_dir / "replace_test.md").write_text(md_content) response = await self.ask_ollama_to_call_tool( "Replace the content of 'Title > paragraph 1' in file 'replace_test.md' " "with 'New updated content!' using the replace_content tool." ) message = response.get("message", {}) tool_calls = message.get("tool_calls", []) assert len(tool_calls) > 0, f"No tool calls: {response}" tool_call = tool_calls[0] assert tool_call["function"]["name"] == "replace_content" args = tool_call["function"].get("arguments", {}) result = await execute_tool("replace_content", args) assert result.get("success") is True # Verify the change new_content = (self.test_dir / "replace_test.md").read_text() assert "New updated content!" in new_content or "new" in new_content.lower() print("[OK] replace_content: successfully replaced content") @pytest.mark.asyncio async def test_07_insert_element(self): """Test: Ollama calls insert_element.""" md_content = """# Main First paragraph. """ (self.test_dir / "insert_test.md").write_text(md_content) response = await self.ask_ollama_to_call_tool( "Insert a new paragraph with content 'Inserted paragraph!' after 'Main > paragraph 1' " "in file 'insert_test.md' using the insert_element tool. Use element_type 'paragraph'." ) message = response.get("message", {}) tool_calls = message.get("tool_calls", []) assert len(tool_calls) > 0, f"No tool calls: {response}" tool_call = tool_calls[0] assert tool_call["function"]["name"] == "insert_element" args = tool_call["function"].get("arguments", {}) result = await execute_tool("insert_element", args) assert result.get("success") is True # Verify the insertion new_content = (self.test_dir / "insert_test.md").read_text() assert "Inserted" in new_content or "insert" in new_content.lower() print("[OK] insert_element: successfully inserted element") @pytest.mark.asyncio async def test_08_search_text(self): """Test: Ollama calls search_text.""" md_content = """# Document This contains a special keyword FINDME in the text. ## Another Section More content without the keyword. ## Final Section FINDME appears again here! """ (self.test_dir / "search_test.md").write_text(md_content) response = await self.ask_ollama_to_call_tool( "Search for the text 'FINDME' in file 'search_test.md' using the search_text tool." ) message = response.get("message", {}) tool_calls = message.get("tool_calls", []) assert len(tool_calls) > 0, f"No tool calls: {response}" tool_call = tool_calls[0] assert tool_call["function"]["name"] == "search_text" args = tool_call["function"].get("arguments", {}) result = await execute_tool("search_text", args) assert isinstance(result, list) assert len(result) >= 2 # Should find at least 2 matches print(f"[OK] search_text: found {len(result)} matches") @pytest.mark.asyncio async def test_09_get_context(self): """Test: Ollama calls get_context.""" md_content = """# Section Paragraph before target. Target paragraph content. Paragraph after target. """ (self.test_dir / "context_test.md").write_text(md_content) response = await self.ask_ollama_to_call_tool( "Get the context of element 'Section > paragraph 2' from file 'context_test.md' using the get_context tool." ) message = response.get("message", {}) tool_calls = message.get("tool_calls", []) assert len(tool_calls) > 0, f"No tool calls: {response}" tool_call = tool_calls[0] assert tool_call["function"]["name"] == "get_context" args = tool_call["function"].get("arguments", {}) result = await execute_tool("get_context", args) assert "current" in result print("[OK] get_context: got context for element") @pytest.mark.asyncio async def test_10_move_element(self): """Test: Ollama calls move_element.""" md_content = """# First Content of first section. # Second Content of second section. # Third Content to move. """ (self.test_dir / "move_test.md").write_text(md_content) response = await self.ask_ollama_to_call_tool( "Move the element 'Third' to before 'First' in file 'move_test.md' " "using the move_element tool. Set source_path='Third', target_path='First', where='before'." ) message = response.get("message", {}) tool_calls = message.get("tool_calls", []) assert len(tool_calls) > 0, f"No tool calls: {response}" tool_call = tool_calls[0] assert tool_call["function"]["name"] == "move_element" args = tool_call["function"].get("arguments", {}) result = await execute_tool("move_element", args) assert result.get("success") is True print("[OK] move_element: successfully moved element") @pytest.mark.asyncio async def test_11_update_metadata(self): """Test: Ollama calls update_metadata.""" md_content = """--- title: Original Title --- # Content Some text here. """ (self.test_dir / "meta_test.md").write_text(md_content) response = await self.ask_ollama_to_call_tool( 'Update the metadata of file \'meta_test.md\' with {"author": "Test Author", "version": "1.0"} ' "using the update_metadata tool." ) message = response.get("message", {}) tool_calls = message.get("tool_calls", []) assert len(tool_calls) > 0, f"No tool calls: {response}" tool_call = tool_calls[0] assert tool_call["function"]["name"] == "update_metadata" args = tool_call["function"].get("arguments", {}) result = await execute_tool("update_metadata", args) assert result.get("success") is True # Verify the metadata was updated new_content = (self.test_dir / "meta_test.md").read_text() assert "author" in new_content.lower() or "Author" in new_content print("[OK] update_metadata: successfully updated metadata") @pytest.mark.asyncio async def test_12_delete_element(self): """Test: Ollama calls delete_element.""" md_content = """# Keep This This should stay. # Delete This This should be deleted. # Also Keep More content. """ (self.test_dir / "delete_elem_test.md").write_text(md_content) response = await self.ask_ollama_to_call_tool( "Delete the element 'Delete This' from file 'delete_elem_test.md' using the delete_element tool." ) message = response.get("message", {}) tool_calls = message.get("tool_calls", []) assert len(tool_calls) > 0, f"No tool calls: {response}" tool_call = tool_calls[0] assert tool_call["function"]["name"] == "delete_element" args = tool_call["function"].get("arguments", {}) result = await execute_tool("delete_element", args) assert result.get("success") is True # Verify deletion new_content = (self.test_dir / "delete_elem_test.md").read_text() assert "Delete This" not in new_content print("[OK] delete_element: successfully deleted element") @pytest.mark.asyncio async def test_13_undo(self): """Test: Ollama calls undo.""" md_content = """# Test Original content. """ test_file = self.test_dir / "undo_test.md" test_file.write_text(md_content) # First make a change to undo await replace_content("undo_test.md", "Test > paragraph 1", "Modified content.") response = await self.ask_ollama_to_call_tool( "Undo the last change to file 'undo_test.md' using the undo tool with count=1." ) message = response.get("message", {}) tool_calls = message.get("tool_calls", []) assert len(tool_calls) > 0, f"No tool calls: {response}" tool_call = tool_calls[0] assert tool_call["function"]["name"] == "undo" args = tool_call["function"].get("arguments", {}) result = await execute_tool("undo", args) assert result.get("success") is True print("[OK] undo: successfully undid changes") @pytest.mark.asyncio async def test_14_delete_item(self): """Test: Ollama calls delete_item.""" # Create a file to delete test_file = self.test_dir / "to_delete.md" test_file.write_text("# Delete me") response = await self.ask_ollama_to_call_tool( "Delete the file 'to_delete.md' using the delete_item tool." ) message = response.get("message", {}) tool_calls = message.get("tool_calls", []) assert len(tool_calls) > 0, f"No tool calls: {response}" tool_call = tool_calls[0] assert tool_call["function"]["name"] == "delete_item" args = tool_call["function"].get("arguments", {}) result = await execute_tool("delete_item", args) assert result.get("success") is True assert not test_file.exists() print("[OK] delete_item: successfully deleted file") @pytest.mark.asyncio async def test_15_search_tools(self): """Test: Ollama calls search_tools.""" response = await self.ask_ollama_to_call_tool( "Find tools related to 'delete' using the search_tools tool with query='delete'." ) message = response.get("message", {}) tool_calls = message.get("tool_calls", []) assert len(tool_calls) > 0, f"No tool calls: {response}" tool_call = tool_calls[0] assert tool_call["function"]["name"] == "search_tools" args = tool_call["function"].get("arguments", {}) result = await execute_tool("search_tools", args) assert "tools" in result assert any("delete" in t for t in result["tools"]) print(f"[OK] search_tools: found relevant tools {result['tools']}") @pytest.mark.asyncio async def test_full_workflow(): """ Integration test: Complete workflow with Ollama making multiple tool calls. Tests a realistic scenario: create file, edit it, search, then cleanup. """ available = await check_ollama_available() if not available: pytest.skip(f"Ollama with {MODEL} not available") test_dir = tempfile.mkdtemp() original_cwd = os.getcwd() try: os.chdir(test_dir) PathResolver.set_base_path(test_dir) edit_tool_instance.invalidate_all_cache() print("\n=== Full Workflow Test ===\n") # Step 1: Create a document print("Step 1: Creating document...") result = await create_file( "workflow.md", """# Project Plan ## Goals Define project goals here. ## Timeline Q1 2025: Planning Q2 2025: Implementation """, ) assert result.get("success") is True print(" - Created workflow.md") # Step 2: Get structure print("Step 2: Getting structure...") structure = await get_document_structure("workflow.md") assert len(structure) > 0 print(f" - Found {len(structure)} sections") # Step 3: Insert new content print("Step 3: Inserting new section...") result = await insert_element( "workflow.md", "Timeline", "heading", "Resources", "after", heading_level=2 ) assert result.get("success") is True print(" - Inserted Resources section") # Step 4: Search for text print("Step 4: Searching for 'Q1'...") results = await search_in_document("workflow.md", "Q1") assert len(results) > 0 print(f" - Found {len(results)} matches") # Step 5: Replace content print("Step 5: Replacing content...") result = await replace_content( "workflow.md", "Goals > paragraph 1", "Build an awesome product!" ) assert result.get("success") is True print(" - Replaced goals paragraph") # Step 6: Update metadata print("Step 6: Adding metadata...") result = await update_document_metadata( "workflow.md", {"project": "MCP Server", "status": "in-progress"} ) assert result.get("success") is True print(" - Added metadata") # Step 7: Verify final state print("Step 7: Verifying final document...") final_content = open("workflow.md").read() assert "Resources" in final_content assert "awesome product" in final_content assert "project:" in final_content or "status:" in final_content print(" - Document verified!") print("\n=== Workflow Complete! ===\n") finally: os.chdir(original_cwd) PathResolver.set_base_path(None) shutil.rmtree(test_dir) if __name__ == "__main__": pytest.main([__file__, "-v", "-s"])

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/KazKozDev/markdown-editor-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server