Skip to content

Commit 278e38d

Browse files
committed
Reimplement MCP server with finecode_jsonrpc instead of mcp library. Initially started with process exit code, which was problematic to handle.
1 parent 445d2fd commit 278e38d

2 files changed

Lines changed: 122 additions & 120 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,7 @@ dependencies = [
1616
"finecode_builtin_handlers~=0.2.0a0",
1717
"finecode_jsonrpc~=0.1.0a0",
1818
"ordered-set==4.1.*",
19-
"mcp>=1.0.0",
20-
"fine_python_virtualenv~=0.2.0a0",
21-
"fine_python_pip~=0.2.0a0",
19+
"fine_python_uv~=0.1.0a0",
2220
"culsans==0.11.*",
2321
"apischema==0.19.*",
2422
]

src/finecode/mcp_server.py

Lines changed: 121 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,13 @@
1313
import sys
1414
import uuid
1515

16+
import finecode_jsonrpc
1617
from finecode.wm_client import ApiClient
1718
from finecode.wm_server import wm_lifecycle
1819
from finecode_extension_api.resource_uri import path_to_resource_uri
1920
from loguru import logger
20-
from mcp.server import Server
21-
from mcp.server.stdio import stdio_server
22-
from mcp.types import TextContent, Tool
2321

2422
_wm_client = ApiClient()
25-
server = Server("FineCode")
2623

2724
_partial_result_queues: dict[str, asyncio.Queue] = {}
2825
_progress_queues: dict[str, asyncio.Queue] = {}
@@ -31,21 +28,19 @@
3128
_workdir: pathlib.Path | None = None
3229
_wm_connected: bool = False
3330

34-
# Populated by list_tools(); maps MCP tool name (action name) → action source.
31+
# Populated by _handle_list_tools(); maps MCP tool name (action name) → action source.
3532
_tool_name_to_source: dict[str, str] = {}
3633

34+
_client_name: str | None = None
35+
_session: finecode_jsonrpc.JsonRpcServerSession | None = None
3736

38-
async def _ensure_wm_connected(session) -> None:
37+
38+
async def _ensure_wm_connected() -> None:
3939
global _wm_connected
4040
if _wm_connected:
4141
return
4242

43-
client_id = "mcp"
44-
if session is not None and session.client_params is not None:
45-
info = session.client_params.clientInfo
46-
if info is not None and info.name:
47-
client_id = f"mcp-{info.name}"
48-
43+
client_id = f"mcp-{_client_name}" if _client_name else "mcp"
4944
logger.info(f"MCP: Connecting to WM server on 127.0.0.1:{_wm_port} as {client_id!r}")
5045
await _wm_client.connect("127.0.0.1", _wm_port, client_id=client_id)
5146
logger.info("MCP: Connected to WM server")
@@ -58,11 +53,7 @@ async def _ensure_wm_connected(session) -> None:
5853

5954

6055
def _setup_partial_result_forwarding() -> None:
61-
"""Register the WM partial-result notification handler.
62-
63-
Must be called once after ``_wm_client.connect()``. Each ``actions/partialResult``
64-
notification is routed by token to the matching per-call asyncio.Queue.
65-
"""
56+
"""Register the WM partial-result notification handler."""
6657

6758
async def _on_partial_result(params: dict) -> None:
6859
token = params.get("token")
@@ -76,11 +67,7 @@ async def _on_partial_result(params: dict) -> None:
7667

7768

7869
def _setup_progress_forwarding() -> None:
79-
"""Register the WM progress notification handler.
80-
81-
Must be called once after ``_wm_client.connect()``. Each ``actions/progress``
82-
notification is routed by token to the matching per-call asyncio.Queue.
83-
"""
70+
"""Register the WM progress notification handler."""
8471

8572
async def _on_progress(params: dict) -> None:
8673
token = params.get("token")
@@ -93,22 +80,22 @@ async def _on_progress(params: dict) -> None:
9380
_wm_client.on_notification("actions/progress", _on_progress)
9481

9582

