1313import sys
1414import uuid
1515
16+ import finecode_jsonrpc
1617from finecode .wm_client import ApiClient
1718from finecode .wm_server import wm_lifecycle
1819from finecode_extension_api .resource_uri import path_to_resource_uri
1920from 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 ] = {}
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
6055def _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
7869def _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+
9692async 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
373374def 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