Support onboarding task (initial demo)

* Add LLM prompt factory (yaml-based)
  * Add list_dir tool
  * Add onboarding tool (which returns prompt only, as registered MCP prompts
    do not appear to be usable by Claude Desktop)
This commit is contained in:
Dominik Jain committed 2025-03-24 17:07:40 +01:00
1 parent 3eb33d2050
commit 61f7c15b15
12 files changed
+520 -8

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
from sensai.util import logging
from serena.llm.multilang_prompt import MultiLangPromptTemplateCollection
log = logging.getLogger(__name__)
def main():
coll = MultiLangPromptTemplateCollection()
package = "src/serena"
# collect methods to generate
indent = " "
methods = []
for mpt in coll.prompt_templates.values():
prompt_name = mpt.name
params = mpt.get_parameters()
if len(params) == 0:
params_str = ""
else:
params_str = ", *, " + ", ".join(params)
methods.append(
f"def create_{prompt_name}(self{params_str}) -> str:"
+ f"\n{indent}{indent}return self._format_prompt('{prompt_name}', locals())\n\n{indent}"
)
for mpl in coll.prompt_lists.values():
prompt_name = mpl.name
methods.append(
f"def get_list_{prompt_name}(self) -> PromptList:" + f"\n{indent}{indent}return self._get_list('{prompt_name}')\n\n{indent}"
)
# write prompt factory with added methods
with open("code_templates/prompt_factory_template.py") as f:
code = f.read()
methods_str = "".join(methods)
code = code.replace("# methods", methods_str)
with open(f"{package}/llm/prompt_factory.py", "w") as f:
f.write(code)
if __name__ == "__main__":
logging.run_main(main)
+92 -8
View File
@@ -2,21 +2,25 @@
The Serena Model Context Protocol (MCP) Server
"""
import json
import os
import sys
from abc import abstractmethod
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import cast
from typing import Any, cast
import yaml
from mcp.server.fastmcp.prompts.base import Message, UserMessage
from mcp.server.fastmcp.server import Context, FastMCP, Settings
from sensai.util import logging
from multilspy import SyncLanguageServer
from multilspy.multilspy_config import Language, MultilspyConfig
from multilspy.multilspy_logger import MultilspyLogger
from serena.file_utils import scan_directory
from serena.llm.prompt_factory import PromptFactory
log = logging.getLogger(__name__)
@@ -24,6 +28,9 @@ log = logging.getLogger(__name__)
@dataclass
class SerenaMCPRequestContext:
language_server: SyncLanguageServer
project_root: str
project_config: dict[str, Any]
prompt_factory: PromptFactory
@asynccontextmanager
@@ -41,9 +48,9 @@ async def server_lifespan(mcp_server: FastMCP) -> AsyncIterator[SerenaMCPRequest
# read project configuration
with open(project_file, encoding="utf-8") as f:
project = yaml.safe_load(f)
language = Language(project["language"])
project_root = project["project_root"]
project_config = yaml.safe_load(f)
language = Language(project_config["language"])
project_root = project_config["project_root"]
# create and start the language server instance
config = MultilspyConfig(code_language=language)
@@ -51,7 +58,9 @@ async def server_lifespan(mcp_server: FastMCP) -> AsyncIterator[SerenaMCPRequest
language_server = SyncLanguageServer.create(config, logger, project_root)
language_server.start()
try:
yield SerenaMCPRequestContext(language_server=language_server)
yield SerenaMCPRequestContext(
language_server=language_server, project_root=project_root, project_config=project_config, prompt_factory=PromptFactory()
)
finally:
language_server.stop()
@@ -60,11 +69,16 @@ mcp_settings = Settings(lifespan=server_lifespan)
mcp = FastMCP(**mcp_settings.model_dump())
class Tool:
class Component(ABC):
def __init__(self, ctx: Context):
lifespan_context = cast(SerenaMCPRequestContext, ctx.request_context.lifespan_context)
self.langsrv = lifespan_context.language_server
self.project_root = lifespan_context.project_root
self.project_config = lifespan_context.project_config
self.prompt_factory = lifespan_context.prompt_factory
class Tool(Component):
def execute(self) -> str:
try:
return self._execute()
@@ -73,12 +87,47 @@ class Tool:
return f"Error executing tool: {e}"
@abstractmethod
def _execute(self):
def _execute(self) -> str:
pass
class SimplePrompt(Component):
def __init__(self, ctx: Context):
super().__init__(ctx)
self.messages = []
def create(self) -> str:
return self._create_prompt()
@abstractmethod
def _create_prompt(self) -> str:
pass
class SequentialPrompt(Component):
def __init__(self, ctx: Context):
super().__init__(ctx)
self.messages = []
def create(self) -> list[Message]:
self._add_messages()
return self.messages
@abstractmethod
def _add_messages(self) -> None:
pass
def _add_user_message(self, msg: str) -> None:
self.messages.append(UserMessage(content=msg))
@mcp.tool()
def read_file(ctx: Context, relative_path: str) -> str:
"""
:param ctx: the context object, which will be created and provided automatically
:param relative_path: the relative path to the file to read
:return: the full text of the file at the given relative path
"""
log.info(f"read_file: {relative_path=}")
class ReadFileTool(Tool):
@@ -89,6 +138,41 @@ def read_file(ctx: Context, relative_path: str) -> str:
return ReadFileTool(ctx).execute()
@mcp.tool()
def list_dir(ctx: Context, relative_path: str, recursive: bool) -> str:
"""
:param ctx: the context object, which will be created and provided automatically
:param relative_path: the relative path to the directory to list; pass "." to scan the project root
:param recursive: whether to scan subdirectories recursively
:return: a JSON object with the names of directories and files within the given directory
"""
log.info(f"list_dir: {relative_path=}")
class ListDirTool(Tool):
def _execute(self):
dirs, files = scan_directory(
os.path.join(self.project_root, relative_path), recursive=recursive, ignored_dirs=self.project_config["ignored_dirs"]
)
return json.dumps({"dirs": dirs, "files": files})
return ListDirTool(ctx).execute()
@mcp.tool()
def onboarding(ctx: Context) -> str:
"""
:param ctx: the context object, which will be created and provided automatically
:return: instructions on how to create the onboarding information
"""
onboarding_file = "serena_onboarding.md"
class OnboardingPrompt(SimplePrompt):
def _create_prompt(self):
return self.prompt_factory.create_onboarding_prompt(onboarding_file=onboarding_file)
return OnboardingPrompt(ctx).create()
if __name__ == "__main__":
logging.basicConfig(
level=logging.DEBUG, stream=sys.stderr, format="%(levelname)-5s %(asctime)-15s %(name)s:%(funcName)s:%(lineno)d - %(message)s"