Add MCP server support and long paste handling

This commit is contained in:
2026-01-21 22:12:25 -08:00
parent 80629105ed
commit f07effc51e
4 changed files with 190 additions and 25 deletions

View File

@@ -1,8 +1,8 @@
{ {
"model": null, "model": null,
"max_iters": 20, "max_iters": 50,
"lm": "openrouter/anthropic/claude-3.5-sonnet", "lm": "openrouter/anthropic/claude-sonnet-4.5",
"sub_lm": "openrouter/openai/gpt-4.1", "sub_lm": "openrouter/openai/gpt-5-mini",
"api_base": "https://openrouter.ai/api/v1", "api_base": "https://openrouter.ai/api/v1",
"max_tokens": 32000, "max_tokens": 32000,
"max_output_chars": 100000, "max_output_chars": 100000,

View File

@@ -2,6 +2,9 @@ import os
import re import re
import glob as globlib import glob as globlib
import subprocess import subprocess
import shlex
import json
import tempfile
from modaic import PrecompiledProgram, PrecompiledConfig from modaic import PrecompiledProgram, PrecompiledConfig
import dspy import dspy
from dspy.utils.callback import BaseCallback from dspy.utils.callback import BaseCallback
@@ -24,6 +27,16 @@ MAGENTA = "\033[35m"
# --- Display utilities --- # --- Display utilities ---
LONG_PASTE_THRESHOLD = int(os.environ.get("NANOCODE_LONG_PASTE_THRESHOLD", "4000"))
def save_long_paste(text: str) -> str:
fd, path = tempfile.mkstemp(prefix="nanocode_paste_", suffix=".txt")
with os.fdopen(fd, "w") as handle:
handle.write(text)
return path
def separator(): def separator():
"""Return a horizontal separator line that fits the terminal width.""" """Return a horizontal separator line that fits the terminal width."""
@@ -264,9 +277,9 @@ class ToolLoggingCallback(BaseCallback):
class RLMCodingConfig(PrecompiledConfig): class RLMCodingConfig(PrecompiledConfig):
max_iters: int = 20 max_iters: int = 50
lm: str = "openrouter/anthropic/claude-3.5-sonnet" # Default fallback lm: str = "openrouter/anthropic/claude-sonnet-4.5"
sub_lm: str = "openrouter/openai/gpt-4.1" # Default fallback sub_lm: str = "openrouter/openai/gpt-5-mini"
api_base: str = "https://openrouter.ai/api/v1" api_base: str = "https://openrouter.ai/api/v1"
max_tokens: int = 32000 max_tokens: int = 32000
max_output_chars: int = 100000 max_output_chars: int = 100000
@@ -291,13 +304,13 @@ class RLMCodingProgram(PrecompiledProgram):
# tool logging for introspections on multi-turn conversations # tool logging for introspections on multi-turn conversations
dspy.settings.configure(callbacks=[ToolLoggingCallback()]) dspy.settings.configure(callbacks=[ToolLoggingCallback()])
lm = dspy.LM( self.lm = dspy.LM(
self.config.lm, self.config.lm,
api_base=self.config.api_base, api_base=self.config.api_base,
max_tokens=self.config.max_tokens, max_tokens=self.config.max_tokens,
track_usage=self.config.track_usage, track_usage=self.config.track_usage,
) )
sub_lm = dspy.LM( self.sub_lm = dspy.LM(
self.config.sub_lm, self.config.sub_lm,
api_base=self.config.api_base, api_base=self.config.api_base,
max_tokens=self.config.max_tokens, max_tokens=self.config.max_tokens,
@@ -305,14 +318,13 @@ class RLMCodingProgram(PrecompiledProgram):
) )
agent = dspy.RLM( agent = dspy.RLM(
CodingAssistant, CodingAssistant,
sub_lm=sub_lm, sub_lm=self.sub_lm,
tools=self.tools, tools=self.tools,
max_output_chars=self.config.max_output_chars, max_output_chars=self.config.max_output_chars,
max_iterations=self.config.max_iters, max_iterations=self.config.max_iters,
verbose=self.config.verbose, verbose=self.config.verbose,
) )
agent.set_lm(self.lm)
agent.set_lm(lm)
self.agent = agent self.agent = agent
def forward(self, task: str) -> str: def forward(self, task: str) -> str:
@@ -321,17 +333,33 @@ class RLMCodingProgram(PrecompiledProgram):
def get_tools(self): def get_tools(self):
return self.tools return self.tools
def set_tool(self, name: str, tool: callable): def set_tool(self, name: str, tool: callable):
self.tools[name] = tool self.tools[name] = tool
self.reload_repl_tools()
def remove_tool(self, name: str): def remove_tool(self, name: str):
del self.tools[name] if name in self.tools:
del self.tools[name]
self.reload_repl_tools()
def reload_repl_tools(
self,
): # we need to create a new instance for tool mutations to be passed back into the REPL
new_instance = dspy.RLM(
CodingAssistant,
sub_lm=self.sub_lm,
tools=self.tools,
max_output_chars=self.config.max_output_chars,
max_iterations=self.config.max_iters,
verbose=self.config.verbose,
)
new_instance.set_lm(self.lm)
self.agent = new_instance
def main(): def main():
model = os.getenv("MODEL") model = select_model()
if model is None:
model = select_model()
# Add openrouter/ prefix if not already present # Add openrouter/ prefix if not already present
if not model.startswith("openrouter/"): if not model.startswith("openrouter/"):
@@ -348,12 +376,35 @@ def main():
# Conversation history for context # Conversation history for context
history = [] history = []
# MCP servers registry
mcp_servers = {}
def register_mcp_server(name, server):
tool_names = []
for tool in server.tools:
tool_name = f"{name}_{tool.__name__}"
agent.set_tool(tool_name, tool)
tool_names.append(tool_name)
return tool_names
while True: while True:
try: try:
print(separator()) print(separator())
user_input = input(f"{BOLD}{BLUE}{RESET} ").strip() user_input = input(f"{BOLD}{BLUE}{RESET} ").strip()
print(separator()) print(separator())
tmp_paste_path = None
if len(user_input) > LONG_PASTE_THRESHOLD:
tmp_paste_path = save_long_paste(user_input)
print(
f"{YELLOW}⏺ Long paste detected ({len(user_input)} chars). Saved to {tmp_paste_path}{RESET}"
)
user_input = (
f"The user pasted a long input ({len(user_input)} chars). "
f"It has been saved to {tmp_paste_path}. "
"Use read_file to view it. The file will be deleted after this response."
)
if not user_input: if not user_input:
continue continue
if user_input in ("/q", "exit"): if user_input in ("/q", "exit"):
@@ -377,16 +428,32 @@ def main():
continue continue
elif choice in AVAILABLE_MODELS: elif choice in AVAILABLE_MODELS:
name, model_id = AVAILABLE_MODELS[choice] name, model_id = AVAILABLE_MODELS[choice]
new_model = model_id if model_id.startswith("openrouter/") else f"openrouter/{model_id}" new_model = (
model_id
if model_id.startswith("openrouter/")
else f"openrouter/{model_id}"
)
config.lm = new_model config.lm = new_model
agent = RLMCodingProgram(config) agent = RLMCodingProgram(config)
for server_name, info in mcp_servers.items():
info["tools"] = register_mcp_server(server_name, info["server"])
print(f"{GREEN}⏺ Switched to: {name} ({new_model}){RESET}") print(f"{GREEN}⏺ Switched to: {name} ({new_model}){RESET}")
elif choice == "c": elif choice == "c":
custom_model = input(f"{BOLD}{BLUE}{RESET} Enter model ID: ").strip() custom_model = input(
f"{BOLD}{BLUE}{RESET} Enter model ID: "
).strip()
if custom_model: if custom_model:
new_model = custom_model if custom_model.startswith("openrouter/") else f"openrouter/{custom_model}" new_model = (
custom_model
if custom_model.startswith("openrouter/")
else f"openrouter/{custom_model}"
)
config.lm = new_model config.lm = new_model
agent = RLMCodingProgram(config) agent = RLMCodingProgram(config)
for server_name, info in mcp_servers.items():
info["tools"] = register_mcp_server(
server_name, info["server"]
)
print(f"{GREEN}⏺ Switched to custom model: {new_model}{RESET}") print(f"{GREEN}⏺ Switched to custom model: {new_model}{RESET}")
else: else:
print(f"{RED}⏺ Invalid model ID, keeping current model{RESET}") print(f"{RED}⏺ Invalid model ID, keeping current model{RESET}")
@@ -394,6 +461,97 @@ def main():
print(f"{RED}⏺ Invalid choice, keeping current model{RESET}") print(f"{RED}⏺ Invalid choice, keeping current model{RESET}")
continue continue
if user_input.startswith("/add-mcp"):
parts = shlex.split(user_input)
args = parts[1:]
if not args:
print(
f"{YELLOW}⏺ Usage: /add-mcp <name> <server> [--auth <auth>|--oauth] [--headers '<json>'] [--auto-auth|--no-auto-auth]{RESET}"
)
continue
name = None
auth = None
headers = None
auto_auth = None
positional = []
i = 0
while i < len(args):
if args[i] in ("--name", "-n") and i + 1 < len(args):
name = args[i + 1]
i += 2
elif args[i].startswith("--auth="):
auth = args[i].split("=", 1)[1]
i += 1
elif args[i] == "--auth" and i + 1 < len(args):
auth = args[i + 1]
i += 2
elif args[i] == "--oauth":
auth = "oauth"
i += 1
elif args[i] == "--auto-auth":
auto_auth = True
i += 1
elif args[i] == "--no-auto-auth":
auto_auth = False
i += 1
elif args[i].startswith("--headers="):
headers = json.loads(args[i].split("=", 1)[1])
i += 1
elif args[i] == "--headers" and i + 1 < len(args):
headers = json.loads(args[i + 1])
i += 2
else:
positional.append(args[i])
i += 1
server_cmd = None
if positional:
if name is None and len(positional) >= 2:
name = positional[0]
server_cmd = " ".join(positional[1:])
else:
server_cmd = " ".join(positional)
if not server_cmd:
print(
f"{YELLOW}⏺ Usage: /add-mcp <name> <server> [--auth <auth>|--oauth] [--headers '<json>'] [--auto-auth|--no-auto-auth]{RESET}"
)
continue
if not name:
name = re.sub(r"[^a-zA-Z0-9_]+", "_", server_cmd).strip("_")
if not name:
name = f"mcp_{len(mcp_servers) + 1}"
if name in mcp_servers:
for tool_name in mcp_servers[name]["tools"]:
agent.remove_tool(tool_name)
try:
from mcp2py import load
kwargs = {}
if auth is not None:
kwargs["auth"] = auth
if headers:
kwargs["headers"] = headers
if auto_auth is not None:
kwargs["auto_auth"] = auto_auth
server = load(server_cmd, **kwargs)
tool_names = register_mcp_server(name, server)
mcp_servers[name] = {"server": server, "tools": tool_names}
print(
f"{GREEN}⏺ Added MCP server '{name}' with {len(tool_names)} tools{RESET}"
)
print(f"{GREEN}⏺ Tools: {list(agent.tools.keys())}{RESET}")
except Exception as err:
print(f"{RED}⏺ Failed to add MCP server: {err}{RESET}")
continue
# Build context from history # Build context from history
context = f"Working directory: {os.getcwd()}\n" context = f"Working directory: {os.getcwd()}\n"
if history: if history:
@@ -406,11 +564,18 @@ def main():
print(f"\n{CYAN}{RESET} Thinking...", flush=True) print(f"\n{CYAN}{RESET} Thinking...", flush=True)
# Run the RLM agent # Run the RLM agent
result = agent(task=task) try:
result = agent(task=task)
finally:
if tmp_paste_path:
try:
os.remove(tmp_paste_path)
except OSError:
pass
# Display the answer # Display the answer
print(f"\n{CYAN}{RESET} {render_markdown(result.answer)}") print(f"\n{CYAN}{RESET} {render_markdown(result.answer)}")
# Display usage # Display usage
print(f"\n{MAGENTA}⏺ Debug Prediction: {result}{RESET}") print(f"\n{MAGENTA}⏺ Debug Prediction: {result}{RESET}")
@@ -430,5 +595,5 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
agent = RLMCodingProgram(RLMCodingConfig()) agent = RLMCodingProgram(RLMCodingConfig())
agent.push_to_hub(MODAIC_REPO_PATH, commit_message="Switch to RLM instead of ReAct", tag="v0.0.3") agent.push_to_hub(MODAIC_REPO_PATH, commit_message="Add MCP server support and long paste handling", tag="v0.0.4")
#main() #main()

View File

@@ -29,7 +29,7 @@
] ]
}, },
"lm": { "lm": {
"model": "openrouter/anthropic/claude-3.5-sonnet", "model": "openrouter/anthropic/claude-sonnet-4.5",
"model_type": "chat", "model_type": "chat",
"cache": true, "cache": true,
"num_retries": 3, "num_retries": 3,
@@ -68,7 +68,7 @@
] ]
}, },
"lm": { "lm": {
"model": "openrouter/anthropic/claude-3.5-sonnet", "model": "openrouter/anthropic/claude-sonnet-4.5",
"model_type": "chat", "model_type": "chat",
"cache": true, "cache": true,
"num_retries": 3, "num_retries": 3,

View File

@@ -4,4 +4,4 @@ version = "0.1.0"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = ["dspy>=3.1.2", "modaic>=0.10.4"] dependencies = ["dspy>=3.1.2", "fastmcp>=2.14.3", "mcp2py>=0.6.0", "modaic>=0.10.4"]