Skip to content

Commit 1102c9b

Browse files
committed
Add support of partial results in MCP server. Make WM clients identifiable, add client/initialize request in WM protocol. Store runner logs in separate directory. Fix LSP start with tcp. Fix per-project requests in LSP server (use project path instead of project name).
1 parent d207481 commit 1102c9b

10 files changed

Lines changed: 197 additions & 60 deletions

File tree

finecode_extension_runner/src/finecode_extension_runner/cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def start(
6060
/ ".venvs"
6161
/ env_name
6262
/ "logs"
63+
/ "runner"
6364
/ "runner.log")
6465

6566
logs.setup_logging(log_level="INFO" if trace is False else "TRACE", log_file_path=log_file_path)

finecode_extension_runner/src/finecode_extension_runner/lsp_server.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,38 @@ async def tcp_server(h: str, p: int):
192192
except asyncio.CancelledError:
193193
logger.debug("Server was cancelled")
194194

195+
async def start_tcp_async(self, host: str, port: int) -> None:
196+
"""Starts TCP server from within an existing event loop."""
197+
logger.info("Starting TCP server on %s:%s", host, port)
198+
199+
self._stop_event = stop_event = threading.Event()
200+
201+
async def lsp_connection(
202+
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
203+
):
204+
logger.debug("Connected to client")
205+
self.protocol.set_writer(writer) # type: ignore
206+
await run_async(
207+
stop_event=stop_event,
208+
reader=reader,
209+
protocol=self.protocol,
210+
logger=logger,
211+
error_handler=self.report_server_error,
212+
)
213+
logger.debug("Main loop finished")
214+
self.shutdown()
215+
216+
self._server = await asyncio.start_server(lsp_connection, host, port)
217+
218+
addrs = ", ".join(str(sock.getsockname()) for sock in self._server.sockets)
219+
logger.info(f"Serving on {addrs}")
220+
221+
try:
222+
async with self._server:
223+
await self._server.serve_forever()
224+
finally:
225+
await self._finecode_exit_stack.aclose()
226+
195227

196228

197229
def file_editor_file_change_to_lsp_text_edit(file_change: ifileeditor.FileChange) -> types.TextEdit:

src/finecode/lsp_server/endpoints/diagnostics.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
from finecode_extension_api.actions import lint as lint_action
1313

1414

