From f9880f46691c64c6789773e6c8afa3ffcf69a917 Mon Sep 17 00:00:00 2001 From: Concedo <39025047+LostRuins@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:34:07 +0800 Subject: [PATCH] kcpp agent mcp tool calling works --- kcpp_agent.py | 166 +++++++++++++++++++++++++++++++++++++++++++++++--- koboldcpp.py | 24 ++++++-- 2 files changed, 176 insertions(+), 14 deletions(-) diff --git a/kcpp_agent.py b/kcpp_agent.py index 6a70764f1..f39343edb 100644 --- a/kcpp_agent.py +++ b/kcpp_agent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """A tiny, cross-platform OpenAI Chat Completions-compatible local agent, for use in KoboldCpp. -Eight tools: +Eight built-in tools, plus tools exposed by KoboldCpp's MCP proxy: - read - write - edit @@ -157,7 +157,7 @@ class Throbber: def system_prompt() -> str: return f"""You are a small, careful local computer assistant running on {platform.system()}. -You have eight tools: read, write, edit, shell, list_directory, glob, grep, and web_fetch. +You have eight built-in tools: read, write, edit, shell, list_directory, glob, grep, and web_fetch. The server may also supply MCP tools. Rules: - Use tools when needed instead of pretending an action happened. @@ -858,6 +858,7 @@ def chat_completion( api_key: str, model: str, messages: list[dict[str, Any]], + tools: list[dict[str, Any]], temperature: float, max_tokens: int | None, request_timeout: int, @@ -867,7 +868,7 @@ def chat_completion( payload = { "model": model, "messages": messages, - "tools": TOOLS, + "tools": tools, "tool_choice": "auto", "temperature": temperature, } @@ -924,6 +925,117 @@ def api_url(base_url: str, resource: str) -> str: return urllib.parse.urlunsplit(parsed._replace(path=path)) +def mcp_url(base_url: str) -> str: + """Return the KoboldCpp MCP proxy URL for an OpenAI-compatible base URL.""" + parsed = urllib.parse.urlsplit(normalize_base_url(base_url)) + return urllib.parse.urlunsplit(parsed._replace(path="/mcp", query="", fragment="")) + + +def mcp_request( + base_url: str, + api_key: str, + method: str, + params: dict[str, Any], + timeout: int, +) -> dict[str, Any]: + payload = { + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + } + request = urllib.request.Request( + mcp_url(base_url), + data=json.dumps(payload).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": f"Bearer {api_key}", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + value = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"MCP HTTP {exc.code}: {limit_text(body, 'error response')}") from exc + except (urllib.error.URLError, ConnectionError, TimeoutError) as exc: + raise RuntimeError(f"Could not reach KoboldCpp MCP proxy: {exc}") from exc + except json.JSONDecodeError as exc: + raise RuntimeError(f"KoboldCpp MCP proxy returned invalid JSON: {exc}") from exc + if not isinstance(value, dict): + raise RuntimeError("KoboldCpp MCP proxy returned a non-object response") + if value.get("error") is not None: + raise RuntimeError(f"MCP error: {json.dumps(value['error'], ensure_ascii=False)}") + return value + + +def discover_mcp_tools( + base_url: str, api_key: str, timeout: int +) -> tuple[list[dict[str, Any]], set[str], list[str]]: + response = mcp_request(base_url, api_key, "tools/list", {}, timeout) + result = response.get("result", {}) + raw_tools = result.get("tools", []) if isinstance(result, dict) else [] + if not isinstance(raw_tools, list): + raise RuntimeError("MCP tools/list result does not contain a tools list") + + tools: list[dict[str, Any]] = [] + names: set[str] = set() + warnings: list[str] = [] + reserved = set(TOOL_IMPL) + for item in raw_tools: + if not isinstance(item, dict): + warnings.append("Skipped a malformed MCP tool entry") + continue + name = item.get("name") + if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", name): + warnings.append(f"Skipped MCP tool with unsupported name: {name!r}") + continue + if name in reserved or name in names: + warnings.append(f"Skipped conflicting MCP tool name: {name}") + continue + description = item.get("description", "") + if not isinstance(description, str): + description = str(description) + parameters = item.get("inputSchema", {"type": "object"}) + if not isinstance(parameters, dict): + warnings.append(f"Skipped MCP tool with invalid input schema: {name}") + continue + tools.append( + { + "type": "function", + "function": { + "name": name, + "description": f"[MCP] {description}".strip(), + "parameters": parameters, + }, + } + ) + names.add(name) + return tools, names, warnings + + +def call_mcp_tool( + base_url: str, + api_key: str, + name: str, + arguments: dict[str, Any], + timeout: int, +) -> str: + response = mcp_request( + base_url, + api_key, + "tools/call", + {"name": name, "arguments": arguments}, + timeout, + ) + result = response.get("result") + if isinstance(result, str): + return result + return json.dumps(result, ensure_ascii=False) + + def probe_endpoint(base_url: str, api_key: str, timeout: int) -> tuple[bool, str]: """Check reachability without spending tokens on a completion.""" request = urllib.request.Request( @@ -987,7 +1099,7 @@ def print_runtime_help( print( "\n" + color("Runtime commands:", ANSI_BOLD_CYAN) + "\n" " /help Show this help\n" - " /clear Clear conversation history\n" + " /clear Clear history and refresh MCP tools\n" " /confirm Show confirmation status\n" " /confirm on Require approval for every tool call\n" " /confirm off Auto-approve tool calls\n" @@ -1042,6 +1154,27 @@ def run_agent( return base_url = replacement + available_tools = list(TOOLS) + mcp_tool_names: set[str] = set() + + def refresh_mcp_tools() -> None: + nonlocal available_tools, mcp_tool_names + available_tools = list(TOOLS) + mcp_tool_names = set() + try: + mcp_tools, mcp_tool_names, warnings = discover_mcp_tools( + base_url, api_key, request_timeout + ) + available_tools.extend(mcp_tools) + for warning in warnings: + print(color("MCP warning:", ANSI_YELLOW) + f" {warning}") + if mcp_tools: + print(color("MCP tools:", ANSI_CYAN) + f" {len(mcp_tools)} loaded") + except Exception as exc: + print(color("MCP unavailable:", ANSI_YELLOW) + f" {exc}") + + refresh_mcp_tools() + messages: list[dict[str, Any]] = [ {"role": "system", "content": system_prompt()} ] @@ -1076,6 +1209,7 @@ def run_agent( continue if command == "/clear" and not command_arg: messages[:] = [{"role": "system", "content": system_prompt()}] + refresh_mcp_tools() print("Conversation cleared.\n") continue if command == "/confirm": @@ -1136,12 +1270,14 @@ def run_agent( if reachable: base_url = candidate print(f"Connected to {base_url} ({detail}).\n") + refresh_mcp_tools() continue replacement = prompt_for_endpoint( candidate, api_key, request_timeout, detail ) if replacement is not None: base_url = replacement + refresh_mcp_tools() continue messages.append({"role": "user", "content": user_text}) @@ -1155,6 +1291,7 @@ def run_agent( api_key=api_key, model=model, messages=messages, + tools=available_tools, temperature=temperature, max_tokens=max_tokens, request_timeout=request_timeout, @@ -1168,6 +1305,7 @@ def run_agent( if replacement is None: break base_url = replacement + refresh_mcp_tools() continue except APIResponseError as exc: label = color("API error:", ANSI_RED, stderr=True) @@ -1232,6 +1370,7 @@ def run_agent( call_id = call.get("id", "tool_call") function = call.get("function") or {} name = function.get("name", "") + display_name = f"MCP: {name}" if name in mcp_tool_names else name raw_args = function.get("arguments", "{}") try: @@ -1241,13 +1380,22 @@ def run_agent( except Exception as exc: result = f"ERROR: invalid tool arguments: {exc}" else: - if name not in TOOL_IMPL: + if name not in TOOL_IMPL and name not in mcp_tool_names: result = f"ERROR: unknown tool: {name}" - elif not confirm_tool_call(name, args, auto_approve, verbose): + elif not confirm_tool_call(display_name, args, auto_approve, verbose): result = "DENIED BY USER: The user did not approve this tool call." else: try: - result = TOOL_IMPL[name](args) + if name in mcp_tool_names: + result = call_mcp_tool( + base_url, + api_key, + name, + args, + request_timeout, + ) + else: + result = TOOL_IMPL[name](args) except subprocess.TimeoutExpired: result = "ERROR: shell command timed out" except Exception as exc: @@ -1257,7 +1405,7 @@ def run_agent( # listings and any future tools that forget to limit themselves. result = limit_text(str(result), "tool result") - print_tool_result(name, result, verbose) + print_tool_result(display_name, result, verbose) messages.append( { "role": "tool", @@ -1279,7 +1427,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--api-key", default=DEFAULT_API_KEY, - help="API key (default: OPENAI_API_KEY or 'local')", + help="API key for model requests and the KoboldCpp MCP proxy (default: OPENAI_API_KEY or 'local')", ) parser.add_argument( "--model", diff --git a/koboldcpp.py b/koboldcpp.py index 9ba72510d..fa435d142 100644 --- a/koboldcpp.py +++ b/koboldcpp.py @@ -8584,7 +8584,7 @@ def save_config_dict(filename, savdict, template): filenamestr += ".kcpps" if not filenamestr.endswith(".kcppt") and template: filenamestr += ".kcppt" - do_not_save = {'agent_base_url', 'allow_config_onready', 'analyze', 'config', 'exportconfig', 'exporttemplate', 'run_agent', 'testmemory', 'unpack', 'version'} + do_not_save = {'agent_api_key', 'agent_base_url', 'allow_config_onready', 'analyze', 'config', 'exportconfig', 'exporttemplate', 'run_agent', 'testmemory', 'unpack', 'version'} filtered = {k: v for k, v in savdict.items() if k not in do_not_save} if 'gendefaults' in filtered: gendefaults = parse_json_object(filtered['gendefaults'], 'gendefaults') @@ -11601,7 +11601,7 @@ def get_kobold_agent_path(): return os.path.join(base_path, "kcpp_agent.py") -def run_kobold_agent(base_url=None): +def run_kobold_agent(base_url=None, api_key=None): agent_path = get_kobold_agent_path() if not os.path.isfile(agent_path): raise FileNotFoundError(f"Kobold Agent script not found: {agent_path}") @@ -11614,12 +11614,14 @@ def run_kobold_agent(base_url=None): sys.argv = [agent_path] if base_url: sys.argv.extend(["--base-url", base_url]) + if api_key: + sys.argv.extend(["--api-key", api_key]) runpy.run_path(agent_path, run_name="__main__") finally: sys.argv = old_argv -def launch_kobold_agent(base_url=None): +def launch_kobold_agent(base_url=None, api_key=None): agent_path = get_kobold_agent_path() if not os.path.isfile(agent_path): print(f"Cannot launch Kobold Agent: script not found at {agent_path}") @@ -11634,6 +11636,11 @@ def launch_kobold_agent(base_url=None): command.extend(["--agent-base-url", base_url]) else: command.extend(["--base-url", base_url]) + if api_key: + if getattr(sys, 'frozen', False): + command.extend(["--agent-api-key", api_key]) + else: + command.extend(["--api-key", api_key]) try: if os.name == 'nt': @@ -11811,7 +11818,7 @@ def main(launch_args, default_args): args = launch_args #note: these are NOT shared with the child processes! if args.run_agent: - run_kobold_agent(args.agent_base_url) + run_kobold_agent(args.agent_base_url, args.agent_api_key) return if args.agent and len(sys.argv) == 2: @@ -13277,7 +13284,13 @@ def kcpp_main_process(launch_args, g_memory=None, gui_launcher=False): on_server_ready = None if agent_base_url: def on_server_ready(): - return launch_kobold_agent(agent_base_url) + if args.mcpfile: + agent_timer = threading.Timer( + 2, launch_kobold_agent, args=(agent_base_url, args.password) + ) + agent_timer.start() + return True + return launch_kobold_agent(agent_base_url, args.password) asyncio.run(RunServerMultiThreaded(args.host, args.port, KcppServerRequestHandler, on_server_ready)) else: # Flush stdout for previous win32 issue so the client can see output. @@ -13507,5 +13520,6 @@ if __name__ == '__main__': debuggroup.add_argument("--testmemory", help=argparse.SUPPRESS, action='store_true') debuggroup.add_argument("--run-agent", help=argparse.SUPPRESS, action='store_true') debuggroup.add_argument("--agent-base-url", help=argparse.SUPPRESS, default=None) + debuggroup.add_argument("--agent-api-key", help=argparse.SUPPRESS, default=None) main(launch_args=parser.parse_args(),default_args=parser.parse_args([]))