83+
async def _send_log_message(level: str, data: object, logger_name: str = "finecode") -> None:
84+
if _session is None:
85+
return
86+
await _session.send_notification(
87+
"notifications/message",
88+
{"level": level, "data": data, "logger": logger_name},
89+
)
90+
91+
9692
async def _run_with_progress(
9793
action_source: str,
9894
project: str,
9995
params: dict,
10096
options: dict,
101-
session,
10297
) -> dict:
103-
"""Run a WM action with streaming partial results and progress forwarded as MCP messages.
104-
105-
``project`` may be ``""`` to run across all projects that expose the action.
106-
Each ``actions/partialResult`` notification is forwarded to the MCP client as a
107-
``notifications/message`` log message while the call blocks waiting for the final result.
108-
Partial results are also collected and returned in ``resultsByProject`` keyed by project
109-
path, so callers receive the actual action output rather than just ``returnCode``.
110-
Progress notifications are forwarded as log messages with the progress metadata.
111-
"""
98+
"""Run a WM action with streaming partial results and progress forwarded as MCP messages."""
11299
token = str(uuid.uuid4())
113100
progress_token = str(uuid.uuid4())
114101
queue: asyncio.Queue = asyncio.Queue()
@@ -126,9 +113,7 @@ async def _forward_partials() -> None:
126113
result_by_format = value.get("resultByFormat", {})
127114
if result_by_format:
128115
results_by_project[project_key] = result_by_format
129-
await session.send_log_message(
130-
level="info", data=value, logger="finecode"
131-
)
116+
await _send_log_message("info", value)
132117
except asyncio.CancelledError:
133118
pass
134119

@@ -142,9 +127,7 @@ async def _forward_progress() -> None:
142127
log_data = {"progress_type": progress_type, "message": message}
143128
if percentage is not None:
144129
log_data["percentage"] = percentage
145-
await session.send_log_message(
146-
level="info", data=log_data, logger="finecode.progress"
147-
)
130+
await _send_log_message("info", log_data, "finecode.progress")
148131
except asyncio.CancelledError:
149132
pass
150133

@@ -181,33 +164,47 @@ async def _forward_progress() -> None:
181164
return result
182165

183166

