source-context-mcp 0.1.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.3
2
+ Name: source-context-mcp
3
+ Version: 0.1.dev1
4
+ Summary: Local Model Context Protocol (MCP) server for Source Context code intelligence.
5
+ Author: Le Ngoc Tu, Dang Huu Hieu, Huynh Phat Tai
6
+ Author-email: Le Ngoc Tu <tuleaibk@gmail.com>, Dang Huu Hieu <danghuuhieu038@gmail.com>, Huynh Phat Tai <huynhphtai@gmail.com>
7
+ Requires-Dist: httpx>=0.28.1
8
+ Requires-Dist: mcp[cli]>=2.0.0
9
+ Requires-Dist: pydantic>=2.13.4
10
+ Requires-Dist: pydantic-settings>=2.14.2
11
+ Requires-Dist: tomli-w>=1.2.0
12
+ Requires-Dist: typer>=0.27.0
13
+ Requires-Python: >=3.12
14
+ Project-URL: Repository, https://github.com/TuLe142857/source-context
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Readme for MCP Server
@@ -0,0 +1 @@
1
+ # Readme for MCP Server
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "source-context-mcp"
3
+ version = "0.1.dev1"
4
+ description = "Local Model Context Protocol (MCP) server for Source Context code intelligence."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Le Ngoc Tu", email = "tuleaibk@gmail.com" },
8
+ { name = "Dang Huu Hieu", email = "danghuuhieu038@gmail.com" },
9
+ { name = "Huynh Phat Tai", email = "huynhphtai@gmail.com" }
10
+ ]
11
+ requires-python = ">=3.12"
12
+ dependencies = [
13
+ "httpx>=0.28.1",
14
+ "mcp[cli]>=2.0.0",
15
+ "pydantic>=2.13.4",
16
+ "pydantic-settings>=2.14.2",
17
+ "tomli-w>=1.2.0",
18
+ "typer>=0.27.0",
19
+ ]
20
+
21
+
22
+
23
+ [project.urls]
24
+ Repository = "https://github.com/TuLe142857/source-context"
25
+
26
+ [project.scripts]
27
+ source-context-mcp = "source_context_mcp:run"
28
+
29
+ [build-system]
30
+ requires = ["uv_build>=0.11.21,<0.12.0"]
31
+ build-backend = "uv_build"
32
+
33
+ [tool.ruff]
34
+ extend = "../pyproject.toml"
35
+
36
+ [tool.mypy]
37
+ python_version = "3.12"
38
+ explicit_package_bases = true
39
+ mypy_path = ["src"]
40
+ check_untyped_defs = true
41
+ disallow_untyped_defs = true
42
+ disallow_incomplete_defs = true
43
+ no_implicit_optional = true
44
+ warn_return_any = true
45
+ warn_unused_ignores = true
46
+ warn_redundant_casts = true
47
+ warn_unreachable = true
48
+ show_error_codes = true
@@ -0,0 +1,5 @@
1
+ from .cli import cli
2
+
3
+
4
+ def run():
5
+ cli()
@@ -0,0 +1,4 @@
1
+ from .cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
@@ -0,0 +1,49 @@
1
+ import typer
2
+
3
+ from .core.settings import CONFIG_FILE, get_settings, get_settings_with_overrides, write_settings
4
+ from .server import create_server
5
+
6
+ cli = typer.Typer()
7
+
8
+
9
+ @cli.command(name="run", help="Run the MCP server")
10
+ def run(
11
+ server_url_override: str | None = typer.Option(
12
+ None, "--server-url", help="Server url to override for this session"
13
+ ),
14
+ pat_override: str | None = typer.Option(None, "--token", help="Token to override for this session"),
15
+ workspace_id: int | None = typer.Option(
16
+ None, "--workspace-id", help="Default workspace id to override for this session"
17
+ ),
18
+ ):
19
+ server = create_server(
20
+ base_url_override=server_url_override,
21
+ pat_override=pat_override,
22
+ workspace_id_override=workspace_id,
23
+ )
24
+ server.run("stdio")
25
+
26
+
27
+ @cli.command(name="config", help="Configure the MCP server")
28
+ def config(
29
+ server_url: str | None = typer.Option(None, "--server-url", help="Server URL"),
30
+ token: str | None = typer.Option(None, "--token", help="Personal Access Token"),
31
+ default_workspace_id: int | None = typer.Option(None, "--workspace-id", help="Default workspace id"),
32
+ ):
33
+ settings = get_settings_with_overrides(
34
+ server_url_override=server_url,
35
+ pat_override=token,
36
+ default_workspace_id_override=default_workspace_id,
37
+ )
38
+ write_settings(settings)
39
+ typer.echo(f"Config saved to {CONFIG_FILE}")
40
+
41
+
42
+ @cli.command(name="show-config", help="Show the MCP server's configuration")
43
+ def show_config():
44
+ settings = get_settings()
45
+ settings_json = settings.model_dump_json(indent=2)
46
+
47
+ typer.echo(f"Configuration file: {CONFIG_FILE}")
48
+ typer.echo("Note: Secret value will be display as ***")
49
+ typer.echo(settings_json)
@@ -0,0 +1,9 @@
1
+ from .api_client import ApiClient as ApiClient
2
+ from .api_client import ApiResponse as ApiResponse
3
+ from .context import AppContext as AppContext
4
+ from .dependencies import ApiClientDep as ApiClientDep
5
+ from .settings import CONFIG_FILE as CONFIG_FILE
6
+ from .settings import Settings as Settings
7
+ from .settings import get_settings as get_settings
8
+ from .settings import get_settings_with_overrides as get_settings_with_overrides
9
+ from .settings import write_settings as write_settings
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Literal, overload
5
+
6
+ import httpx
7
+ from pydantic import TypeAdapter, ValidationError
8
+
9
+
10
+ @dataclass
11
+ class ApiResponse[T]:
12
+ success: bool
13
+ status_code: int
14
+ data: T | None
15
+ error_code: str | None
16
+ message: str | None
17
+
18
+ @staticmethod
19
+ def ok(data: T | None, message: str | None = None, status_code: int = 200) -> ApiResponse[T]:
20
+ return ApiResponse(
21
+ success=True,
22
+ status_code=status_code,
23
+ data=data,
24
+ message=message,
25
+ error_code=None,
26
+ )
27
+
28
+ @staticmethod
29
+ def error(status_code: int, error_code: str, message: str | None = None) -> ApiResponse:
30
+ return ApiResponse(
31
+ success=False,
32
+ data=None,
33
+ status_code=status_code,
34
+ error_code=error_code,
35
+ message=message,
36
+ )
37
+
38
+
39
+ class ApiClient:
40
+ def __init__(self, base_url: str, token: str, max_retry: int = 1):
41
+ """
42
+ Args:
43
+ base_url: server url
44
+ token: personal access token
45
+ """
46
+ self.server_url = base_url
47
+ self.token = token
48
+ self.max_retry = max_retry
49
+ self.async_client = httpx.AsyncClient(
50
+ base_url=self.server_url,
51
+ headers={
52
+ "Authorization": f"Bearer {self.token}",
53
+ "Accept": "application/json",
54
+ }
55
+ if len(token) > 0
56
+ else None,
57
+ )
58
+
59
+ async def close(self):
60
+ if self.async_client is not None:
61
+ await self.async_client.aclose()
62
+
63
+ def _parse_data(self, data: Any, response_model: type) -> Any:
64
+ """
65
+ Raises:
66
+ ValidationError
67
+ """
68
+ adapter = TypeAdapter(response_model)
69
+ return adapter.validate_python(data)
70
+
71
+ async def _request(
72
+ self,
73
+ method: Literal["GET", "PUT", "POST", "PATCH", "DELETE"],
74
+ path: str,
75
+ response_model: type | None = None,
76
+ **kwargs,
77
+ ) -> ApiResponse[Any]:
78
+ for attempt in range(self.max_retry):
79
+ try:
80
+ resp = await self.async_client.request(method, path, **kwargs)
81
+ resp.raise_for_status()
82
+
83
+ try:
84
+ raw_data = resp.json()
85
+ if isinstance(raw_data, dict) and "data" in raw_data:
86
+ raw_data = raw_data["data"]
87
+ except ValueError:
88
+ raw_data = resp.text
89
+
90
+ if response_model is not None:
91
+ try:
92
+ parsed_data = self._parse_data(raw_data, response_model)
93
+ return ApiResponse.ok(data=parsed_data)
94
+ except ValidationError as e:
95
+ return ApiResponse.error(0, str(e))
96
+ else:
97
+ return ApiResponse.ok(data=raw_data)
98
+
99
+ except httpx.TimeoutException:
100
+ if attempt < self.max_retry - 1:
101
+ continue
102
+ return ApiResponse.error(
103
+ status_code=0,
104
+ error_code="REQUEST_TIMEOUT",
105
+ )
106
+
107
+ except httpx.ConnectError:
108
+ if attempt < self.max_retry - 1:
109
+ continue
110
+ return ApiResponse.error(
111
+ status_code=0,
112
+ error_code="CONNECTION_ERROR",
113
+ )
114
+
115
+ except httpx.HTTPError as exc:
116
+ return ApiResponse.error(0, str(exc))
117
+
118
+ return ApiResponse.error(0, "UNKNOW_ERROR", f"Request failed after {self.max_retry} attempts")
119
+
120
+ @overload
121
+ async def get(self, path: str, params: dict | None = None) -> ApiResponse[Any]: ...
122
+ @overload
123
+ async def get[M](self, path: str, params: dict | None = None, *, response_model: type[M]) -> ApiResponse[M]: ...
124
+ async def get[M](self, path: str, params: dict | None = None, *, response_model: type[M] | None = None):
125
+ return await self._request("GET", path, response_model=response_model, params=params)
126
+
127
+ @overload
128
+ async def post(self, path: str, json: dict | None = None) -> ApiResponse[Any]: ...
129
+ @overload
130
+ async def post[M](self, path: str, json: dict | None = None, *, response_model: type[M]) -> ApiResponse[M]: ...
131
+ async def post[M](self, path: str, json: dict | None = None, *, response_model: type[M] | None = None):
132
+ return await self._request("POST", path, response_model=response_model, json=json)
133
+
134
+ @overload
135
+ async def put(self, path: str, json: dict | None = None) -> ApiResponse[Any]: ...
136
+ @overload
137
+ async def put[M](self, path: str, json: dict | None = None, *, response_model: type[M]) -> ApiResponse[M]: ...
138
+ async def put[M](self, path: str, json: dict | None = None, *, response_model: type[M] | None = None):
139
+ return await self._request("PUT", path, response_model=response_model, json=json)
140
+
141
+ @overload
142
+ async def patch(self, path: str, json: dict | None = None) -> ApiResponse[Any]: ...
143
+ @overload
144
+ async def patch[M](self, path: str, json: dict | None = None, *, response_model: type[M]) -> ApiResponse[M]: ...
145
+ async def patch[M](self, path: str, json: dict | None = None, *, response_model: type[M] | None = None):
146
+ return await self._request("PATCH", path, response_model=response_model, json=json)
147
+
148
+ @overload
149
+ async def delete(self, path: str) -> ApiResponse[Any]: ...
150
+ @overload
151
+ async def delete[M](self, path: str, *, response_model: type[M]) -> ApiResponse[M]: ...
152
+ async def delete[M](self, path: str, *, response_model: type[M] | None = None):
153
+ return await self._request("DELETE", path, response_model=response_model)
@@ -0,0 +1,10 @@
1
+ from dataclasses import dataclass
2
+
3
+ from .api_client import ApiClient
4
+ from .settings import Settings
5
+
6
+
7
+ @dataclass
8
+ class AppContext:
9
+ api_client: ApiClient
10
+ settings: Settings
@@ -0,0 +1,13 @@
1
+ from typing import Annotated
2
+
3
+ from mcp.server.mcpserver import Context, Resolve
4
+
5
+ from source_context_mcp.core import ApiClient, AppContext
6
+
7
+
8
+ def get_api_client(ctx: Context[AppContext]) -> ApiClient:
9
+ app_context: AppContext = ctx.request_context.lifespan_context
10
+ return app_context.api_client
11
+
12
+
13
+ ApiClientDep = Annotated[ApiClient, Resolve(get_api_client)]
@@ -0,0 +1,91 @@
1
+ from functools import lru_cache
2
+ from pathlib import Path
3
+
4
+ import tomli_w
5
+ from pydantic import SecretStr
6
+ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
7
+
8
+ CONFIG_DIR = Path.home() / ".source_context_mcp"
9
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
10
+
11
+
12
+ class Settings(BaseSettings):
13
+ model_config = SettingsConfigDict(
14
+ toml_file=str(CONFIG_FILE),
15
+ )
16
+
17
+ SERVER_URL: str = "default"
18
+ """Server URL. Include prefix. Example: http://localhost:8000/mcp/v1"""
19
+
20
+ PAT: SecretStr = SecretStr("Default")
21
+ """Personal access token"""
22
+
23
+ DEFAULT_WORKSPACE_ID: int = 1
24
+ """Default Workspace ID"""
25
+
26
+ @classmethod
27
+ def settings_customise_sources(
28
+ cls,
29
+ settings_cls: type[BaseSettings],
30
+ init_settings: PydanticBaseSettingsSource,
31
+ env_settings: PydanticBaseSettingsSource,
32
+ dotenv_settings: PydanticBaseSettingsSource,
33
+ file_secret_settings: PydanticBaseSettingsSource,
34
+ ):
35
+ return (
36
+ init_settings,
37
+ TomlConfigSettingsSource(settings_cls),
38
+ )
39
+
40
+
41
+ @lru_cache
42
+ def get_settings() -> Settings:
43
+ """
44
+ Get default settings.
45
+ - See ``get_settings_with_overrides`` to see how to get settings with overrides values.
46
+ Returns:
47
+
48
+ """
49
+ return Settings()
50
+
51
+
52
+ def get_settings_with_overrides(
53
+ server_url_override: str | None = None,
54
+ pat_override: SecretStr | str | None = None,
55
+ default_workspace_id_override: int | None = None,
56
+ ) -> Settings:
57
+ """
58
+ Get settings with overrides values. Any None value will be ignored.
59
+ - This method does not change value of ``get_settings``
60
+ - This method does not change default settings in config file.
61
+ """
62
+
63
+ fields_overrides = {}
64
+ if server_url_override is not None:
65
+ fields_overrides["SERVER_URL"] = server_url_override
66
+ if pat_override is not None:
67
+ fields_overrides["PAT"] = pat_override if isinstance(pat_override, SecretStr) else SecretStr(pat_override)
68
+ if default_workspace_id_override is not None:
69
+ fields_overrides["DEFAULT_WORKSPACE_ID"] = default_workspace_id_override
70
+
71
+ # Any unprovided fields will be read from config file
72
+ return Settings(**fields_overrides)
73
+
74
+
75
+ def write_settings(settings: Settings):
76
+ """
77
+ Write settings to config file. Reset cache of ``get_settings()``
78
+ """
79
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
80
+
81
+ data = {
82
+ "SERVER_URL": settings.SERVER_URL,
83
+ "PAT": settings.PAT.get_secret_value(),
84
+ "DEFAULT_WORKSPACE_ID": settings.DEFAULT_WORKSPACE_ID,
85
+ }
86
+
87
+ with CONFIG_FILE.open("wb") as f:
88
+ tomli_w.dump(data, f)
89
+
90
+ # clear old cache
91
+ get_settings.cache_clear()
@@ -0,0 +1,51 @@
1
+ from collections.abc import AsyncIterator
2
+ from contextlib import asynccontextmanager
3
+
4
+ from mcp.server import MCPServer
5
+
6
+ from source_context_mcp.core import ApiClient, AppContext, get_settings_with_overrides
7
+ from source_context_mcp.tools import register_tools
8
+
9
+
10
+ def make_life_span(
11
+ base_url_override: str | None = None, pat_override: str | None = None, workspace_id_override: int | None = None
12
+ ):
13
+ @asynccontextmanager
14
+ async def server_lifespan(app: MCPServer) -> AsyncIterator[AppContext]:
15
+ settings = get_settings_with_overrides(
16
+ server_url_override=base_url_override,
17
+ pat_override=pat_override,
18
+ default_workspace_id_override=workspace_id_override,
19
+ )
20
+
21
+ yield AppContext(
22
+ api_client=ApiClient(base_url=settings.SERVER_URL, token=settings.PAT.get_secret_value()), settings=settings
23
+ )
24
+
25
+ return server_lifespan
26
+
27
+
28
+ def create_server(
29
+ base_url_override: str | None = None, pat_override: str | None = None, workspace_id_override: int | None = None
30
+ ) -> MCPServer:
31
+ """
32
+ Create an MCPServer instance.
33
+ Returns:
34
+ MCPServer instance.
35
+ """
36
+
37
+ life_span = make_life_span(
38
+ base_url_override=base_url_override, pat_override=pat_override, workspace_id_override=workspace_id_override
39
+ )
40
+
41
+ server = MCPServer(
42
+ name="SourceContextMCP",
43
+ lifespan=life_span,
44
+ )
45
+
46
+ register_tools(server)
47
+
48
+ return server
49
+
50
+
51
+ mcp = create_server()
@@ -0,0 +1,19 @@
1
+ from mcp.server import MCPServer
2
+
3
+ from .base import register_tools as register_base_tools
4
+ from .debug import register_tools as register_debug_tools
5
+ from .graph import register_tools as register_graph_tools
6
+ from .vector import register_tools as register_vector_tools
7
+
8
+
9
+ def register_tools(mcp: MCPServer) -> None:
10
+ """
11
+ Registry tools for MCPServer.
12
+ Args:
13
+ mcp: instance of MCPServer
14
+ """
15
+ register_debug_tools(mcp)
16
+
17
+ register_base_tools(mcp)
18
+ register_graph_tools(mcp)
19
+ register_vector_tools(mcp)
@@ -0,0 +1,41 @@
1
+ from typing import Any
2
+
3
+ from mcp.server import MCPServer
4
+
5
+ from source_context_mcp.core import ApiClientDep
6
+
7
+
8
+ def register_tools(mcp: MCPServer) -> None:
9
+ """
10
+ Register bases tools:
11
+ - list user's available workspaces
12
+ - list repos in workspace
13
+ - list branches in repo
14
+ - list projects in branch
15
+
16
+ Args:
17
+ mcp: MCPServer instance
18
+ """
19
+
20
+ @mcp.tool(description="List all available workspaces that current user can access")
21
+ async def list_workspaces(client: ApiClientDep) -> Any:
22
+ res = await client.get("/general/workspaces", response_model=list)
23
+ return res.data
24
+
25
+ @mcp.tool(description="List all available repositories in workspace")
26
+ async def list_repositories(
27
+ client: ApiClientDep,
28
+ workspace_id: int,
29
+ ) -> Any:
30
+ res = await client.post("/general/repositories", {"workspace_id": workspace_id})
31
+ return res.data
32
+
33
+ @mcp.tool(description="List all available branches in specified repository.")
34
+ async def list_branches(client: ApiClientDep, project_id: int) -> Any:
35
+ """This tool is not implemented yet"""
36
+ return ["This tool is not implemented yet"]
37
+
38
+ @mcp.tool(description="List all available projects in specific branch.")
39
+ def list_projects(client: ApiClientDep, branch_id: int) -> Any:
40
+ """This tool is not implemented yet"""
41
+ return ["This tool is not implemented yet"]
@@ -0,0 +1,11 @@
1
+ from mcp.server import MCPServer
2
+ from mcp.server.mcpserver import Context
3
+
4
+ from source_context_mcp.core import AppContext, Settings
5
+
6
+
7
+ def register_tools(mcp: MCPServer):
8
+ @mcp.tool(description="This tool for development env only. Show all current config. Read source code to see more")
9
+ def debug(ctx: Context[AppContext]) -> Settings:
10
+ app_context: AppContext = ctx.request_context.lifespan_context
11
+ return app_context.settings
@@ -0,0 +1,38 @@
1
+ from typing import Any
2
+
3
+ from mcp.server import MCPServer
4
+
5
+ from source_context_mcp.core import ApiClientDep
6
+
7
+
8
+ def register_tools(mcp: MCPServer) -> None:
9
+ """
10
+ Registers all the graph tools.
11
+ Args:
12
+ mcp: MCPServer instance
13
+ """
14
+
15
+ @mcp.tool(description="List all files in specified project.")
16
+ async def list_files_in_project(client: ApiClientDep, project_id: str) -> Any:
17
+ res = await client.get(f"/graph/projects/{project_id}/files")
18
+ return res.data
19
+
20
+ @mcp.tool(description="Inspect file structure(classes and methods")
21
+ async def get_file_structure(client: ApiClientDep, file_id: str) -> Any:
22
+ res = await client.get(f"/graph/files/{file_id}/structures")
23
+ return res.data
24
+
25
+ @mcp.tool(description="Read file content.")
26
+ async def get_file_content(client: ApiClientDep, file_id: str) -> Any:
27
+ res = await client.get(f"/graph/files/{file_id}/content")
28
+ return res.data
29
+
30
+ @mcp.tool(description="Find all nodes that call/reference to the specified node.")
31
+ async def find_node_usages(client: ApiClientDep, node_id: str) -> Any:
32
+ res = await client.get(f"/graph/nodes/{node_id}/usages")
33
+ return res.data
34
+
35
+ @mcp.tool(description="Find all nodes(or its children) that the specified node is calling or reference to.")
36
+ async def find_node_callees(client: ApiClientDep, node_id: str) -> Any:
37
+ res = await client.get(f"/graph/nodes/{node_id}/callees")
38
+ return res.data
@@ -0,0 +1,37 @@
1
+ from typing import Any
2
+
3
+ from mcp.server import MCPServer
4
+
5
+ from source_context_mcp.core import ApiClientDep
6
+
7
+
8
+ def register_tools(mcp: MCPServer) -> None:
9
+ """
10
+ Registers all the vector tools.
11
+ Args:
12
+ mcp: MCP server instance
13
+ """
14
+
15
+ @mcp.tool()
16
+ async def search_in_workspace(client: ApiClientDep, query: str, workspace_id: int, top_k: int = 5) -> Any:
17
+ req_body = {
18
+ "query": query,
19
+ "top_k": top_k,
20
+ }
21
+
22
+ res = await client.post(f"/vector/search/{workspace_id}", req_body)
23
+
24
+ return res.data
25
+
26
+ @mcp.tool()
27
+ async def search_in_repo_and_branch(
28
+ client: ApiClientDep, repository_id: int, branch_name: str, query: str, top_k: int = 5
29
+ ) -> Any:
30
+ req_body = {
31
+ "query": query,
32
+ "top_k": top_k,
33
+ }
34
+
35
+ res = await client.post(f"/vector/search/{repository_id}/{branch_name}", req_body)
36
+
37
+ return res.data