Skip to content
Open
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ authors = [
{name = "johnhuang316"}
]
dependencies = [
"mcp>=0.3.0",
"fastmcp>2.2.4",
"watchdog>=3.0.0",
"tree-sitter>=0.20.0",
"tree-sitter-javascript>=0.20.0",
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
mcp>=0.3.0
fastmcp>2.2.4
watchdog>=3.0.0
protobuf>=4.21.0
tree-sitter>=0.20.0
Expand Down
52 changes: 31 additions & 21 deletions src/code_index_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from typing import AsyncIterator, Dict, Any, List, Optional

# Third-party imports
from mcp.server.fastmcp import FastMCP, Context
from fastmcp import FastMCP, Context

# Local imports
from .project_settings import ProjectSettings
Expand Down Expand Up @@ -47,7 +47,7 @@ def setup_indexing_performance_logging():
# stderr for errors only
stderr_handler = logging.StreamHandler(sys.stderr)
stderr_handler.setFormatter(formatter)
stderr_handler.setLevel(logging.ERROR)
stderr_handler.setLevel(logging.INFO)

root_logger.addHandler(stderr_handler)
root_logger.setLevel(logging.DEBUG)
Expand All @@ -71,15 +71,6 @@ class _CLIConfig:
project_path: str | None = None


class _BootstrapRequestContext:
"""Minimal request context to reuse business services during bootstrap."""

def __init__(self, lifespan_context: CodeIndexerContext):
self.lifespan_context = lifespan_context
self.session = None
self.meta = None


_CLI_CONFIG = _CLIConfig()

@asynccontextmanager
Expand All @@ -101,15 +92,30 @@ async def indexer_lifespan(_server: FastMCP) -> AsyncIterator[CodeIndexerContext
try:
# Bootstrap project path when provided via CLI.
if _CLI_CONFIG.project_path:
bootstrap_ctx = Context(
request_context=_BootstrapRequestContext(context),
fastmcp=mcp
)
# Initialize project directly without using Context/Services
# since we're in lifespan and not in a request context
from .indexing import get_shallow_index_manager

project_path = _CLI_CONFIG.project_path
try:
message = ProjectManagementService(bootstrap_ctx).initialize_project(
_CLI_CONFIG.project_path
)
logger.info("Project initialized from CLI flag: %s", message)
# Initialize settings with the project path
settings = ProjectSettings(project_path, skip_load=False)
context.base_path = project_path
context.settings = settings

# Build shallow index
shallow_manager = get_shallow_index_manager()
if shallow_manager.set_project_path(project_path):
shallow_manager.build_index()
context.file_count = len(shallow_manager.get_file_list())
else:
raise RuntimeError("Failed to set project path for shallow index")

# Note: File watcher will be initialized on first tool call
# We can't initialize it here because FileWatcherService requires a Context

logger.info("Project initialized from CLI flag: %s (%d files)",
project_path, context.file_count)
except Exception as exc: # pylint: disable=broad-except
logger.error("Failed to initialize project from CLI flag: %s", exc)
raise RuntimeError(
Expand All @@ -124,7 +130,7 @@ async def indexer_lifespan(_server: FastMCP) -> AsyncIterator[CodeIndexerContext
context.file_watcher_service.stop_monitoring()

# Create the MCP server with lifespan manager
mcp = FastMCP("CodeIndexer", lifespan=indexer_lifespan, dependencies=["pathlib"])
mcp = FastMCP("CodeIndexer", lifespan=indexer_lifespan)

# ----- RESOURCES -----

Expand All @@ -135,11 +141,14 @@ def get_config() -> str:
ctx = mcp.get_context()
return ProjectManagementService(ctx).get_project_config()

@mcp.resource("files://{file_path}")
@mcp.resource("file://{file_path*}")
@handle_mcp_resource_errors
def get_file_content(file_path: str) -> str:
"""Get the content of a specific file."""
ctx = mcp.get_context()

logger.info("Fetching content for file: %s", file_path)

# Use FileService for simple file reading - this is appropriate for a resource
return FileService(ctx).get_file_content(file_path)

Expand All @@ -153,6 +162,7 @@ def get_file_content(file_path: str) -> str:
@handle_mcp_tool_errors(return_type='str')
def set_project_path(path: str, ctx: Context) -> str:
"""Set the base project path for indexing."""

return ProjectManagementService(ctx).initialize_project(path)

@mcp.tool()
Expand Down
2 changes: 1 addition & 1 deletion src/code_index_mcp/services/base_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from abc import ABC
from typing import Optional
from mcp.server.fastmcp import Context
from fastmcp import Context

from ..utils import ContextHelper, ValidationHelper

Expand Down
5 changes: 5 additions & 0 deletions src/code_index_mcp/services/file_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@
- get_file_content() - used by files://{file_path} resource
"""

import logging
import os
from .base_service import BaseService

from ..utils.validation import ValidationHelper

logger = logging.getLogger(__name__)

class FileService(BaseService):
"""
Simple service for file content reading.
Expand Down Expand Up @@ -44,6 +47,8 @@ def get_file_content(self, file_path: str) -> str:
# Build full path
full_path = os.path.join(self.base_path, normalized_path)

logger.info("Reading file content from: %s", full_path)

try:
# Try UTF-8 first (most common)
with open(full_path, 'r', encoding='utf-8') as f:
Expand Down
2 changes: 1 addition & 1 deletion src/code_index_mcp/services/file_watcher_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ def on_any_event(self, event: FileSystemEvent) -> None:
should_process = self.should_process_event(event)

if should_process:
self.logger.info("File changed: %s - %s", event.event_type, event.src_path)
self.logger.debug("File changed: %s - %s", event.event_type, event.src_path)
self.reset_debounce_timer()
else:
# Only log at debug level for filtered events
Expand Down
2 changes: 2 additions & 0 deletions src/code_index_mcp/services/project_management_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ def initialize_project(self, path: str) -> str:
# Business workflow: Execute initialization
result = self._execute_initialization_workflow(path)

logger.info("Initialized project path to: %s", path)

# Business result formatting
return self._format_initialization_result(result)

Expand Down
2 changes: 1 addition & 1 deletion src/code_index_mcp/utils/context_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import os
from typing import Optional
from mcp.server.fastmcp import Context
from fastmcp import Context

from ..project_settings import ProjectSettings

Expand Down
98 changes: 41 additions & 57 deletions tests/services/test_resource_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,94 +310,79 @@ def test_list_resources_returns_config_resource(self):
import asyncio
from code_index_mcp.server import mcp

# Get list of resources
resources = asyncio.run(mcp.list_resources())
# Get list of resources using internal method
resources = asyncio.run(mcp._resource_manager.get_resources())

# Should have at least the config resource
assert len(resources) > 0

# Find config resource (uri is a pydantic AnyUrl object)
config_resources = [r for r in resources if str(r.uri) == "config://code-indexer"]
assert len(config_resources) == 1

config_resource = config_resources[0]
assert str(config_resource.uri) == "config://code-indexer"
assert config_resource.name is not None or config_resource.uri is not None
# Find config resource
assert "config://code-indexer" in resources
config_resource = resources["config://code-indexer"]
assert config_resource.name == "get_config"

def test_list_resource_templates_returns_files_template(self):
"""Test that list_resource_templates returns the files template."""
def test_list_resource_templates_returns_file_template(self):
"""Test that list_resource_templates returns the file template."""
import asyncio
from code_index_mcp.server import mcp

# Get list of resource templates
templates = asyncio.run(mcp.list_resource_templates())
# Get list of resource templates using internal method
templates = asyncio.run(mcp._resource_manager.get_resource_templates())

# Should have the files template
# Should have the file template
assert len(templates) > 0

# Find files template
files_templates = [t for t in templates if "files://" in t.uriTemplate]
assert len(files_templates) == 1

files_template = files_templates[0]
assert files_template.uriTemplate == "files://{file_path}"
assert files_template.name is not None or files_template.uriTemplate is not None
# Find file template
assert "file://{file_path*}" in templates
file_template = templates["file://{file_path*}"]
assert file_template.name == "get_file_content"
assert file_template.uri_template == "file://{file_path*}"

def test_resources_are_discoverable(self):
"""Test that both static and template resources are discoverable."""
import asyncio
from code_index_mcp.server import mcp

# Get both lists
resources = asyncio.run(mcp.list_resources())
templates = asyncio.run(mcp.list_resource_templates())
# Get both lists using internal methods
resources = asyncio.run(mcp._resource_manager.get_resources())
templates = asyncio.run(mcp._resource_manager.get_resource_templates())

# Should have at least one of each
assert len(resources) >= 1, "Should have at least the config resource"
assert len(templates) >= 1, "Should have at least the files template"

# Collect all URIs/templates (convert AnyUrl to string)
resource_uris = {str(r.uri) for r in resources}
template_uris = {t.uriTemplate for t in templates}
assert len(templates) >= 1, "Should have at least the file template"

# Verify expected resources
assert "config://code-indexer" in resource_uris
assert "files://{file_path}" in template_uris
assert "config://code-indexer" in resources
assert "file://{file_path*}" in templates

def test_config_resource_has_metadata(self):
"""Test that config resource has proper metadata."""
import asyncio
from code_index_mcp.server import mcp

resources = asyncio.run(mcp.list_resources())
config_resources = [r for r in resources if str(r.uri) == "config://code-indexer"]
resources = asyncio.run(mcp._resource_manager.get_resources())

assert len(config_resources) == 1
config_resource = config_resources[0]
assert "config://code-indexer" in resources
config_resource = resources["config://code-indexer"]

# Check that it has some identifying information
assert str(config_resource.uri) == "config://code-indexer"
# At minimum, should have uri
assert hasattr(config_resource, 'uri')
assert config_resource.name == "get_config"
assert config_resource.description is not None

def test_files_template_has_metadata(self):
"""Test that files template resource has proper metadata."""
def test_file_template_has_metadata(self):
"""Test that file template resource has proper metadata."""
import asyncio
from code_index_mcp.server import mcp

templates = asyncio.run(mcp.list_resource_templates())
files_templates = [t for t in templates if "files://" in t.uriTemplate]
templates = asyncio.run(mcp._resource_manager.get_resource_templates())

assert len(files_templates) == 1
files_template = files_templates[0]
assert "file://{file_path*}" in templates
file_template = templates["file://{file_path*}"]

# Check that it has proper template structure
assert files_template.uriTemplate == "files://{file_path}"
assert hasattr(files_template, 'uriTemplate')

# Check if it has description or name (optional but good practice)
# Note: These might be None if not set in the decorator
assert files_template.uriTemplate is not None
assert file_template.uri_template == "file://{file_path*}"
assert file_template.name == "get_file_content"
assert file_template.description is not None

def test_read_resource_via_mcp_with_workspace_files(self):
"""Test reading actual files from the workspace through MCP resources."""
Expand Down Expand Up @@ -478,12 +463,11 @@ def test_config_resource_readable(self):
import asyncio
from code_index_mcp.server import mcp

resources = asyncio.run(mcp.list_resources())
config_resources = [r for r in resources if str(r.uri) == "config://code-indexer"]
resources = asyncio.run(mcp._resource_manager.get_resources())

assert len(config_resources) == 1
assert "config://code-indexer" in resources
config_resource = resources["config://code-indexer"]

# Verify URI scheme is correct
uri_str = str(config_resources[0].uri)
assert uri_str.startswith("config://")
assert "code-indexer" in uri_str
# Verify resource properties
assert config_resource.name == "get_config"
assert str(config_resource.uri) == "config://code-indexer"
Loading