184-
@server.list_tools()
185-
async def list_tools() -> list[Tool]:
186-
"""Build the MCP tool list from live WM data.
187-
188-
Fetches all actions and their payload schemas from the WM, then
189-
constructs one ``Tool`` per action with the real input schema.
190-
A static ``list_projects`` tool is always included.
191-
"""
192-
logger.info("MCP list_tools() called")
193-
from mcp.server.lowlevel.server import request_ctx
194-
session = request_ctx.get().session
195-
await _ensure_wm_connected(session)
196-
tools: list[Tool] = [
197-
Tool(
198-
name="list_projects",
199-
description="List all projects in the FineCode workspace with their names, paths, and statuses",
200-
inputSchema={"type": "object", "properties": {}},
201-
),
202-
Tool(
203-
name="list_runners",
204-
description="List all extension runners and their status (running, stopped, error). Use this to diagnose failures when actions do not respond.",
205-
inputSchema={"type": "object", "properties": {}},
206-
),
207-
Tool(
208-
name="list_actions",
209-
description="List actions available in the workspace, optionally filtered to a single project. Returns action names and which projects expose them.",
210-
inputSchema={
167+
# ---------------------------------------------------------------------------
168+
# MCP protocol handlers
169+
# ---------------------------------------------------------------------------
170+
171+
172+
async def _handle_initialize(params: dict | None) -> dict:
173+
global _client_name
174+
if params:
175+
client_info = params.get("clientInfo") or {}
176+
_client_name = client_info.get("name")
177+
return {
178+
"protocolVersion": "2024-11-05",
179+
"capabilities": {"tools": {}},
180+
"serverInfo": {"name": "FineCode", "version": "1.0.0"},
181+
}
182+
183+
184+
async def _handle_ping(_params: dict | None) -> dict:
185+
return {}
186+
187+
188+
async def _handle_list_tools(_params: dict | None) -> dict:
189+
"""Build the MCP tool list from live WM data."""
190+
logger.info("MCP tools/list called")
191+
await _ensure_wm_connected()
192+
193+
tools: list[dict] = [
194+
{
195+
"name": "list_projects",
196+
"description": "List all projects in the FineCode workspace with their names, paths, and statuses",
197+
"inputSchema": {"type": "object", "properties": {}},
198+
},
199+
{
200+
"name": "list_runners",
201+
"description": "List all extension runners and their status (running, stopped, error). Use this to diagnose failures when actions do not respond.",
202+
"inputSchema": {"type": "object", "properties": {}},
203+
},
204+
{
205+
"name": "list_actions",
206+
"description": "List actions available in the workspace, optionally filtered to a single project. Returns action names and which projects expose them.",
207+
"inputSchema": {
211208
"type": "object",
212209
"properties": {
213210
"project": {
@@ -216,11 +213,11 @@ async def list_tools() -> list[Tool]:
216213
}
217214
},
218215
},
219-
),
220-
Tool(
221-
name="get_project_raw_config",
222-
description="Return the resolved (post-preset-merge) configuration for a project. Use this to understand what actions and handlers are configured.",
223-
inputSchema={
216+
},
217+
{
218+
"name": "get_project_raw_config",
219+
"description": "Return the resolved (post-preset-merge) configuration for a project. Use this to understand what actions and handlers are configured.",
220+
"inputSchema": {
224221
"type": "object",
225222
"properties": {
226223
"project": {
@@ -230,11 +227,11 @@ async def list_tools() -> list[Tool]:
230227
},
231228
"required": ["project"],
232229
},
233-
),
234-
Tool(
235-
name="dump_config",
236-
description="Return the fully resolved project configuration with all presets applied and the presets key removed. Use this to understand the complete effective configuration a project runs with.",
237-
inputSchema={
230+
},
231+
{
232+
"name": "dump_config",
233+
"description": "Return the fully resolved project configuration with all presets applied and the presets key removed. Use this to understand the complete effective configuration a project runs with.",
234+
"inputSchema": {
238235
"type": "object",
239236
"properties": {
240237
"project": {
@@ -244,7 +241,7 @@ async def list_tools() -> list[Tool]:
244241
},
245242
"required": ["project"],
246243
},
247-
),
244+
},
248245
]
249246

250247
try:
@@ -300,41 +297,44 @@ async def list_tools() -> list[Tool]:
300297
"required": schema.get("required", []) if schema else [],
301298
}
302299
tools.append(
303-
Tool(
304-
name=name,
305-
description=description,
306-
inputSchema=input_schema,
307-
)
300+
{
301+
"name": name,
302+
"description": description,
303+
"inputSchema": input_schema,
304+
}
308305
)
309306

310-
logger.info(f"MCP list_tools() returning {len(tools)} tools total")
311-
return tools
307+
logger.info(f"MCP tools/list returning {len(tools)} tools total")
308+
return {"tools": tools}
312309

313310

314-
@server.call_tool()
315-
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
311+
async def _handle_call_tool(params: dict | None) -> dict:
316312
"""Dispatch an MCP tool call to the WM server."""
317-
from mcp.server.lowlevel.server import request_ctx
318-
session = request_ctx.get().session
319-
await _ensure_wm_connected(session)
313+
if not params:
314+
return {"content": [{"type": "text", "text": "Missing params"}], "isError": True}
315+
316+
name = params.get("name", "")
317+
arguments: dict = dict(params.get("arguments") or {})
318+
319+
await _ensure_wm_connected()
320320

321321
if name == "list_projects":
322322
result = await _wm_client.list_projects()
323-
return [TextContent(type="text", text=json.dumps({"projects": result}))]
323+
return {"content": [{"type": "text", "text": json.dumps({"projects": result})}]}
324324

325325
if name == "list_runners":
326326
result = await _wm_client.list_runners()
327-
return [TextContent(type="text", text=json.dumps({"runners": result}))]
327+
return {"content": [{"type": "text", "text": json.dumps({"runners": result})}]}
328328

329329
if name == "list_actions":
330330
project = arguments.get("project")
331331
result = await _wm_client.list_actions(project=project)
332-
return [TextContent(type="text", text=json.dumps({"actions": result}))]
332+
return {"content": [{"type": "text", "text": json.dumps({"actions": result})}]}
333333

334334
if name == "get_project_raw_config":
335335
project = arguments["project"]
336336
result = await _wm_client.get_project_raw_config(project)
337-
return [TextContent(type="text", text=json.dumps({"rawConfig": result}))]
337+
return {"content": [{"type": "text", "text": json.dumps({"rawConfig": result})}]}
338338

339339
if name == "dump_config":
340340
project = arguments["project"]
@@ -356,18 +356,19 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
356356
},
357357
options={"resultFormats": ["json"], "trigger": "user", "devEnv": "ai"},
358358
)
359-
return [TextContent(type="text", text=json.dumps(result))]
360-
361-
from mcp.server.lowlevel.server import request_ctx
359+
return {"content": [{"type": "text", "text": json.dumps(result)}]}
362360

363-
session = request_ctx.get().session
364361
project = arguments.pop("project", None)
365362
action_source = _tool_name_to_source.get(name, name)
366363
options = {"resultFormats": ["json"], "trigger": "user", "devEnv": "ai"}
367364
result = await _run_with_progress(
368-
action_source, project or "", arguments or {}, options, session
365+
action_source, project or "", arguments or {}, options
369366
)
370-
return [TextContent(type="text", text=json.dumps(result))]
367+
return {"content": [{"type": "text", "text": json.dumps(result)}]}
368+
369+
370+
async def _noop(_params: dict | None) -> None:
371+
pass
371372

372373

373374
def start(workdir: pathlib.Path, port_file: pathlib.Path | None = None) -> None:
@@ -376,7 +377,7 @@ def start(workdir: pathlib.Path, port_file: pathlib.Path | None = None) -> None:
376377
If *port_file* is given, a dedicated WM server is started that writes its
377378
port to that file instead of the shared discovery file.
378379
379-
The WM connection is established lazily on the first ``list_tools`` call so
380+
The WM connection is established lazily on the first ``tools/list`` call so
380381
that the MCP client name (from the ``initialize`` handshake) can be included
381382
in the ``client_id`` sent to the WM server.
382383
"""
@@ -398,21 +399,24 @@ def start(workdir: pathlib.Path, port_file: pathlib.Path | None = None) -> None:
398399
_workdir = workdir
399400

400401
async def _run() -> None:
401-
try:
402-
logger.info("MCP: Starting stdio server")
403-
async with stdio_server() as (read_stream, write_stream):
404-
logger.info("MCP: Stdio server ready, running MCP server")
405-
await server.run(
406-
read_stream,
407-
write_stream,
408-
server.create_initialization_options(),
409-
)
410-
finally:
411-
if _wm_connected:
412-
logger.info("MCP: Closing WM client")
413-
await _wm_client.close()
414-
415-
try:
416-
asyncio.run(_run())
417-
except KeyboardInterrupt:
418-
pass
402+
global _session
403+
transport = finecode_jsonrpc.ServerStdioTransport(readable_id="mcp_server")
404+
_session = finecode_jsonrpc.JsonRpcServerSession()
405+
_session.attach(transport)
406+
_session.on_request("initialize", _handle_initialize)
407+
_session.on_request("ping", _handle_ping)
408+
_session.on_request("tools/list", _handle_list_tools)
409+
_session.on_request("tools/call", _handle_call_tool)
410+
_session.on_notification("notifications/initialized", _noop)
411+
412+
await transport.start()
413+
logger.info("MCP: stdio server ready")
414+
while not transport._stop_event.is_set():
415+
await asyncio.sleep(0.05)
416+
417+
logger.info("MCP: stdio transport stopped")
418+
if _wm_connected:
419+
logger.info("MCP: Closing WM client")
420+
await _wm_client.close()
421+
422+
asyncio.run(_run())

0 commit comments

Comments
 (0)