15-
async def _find_project_name_for_file(file_path: Path) -> str | None:
16-
"""Return the project name containing *file_path*.
15+
async def _find_project_dir_for_file(file_path: Path) -> str | None:
16+
"""Return the absolute directory path of the project containing *file_path*.
1717
1818
This helper delegates the lookup to the WM server via
1919
``workspace/findProjectForFile``; the server applies the same logic that
@@ -70,15 +70,15 @@ async def document_diagnostic_with_full_result(
7070
logger.error("Diagnostics requested but WM client not connected")
7171
return None
7272

73-
project_name = await _find_project_name_for_file(file_path)
74-
if project_name is None:
73+
project_dir = await _find_project_dir_for_file(file_path)
74+
if project_dir is None:
7575
logger.error(f"Cannot determine project for diagnostics: {file_path}")
7676
return None
7777

7878
try:
7979
response = await global_state.wm_client.run_action(
8080
action="lint",
81-
project=project_name,
81+
project=project_dir,
8282
params={
8383
"target": "files",
8484
"file_paths": [str(file_path)],
@@ -142,8 +142,8 @@ async def document_diagnostic_with_partial_results(
142142
logger.error("Diagnostics requested but WM client not connected")
143143
return None
144144

145-
project_name = await _find_project_name_for_file(file_path)
146-
if project_name is None:
145+
project_dir = await _find_project_dir_for_file(file_path)
146+
if project_dir is None:
147147
logger.error(f"Cannot determine project for diagnostics: {file_path}")
148148
return None
149149

@@ -155,7 +155,7 @@ async def document_diagnostic_with_partial_results(
155155
"actions/runWithPartialResults",
156156
{
157157
"action": "lint",
158-
"project": project_name,
158+
"project": project_dir,
159159
"params": {"file_paths": [str(file_path)]},
160160
"partialResultToken": partial_result_token,
161161
"options": {"resultFormats": ["json"], "trigger": "system", "devEnv": "ide"},

src/finecode/lsp_server/endpoints/formatting.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,15 @@ async def format_document(ls: LanguageServer, params: types.DocumentFormattingPa
2222
logger.error("Formatting requested but WM client not connected")
2323
return None
2424

25-
project_name = await global_state.wm_client.find_project_for_file(str(file_path))
26-
if project_name is None:
25+
project_dir = await global_state.wm_client.find_project_for_file(str(file_path))
26+
if project_dir is None:
2727
logger.error(f"Cannot determine project for formatting: {file_path}")
2828
return []
2929

3030
try:
3131
response = await global_state.wm_client.run_action(
3232
action="format",
33-
project=project_name,
33+
project=project_dir,
3434
params={"file_paths": [str(file_path)], "save": False, "target": "files"},
3535
options={"trigger": "user", "devEnv": "ide"},
3636
)

src/finecode/lsp_server/endpoints/inlay_hints.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,15 @@ async def document_inlay_hint(
5353
logger.error("Inlay hints requested but WM client not connected")
5454
return None
5555

56-
project_name = await global_state.wm_client.find_project_for_file(str(file_path))
57-
if project_name is None:
56+
project_dir = await global_state.wm_client.find_project_for_file(str(file_path))
57+
if project_dir is None:
5858
# Not all files belong to a project with this action — not an error.
5959
return []
6060

6161
try:
6262
response = await global_state.wm_client.run_action(
6363
action="text_document_inlay_hint",
64-
project=project_name,
64+
project=project_dir,
6565
params=inlay_hint_params_to_dict(params),
6666
options={"trigger": "system", "devEnv": "ide"},
6767
)

src/finecode/lsp_server/lsp_server.py

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ async def _on_initialized(ls: LanguageServer, params: types.InitializedParams):
220220

221221
try:
222222
global_state.wm_client = ApiClient()
223-
await global_state.wm_client.connect("127.0.0.1", port)
223+
await global_state.wm_client.connect("127.0.0.1", port, client_id="lsp")
224224
except (ConnectionRefusedError, OSError) as exc:
225225
logger.error(f"Could not connect to FineCode WM server: {exc}")
226226
global_state.wm_client = None
@@ -234,18 +234,14 @@ async def _on_initialized(ls: LanguageServer, params: types.InitializedParams):
234234
)
235235
)
236236

237-
try:
238-
info = await global_state.wm_client.get_info()
239-
log_path = info.get("logFilePath")
240-
if log_path:
241-
ls.window_log_message(
242-
types.LogMessageParams(
243-
type=types.MessageType.Info,
244-
message=f"FineCode WM Server log: {log_path}",
245-
)
237+
log_path = global_state.wm_client.server_info.get("logFilePath")
238+
if log_path:
239+
ls.window_log_message(
240+
types.LogMessageParams(
241+
type=types.MessageType.Info,
242+
message=f"FineCode WM Server log: {log_path}",
246243
)
247-
except Exception:
248-
pass
244+
)
249245

250246
# Register notification handlers for server→client push messages.
251247
async def on_tree_changed(params: dict) -> None:

src/finecode/lsp_server/main.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,7 @@ async def start(
1515
global_state.lsp_log_file_path = logger_utils.init_logger(log_name="lsp_server", log_level=log_level)
1616
global_state.wm_log_level = log_level
1717
server = create_lsp_server()
18-
await server.start_io_async()
18+
if comm_type == communication_utils.CommunicationType.TCP:
19+
await server.start_tcp_async(host, port)
20+
else:
21+
await server.start_io_async()

src/finecode/mcp_server.py

Lines changed: 78 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,79 @@
1111
import json
1212
import pathlib
1313
import sys
14+
import uuid
1415

16+
from finecode.wm_client import ApiClient
17+
from finecode.wm_server import wm_lifecycle
1518
from loguru import logger
1619
from mcp.server import Server
1720
from mcp.server.stdio import stdio_server
1821
from mcp.types import TextContent, Tool
1922

20-
from finecode.wm_client import ApiClient
21-
from finecode.wm_server import wm_lifecycle
22-
23-
2423
_wm_client = ApiClient()
2524
server = Server("FineCode")
2625

26+
_partial_result_queues: dict[str, asyncio.Queue] = {}
27+
28+
29+
def _setup_partial_result_forwarding() -> None:
30+
"""Register the WM partial-result notification handler.
31+
32+
Must be called once after ``_wm_client.connect()``. Each ``actions/partialResult``
33+
notification is routed by token to the matching per-call asyncio.Queue.
34+
"""
35+
36+
async def _on_partial_result(params: dict) -> None:
37+
token = params.get("token")
38+
value = params.get("value")
39+
if token and value is not None:
40+
queue = _partial_result_queues.get(token)
41+
if queue is not None:
42+
queue.put_nowait(value)
43+
44+
_wm_client.on_notification("actions/partialResult", _on_partial_result)
45+
46+
47+
async def _run_with_progress(
48+
action: str,
49+
project: str,
50+
params: dict,
51+
options: dict,
52+
session,
53+
) -> dict:
54+
"""Run a WM action with streaming partial results forwarded as MCP log messages.
55+
56+
``project`` may be ``""`` to run across all projects that expose the action.
57+
Each ``actions/partialResult`` notification is forwarded to the MCP client as a
58+
``notifications/message`` log message while the call blocks waiting for the final result.
59+
"""
60+
token = str(uuid.uuid4())
61+
queue: asyncio.Queue = asyncio.Queue()
62+
_partial_result_queues[token] = queue
63+
64+
async def _forward() -> None:
65+
try:
66+
while True:
67+
value = await queue.get()
68+
await session.send_log_message(
69+
level="info", data=value, logger="finecode"
70+
)
71+
except asyncio.CancelledError:
72+
pass
73+
74+
result_task = asyncio.create_task(
75+
_wm_client.run_action_with_partial_results(
76+
action, project, token, params, options
77+
)
78+
)
79+
forward_task = asyncio.create_task(_forward())
80+
try:
81+
return await result_task
82+
finally:
83+
forward_task.cancel()
84+
await asyncio.gather(forward_task, return_exceptions=True)
85+
_partial_result_queues.pop(token, None)
86+
2787

2888
@server.list_tools()
2989
async def list_tools() -> list[Tool]:
@@ -167,22 +227,22 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
167227
params={
168228
"source_file_path": str(project_path / "pyproject.toml"),
169229
"project_raw_config": raw_config,
170-
"target_file_path": str(project_path / "finecode_config_dump" / "pyproject.toml"),
230+
"target_file_path": str(
231+
project_path / "finecode_config_dump" / "pyproject.toml"
232+
),
171233
},
172234
options={"resultFormats": ["json"], "trigger": "user", "devEnv": "ai"},
173235
)
174236
return [TextContent(type="text", text=json.dumps(result))]
175237

238+
from mcp.server.lowlevel.server import request_ctx
239+
240+
session = request_ctx.get().session
176241
project = arguments.pop("project", None)
177242
options = {"resultFormats": ["json"], "trigger": "user", "devEnv": "ai"}
178-
if project is not None:
179-
result = await _wm_client.run_action(
180-
name, project, params=arguments or None, options=options
181-
)
182-
else:
183-
result = await _wm_client.run_batch(
184-
[name], params=arguments or None, options=options
185-
)
243+
result = await _run_with_progress(
244+
name, project or "", arguments or {}, options, session
245+
)
186246
return [TextContent(type="text", text=json.dumps(result))]
187247

188248

@@ -209,10 +269,13 @@ def start(workdir: pathlib.Path, port_file: pathlib.Path | None = None) -> None:
209269

210270
async def _run() -> None:
211271
try:
212-
await _wm_client.connect("127.0.0.1", port)
272+
await _wm_client.connect("127.0.0.1", port, client_id="mcp")
213273
except (ConnectionRefusedError, OSError) as exc:
214-
logger.error(f"Could not connect to FineCode WM server on port {port}: {exc}")
274+
logger.error(
275+
f"Could not connect to FineCode WM server on port {port}: {exc}"
276+
)
215277
sys.exit(1)
278+
_setup_partial_result_forwarding()
216279
logger.debug(f"Add dir to API Client: {workdir}")
217280
await _wm_client.add_dir(workdir)
218281
logger.debug("Added dir")

src/finecode/wm_client.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,22 +79,26 @@ def __init__(self) -> None:
7979
str, collections.abc.Callable[..., collections.abc.Coroutine]
8080
] = {}
8181
self._reader_task: asyncio.Task | None = None
82+
self.server_info: dict = {}
8283

8384
# -- Connection lifecycle -----------------------------------------------
8485

85-
async def connect(self, host: str, port: int) -> None:
86+
async def connect(self, host: str, port: int, client_id: str | None = None) -> None:
8687
self._reader, self._writer = await asyncio.open_connection(host, port)
8788
self._reader_task = asyncio.create_task(self._read_loop())
8889
logger.info(f"Connected to FineCode API at {host}:{port}")
8990
try:
90-
info = await self.get_info()
91-
log_path = info.get("logFilePath")
91+
params: dict = {}
92+
if client_id is not None:
93+
params["clientId"] = client_id
94+
self.server_info = await self.request("client/initialize", params) or {}
95+
log_path = self.server_info.get("logFilePath")
9296
if log_path:
9397
logger.info(f"WM Server log file: {log_path}")
9498
else:
9599
logger.info("WM Server returned no log file path")
96100
except Exception as exception:
97-
logger.info(f"Failed to get WM Server log file path: {exception}")
101+
logger.info(f"Failed to initialize with WM Server: {exception}")
98102

99103
async def close(self) -> None:
100104
if self._reader_task is not None:
@@ -140,7 +144,7 @@ async def list_projects(self) -> list[dict]:
140144
return await self.request("workspace/listProjects")
141145

142146
async def find_project_for_file(self, file_path: str) -> str | None:
143-
"""Return the project name containing a given file.
147+
"""Return the absolute directory path of the project containing a given file.
144148
145149
An empty string or null result indicates that the file does not belong to
146150
any project. This mirrors the server's
@@ -277,6 +281,30 @@ async def run_action(
277281
body["params"] = params
278282
return await self.request("actions/run", body)
279283

284+
async def run_action_with_partial_results(
285+
self,
286+
action: str,
287+
project: str,
288+
partial_result_token: str,
289+
params: dict | None = None,
290+
options: dict | None = None,
291+
) -> dict:
292+
"""Run an action with streaming partial results via notifications.
293+
294+
Pass ``project=""`` to run across all projects that expose the action.
295+
Partial results are delivered as ``actions/partialResult`` notifications
296+
before this coroutine returns the aggregated final result.
297+
"""
298+
body: dict = {
299+
"action": action,
300+
"project": project,
301+
"partialResultToken": partial_result_token,
302+
"options": options or {},
303+
}
304+
if params:
305+
body["params"] = params
306+
return await self.request("actions/runWithPartialResults", body)
307+
280308
async def add_dir(
281309
self,
282310
dir_path: pathlib.Path,

0 commit comments

Comments
 (0)