XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 1
返回提交历史

XFEstudio/gpt4free

Add discord bot

5cd22a3f
hlohaus <983577+hlohaus@users.noreply.github.com>
提交于

代码差异

4 个文件 +780 -0
Added projects/discord-bot/.env.example +24 -0
@@ -0,0 +1,24 @@
1 # Discord bot token from https://discord.com/developers/applications
2 DISCORD_TOKEN=your-bot-token-here
3
4 # g4f model to use (default: gpt-4o-mini)
5 G4F_MODEL=gpt-4o-mini
6
7 # Optional system prompt
8 G4F_SYSTEM_PROMPT=You are a helpful, friendly Discord assistant. Keep answers concise and formatted with Discord markdown when useful.
9
10 # Max conversation history messages per user (default: 12)
11 G4F_MAX_HISTORY=12
12
13 # Optional proxy, e.g. socks5://127.0.0.1:1080
14 # G4F_PROXY=
15
16 # MCP tools: comma-separated list of tools to enable at startup.
17 # Default (if unset): web_search,web_scrape,mark_it_down,text_to_audio,image_generation
18 # Full set: web_search,web_scrape,image_generation,text_to_audio,mark_it_down,
19 # python_execute,apply_patch,file_read,file_read_lines,file_search,
20 # file_write,file_list,file_delete
21 # G4F_ENABLED_TOOLS=web_search,web_scrape,image_generation
22
23 # Max tool-calling rounds before forcing a final answer (default: 4)
24 # G4F_MAX_TOOL_LOOPS=4
Added projects/discord-bot/README.md +151 -0
@@ -0,0 +1,151 @@
1 # g4f Discord Bot
2
3 A Discord bot powered by [gpt4free (g4f)](https://github.com/xtekky/gpt4free) that lets your server chat with AI models — **no API keys required**.
4
5 ## Features
6
7 - 🤖 **`/ask`** — one-shot questions (no history kept)
8 - 💬 **`/chat`** — conversational mode with per-user message history
9 - 🧹 **`/clear`** — reset your conversation history
10 - 🏷️ **`/model`** — show the currently configured model
11 - 🔧 **`/tools`** — list, enable, and disable MCP tools
12 - 🛠️ **MCP tool-calling** — the AI can autonomously call tools (web search, web scraping, image generation, text-to-audio, and more) via g4f's built-in MCP server. The bot executes the tool, feeds the result back, and loops until the AI has a final answer.
13 - ⚡ **Streaming responses** — edits the message in-place for a live "typing" effect
14 - 🔒 Per-user history isolation with configurable length
15
16 ## Setup
17
18 ### 1. Create a Discord application
19
20 1. Go to the [Discord Developer Portal](https://discord.com/developers/applications).
21 2. Create a new application → **Bot** tab → **Reset Token** to get your token.
22 3. Enable **Message Content Intent** under *Privileged Gateway Intents*.
23 4. Invite the bot to your server using the **OAuth2 → URL Generator** (scopes: `bot`, `applications.commands`; permissions: `Send Messages`, `Read Message History`).
24
25 ### 2. Configure environment
26
27 ```bash
28 cd projects/discord-bot
29 cp .env.example .env
30 # Edit .env and paste your DISCORD_TOKEN
31 ```
32
33 ### 3. Install dependencies
34
35 The bot needs `discord.py` and `python-dotenv` on top of g4f's requirements:
36
37 ```bash
38 pip install discord.py python-dotenv
39 ```
40
41 ### 4. Run
42
43 ```bash
44 python bot.py
45 ```
46
47 You should see:
48
49 ```
50 [INFO] Logged in as YourBot#1234 (id=...)
51 [INFO] Synced 4 slash commands
52 ```
53
54 ## Configuration
55
56 All settings live in `.env`:
57
58 | Variable | Default | Description |
59 |---|---|---|
60 | `DISCORD_TOKEN` | *(required)* | Your Discord bot token |
61 | `G4F_MODEL` | `gpt-4o-mini` | Model name passed to g4f |
62 | `G4F_SYSTEM_PROMPT` | *(see .env.example)* | System prompt for the assistant |
63 | `G4F_MAX_HISTORY` | `12` | Max messages stored per user |
64 | `G4F_PROXY` | *(none)* | Optional proxy for g4f requests |
65 | `G4F_ENABLED_TOOLS` | *(safe set)* | Comma-separated MCP tools to enable at startup |
66 | `G4F_MAX_TOOL_LOOPS` | `4` | Max tool-calling rounds before forcing a final answer |
67
68 ## MCP tools
69
70 The bot integrates g4f's built-in [MCP server](../../g4f/mcp/) so the AI can call tools autonomously during a conversation. The flow:
71
72 1. You ask a question (e.g. *"What's the latest news on X?"*).
73 2. The model decides to call `web_search` and returns a tool call.
74 3. The bot executes the tool via `MCPServer`, appends the result to the conversation, and asks the model again.
75 4. The loop repeats until the model produces a final answer (or `G4F_MAX_TOOL_LOOPS` is hit).
76
77 ### Available tools
78
79 | Tool | Description | Enabled by default? |
80 |---|---|---|
81 | `web_search` | Search the web via DuckDuckGo | ✅ |
82 | `web_scrape` | Extract text content from a URL | ✅ |
83 | `mark_it_down` | Convert a URL to markdown | ✅ |
84 | `text_to_audio` | Generate an audio URL from text | ✅ |
85 | `image_generation` | Generate an image from a prompt | ✅ |
86 | `python_execute` | Run Python in a sandboxed environment | ❌ |
87 | `apply_patch` | Apply a unified diff patch | ❌ |
88 | `file_read` | Read a file from `~/.g4f/workspace` | ❌ |
89 | `file_read_lines` | Read a line range from a workspace file | ❌ |
90 | `file_search` | Search files in the workspace | ❌ |
91 | `file_write` | Write a file to the workspace | ❌ |
92 | `file_list` | List workspace files | ❌ |
93 | `file_delete` | Delete a workspace file | ❌ |
94
95 File/Python/patch tools are **disabled by default** because they operate on the bot's local filesystem. Enable them only if you trust your Discord users.
96
97 ### Managing tools at runtime
98
99 Use the `/tools` slash command:
100
101 ```
102 /tools # list enabled and available tools
103 /tools action:enable name:python_execute
104 /tools action:disable name:image_generation
105 ```
106
107 You can also set the startup set via `G4F_ENABLED_TOOLS` in `.env`:
108
109 ```
110 G4F_ENABLED_TOOLS=web_search,web_scrape,image_generation
111 ```
112
113 ### Disabling tools per request
114
115 Both `/ask` and `/chat` accept an optional `tools` boolean (defaults to `true`):
116
117 ```
118 /ask question:"What is 2+2?" tools:False
119 ```
120
121 ## Changing the provider
122
123 `bot.py` imports `OpenaiChat` as the default provider. To use a different one, edit the import and the `AsyncClient` constructor:
124
125 ```python
126 from g4f.Provider import Gemini, OpenaiChat, BingCreateImages
127
128 client = AsyncClient(provider=Gemini)
129 ```
130
131 See all available providers with:
132
133 ```bash
134 g4f --help
135 ```
136
137 ## Project structure
138
139 ```
140 projects/discord-bot/
141 ├── bot.py # Main bot logic (commands, tool-calling loop)
142 ├── mcp_tools.py # MCP tool manager (definitions, execution, display)
143 ├── .env.example # Template environment file
144 └── README.md # This file
145 ```
146
147 ## Notes
148
149 - g4f relies on free third-party providers; availability and quality vary. If a request fails, try a different model or provider.
150 - The bot uses `AsyncClient` so it stays responsive while streaming.
151 - Discord limits messages to 2000 characters; long replies are truncated.
Added projects/discord-bot/bot.py +419 -0
@@ -0,0 +1,419 @@
1 """
2 Discord bot powered by g4f (gpt4free).
3
4 Features:
5 - /ask command for one-shot questions
6 - /chat command for conversation with per-user history
7 - /clear to reset conversation history
8 - /model to show the configured model
9 - /tools to list, enable, and disable MCP tools
10 - MCP tool-calling loop: the model can call tools (web search, scraping,
11 image generation, etc.) and the bot executes them via g4f's MCPServer,
12 feeds the results back, and produces a final answer.
13 - Streaming responses edited in-place for a "typing" effect
14 - Configurable model and provider via environment variables
15 """
16
17 from __future__ import annotations
18
19 import os
20 import logging
21 from collections import defaultdict, deque
22 from typing import Deque, Dict, List, Optional
23
24 import discord
25 from discord import app_commands
26 from discord.ext import commands
27 from dotenv import load_dotenv
28
29 from g4f.client import AsyncClient
30 from g4f.Provider import OpenaiChat # default provider; change as needed
31
32 from mcp_tools import MCPToolManager, ALL_AVAILABLE_TOOLS, SAFE_DEFAULT_TOOLS
33
34 load_dotenv()
35
36 # ---------------------------------------------------------------------------
37 # Configuration
38 # ---------------------------------------------------------------------------
39 TOKEN = os.getenv("DISCORD_TOKEN")
40 MODEL = os.getenv("G4F_MODEL", "gpt-4o-mini")
41 SYSTEM_PROMPT = os.getenv(
42 "G4F_SYSTEM_PROMPT",
43 "You are a helpful, friendly Discord assistant. Keep answers concise "
44 "and formatted with Discord markdown when useful. "
45 "When a question needs fresh information, use the web_search tool. "
46 "When asked to generate an image, use the image_generation tool.",
47 )
48 MAX_HISTORY = int(os.getenv("G4F_MAX_HISTORY", "12")) # messages per user
49 PROXY = os.getenv("G4F_PROXY") # optional, e.g. "socks5://127.0.0.1:1080"
50 MAX_TOOL_LOOPS = int(os.getenv("G4F_MAX_TOOL_LOOPS", "4")) # safety cap
51
52 # Comma-separated list of tools to enable at startup (default: safe set).
53 _enabled_env = os.getenv("G4F_ENABLED_TOOLS", "")
54 ENABLED_TOOLS = (
55 {t.strip() for t in _enabled_env.split(",") if t.strip()}
56 if _enabled_env
57 else set(SAFE_DEFAULT_TOOLS)
58 )
59
60 logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
61 log = logging.getLogger("g4f-discord")
62
63 # ---------------------------------------------------------------------------
64 # g4f async client + MCP tool manager (shared across requests)
65 # ---------------------------------------------------------------------------
66 client = AsyncClient(provider=OpenaiChat)
67 mcp = MCPToolManager(enabled_tools=ENABLED_TOOLS)
68
69 # Per-user conversation history: user_id -> deque of {"role", "content"}
70 histories: Dict[int, Deque[dict]] = defaultdict(lambda: deque(maxlen=MAX_HISTORY))
71
72 # ---------------------------------------------------------------------------
73 # Bot setup
74 # ---------------------------------------------------------------------------
75 intents = discord.Intents.default()
76 intents.message_content = True # required to read user messages
77
78 bot = commands.Bot(command_prefix="!", intents=intents)
79
80
81 def _build_messages(user_id: int, user_content: str) -> List[dict]:
82 """Return the full message list including system prompt and history."""
83 messages: List[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
84 messages.extend(histories[user_id])
85 messages.append({"role": "user", "content": user_content})
86 return messages
87
88
89 def _truncate(text: str, limit: int = 1900) -> str:
90 """Truncate text to stay within Discord's 2000-char message limit."""
91 return text if len(text) <= limit else text[:limit] + "…"
92
93
94 # ---------------------------------------------------------------------------
95 # Completion helpers
96 # ---------------------------------------------------------------------------
97 async def _stream_response(
98 interaction: discord.Interaction, messages: List[dict]
99 ) -> str:
100 """
101 Stream a g4f completion (no tools) and edit the interaction response
102 in-place. Returns the full accumulated text.
103 """
104 accumulated = ""
105 last_sent = ""
106 update_threshold = 80 # characters before each edit
107
108 stream = await client.chat.completions.create(
109 model=MODEL,
110 messages=messages,
111 stream=True,
112 proxy=PROXY,
113 )
114
115 async for chunk in stream:
116 if chunk.choices and chunk.choices[0].delta.content:
117 accumulated += chunk.choices[0].delta.content
118 if len(accumulated) - len(last_sent) >= update_threshold:
119 last_sent = accumulated
120 try:
121 await interaction.edit_original_response(
122 content=_truncate(accumulated) + " ▌"
123 )
124 except discord.HTTPException:
125 pass
126
127 return accumulated
128
129
130 async def _completion_with_tools(
131 messages: List[dict],
132 use_tools: bool,
133 ) -> tuple[str, Optional[list]]:
134 """
135 Non-streaming completion that may return tool_calls.
136
137 Returns (content, tool_calls) where tool_calls is None when no tools
138 were requested or none were called.
139 """
140 kwargs: dict = {"model": MODEL, "messages": messages, "stream": False, "proxy": PROXY}
141 if use_tools and mcp.definitions:
142 kwargs["tools"] = mcp.definitions
143 kwargs["tool_choice"] = "auto"
144
145 response = await client.chat.completions.create(**kwargs)
146 choice = response.choices[0]
147 content = choice.message.content or ""
148 tool_calls = getattr(choice.message, "tool_calls", None)
149 return content, tool_calls
150
151
152 async def _send_streamed_text(interaction: discord.Interaction, text: str) -> None:
153 """Send a block of text, editing in chunks for a typing effect."""
154 if not text.strip():
155 return
156 for i in range(0, len(text), 120):
157 sent = text[: i + 120]
158 try:
159 await interaction.edit_original_response(content=_truncate(sent) + " ▌")
160 except discord.HTTPException:
161 pass
162
163
164 async def _run_tool_loop(
165 interaction: discord.Interaction,
166 messages: List[dict],
167 use_tools: bool,
168 ) -> tuple[str, List[dict]]:
169 """
170 Run the full tool-calling loop.
171
172 1. Ask the model for a completion (with tools available).
173 2. If it returns tool_calls, execute them via the MCP server.
174 3. Append the assistant message + tool results to the conversation.
175 4. Repeat until the model stops calling tools or MAX_TOOL_LOOPS is hit.
176 5. Stream the final answer into the interaction response.
177
178 Returns (final_text, tool_result_messages).
179 """
180 tool_results_log: List[dict] = []
181
182 if not (use_tools and mcp.definitions):
183 # No tools — just stream a normal response.
184 final = await _stream_response(interaction, messages)
185 return final, tool_results_log
186
187 # --- Tool-calling phase (non-streaming) ---
188 working_messages = list(messages)
189
190 for loop in range(MAX_TOOL_LOOPS):
191 content, tool_calls = await _completion_with_tools(working_messages, use_tools=True)
192
193 if not tool_calls:
194 # No (more) tool calls — stream this final content to the user.
195 if content.strip():
196 await _send_streamed_text(interaction, content)
197 return content, tool_results_log
198
199 # Show the user that tools are running.
200 names = ", ".join(tc.function.name for tc in tool_calls)
201 try:
202 await interaction.edit_original_response(
203 content=f"🔧 Running tools: {names}…"
204 )
205 except discord.HTTPException:
206 pass
207
208 # Execute the tool calls via the MCP server.
209 tool_results = await mcp.execute_tool_calls(tool_calls)
210 tool_results_log.extend(tool_results)
211
212 # Append the assistant's tool-call message + tool results to the
213 # conversation so the model can see the outcomes.
214 working_messages.append(
215 {
216 "role": "assistant",
217 "content": content,
218 "tool_calls": [
219 {
220 "id": getattr(tc, "id", f"call_{i}"),
221 "type": "function",
222 "function": {
223 "name": tc.function.name,
224 "arguments": tc.function.arguments,
225 },
226 }
227 for i, tc in enumerate(tool_calls)
228 ],
229 }
230 )
231 working_messages.extend(tool_results)
232
233 # Exhausted the loop cap — do one final streaming pass without tools
234 # so the model summarises what it learned.
235 log.warning("Tool loop cap (%d) reached; generating final summary", MAX_TOOL_LOOPS)
236 final = await _stream_response(interaction, working_messages)
237 return final, tool_results_log
238
239
240 # ---------------------------------------------------------------------------
241 # Slash commands
242 # ---------------------------------------------------------------------------
243 @bot.tree.command(name="ask", description="Ask a one-shot question (no history).")
244 @app_commands.describe(
245 question="Your question for the AI",
246 tools="Allow the AI to use MCP tools (web search, etc.)",
247 )
248 async def ask(interaction: discord.Interaction, question: str, tools: bool = True):
249 await interaction.response.defer(thinking=True)
250 messages = [
251 {"role": "system", "content": SYSTEM_PROMPT},
252 {"role": "user", "content": question},
253 ]
254 try:
255 reply, tool_results = await _run_tool_loop(interaction, messages, use_tools=tools)
256 except Exception as e:
257 log.exception("g4f request failed")
258 await interaction.followup.send(f"⚠️ Error: {e}")
259 return
260
261 await _finalize_response(interaction, reply, tool_results)
262
263
264 @bot.tree.command(name="chat", description="Chat with conversation history.")
265 @app_commands.describe(
266 message="Your message to the AI",
267 tools="Allow the AI to use MCP tools (web search, etc.)",
268 )
269 async def chat(interaction: discord.Interaction, message: str, tools: bool = True):
270 await interaction.response.defer(thinking=True)
271 user_id = interaction.user.id
272 messages = _build_messages(user_id, message)
273 try:
274 reply, tool_results = await _run_tool_loop(interaction, messages, use_tools=tools)
275 except Exception as e:
276 log.exception("g4f request failed")
277 await interaction.followup.send(f"⚠️ Error: {e}")
278 return
279
280 await _finalize_response(interaction, reply, tool_results)
281
282 # Store turn in history
283 histories[user_id].append({"role": "user", "content": message})
284 histories[user_id].append({"role": "assistant", "content": reply})
285
286
287 @bot.tree.command(name="clear", description="Clear your conversation history.")
288 async def clear(interaction: discord.Interaction):
289 histories.pop(interaction.user.id, None)
290 await interaction.response.send_message(
291 "🧹 Your conversation history has been cleared.", ephemeral=True
292 )
293
294
295 @bot.tree.command(name="model", description="Show the currently configured model.")
296 async def model(interaction: discord.Interaction):
297 await interaction.response.send_message(
298 f"Current model: `{MODEL}`", ephemeral=True
299 )
300
301
302 @bot.tree.command(name="tools", description="List, enable, or disable MCP tools.")
303 @app_commands.describe(
304 action="What to do (default: list)",
305 name="Tool name (for enable/disable)",
306 )
307 @app_commands.choices(
308 action=[
309 app_commands.Choice(name="list", value="list"),
310 app_commands.Choice(name="enable", value="enable"),
311 app_commands.Choice(name="disable", value="disable"),
312 ]
313 )
314 async def tools(
315 interaction: discord.Interaction,
316 action: Optional[app_commands.Choice[str]] = None,
317 name: Optional[str] = None,
318 ):
319 value = action.value if action is not None else "list"
320
321 if value == "list":
322 enabled = mcp.enabled_names
323 lines = ["**Enabled MCP tools:**"]
324 if enabled:
325 lines.extend(f"• `{t}`" for t in enabled)
326 else:
327 lines.append("_(none)_")
328 lines.append("\n**Available (not enabled):**")
329 disabled = sorted(ALL_AVAILABLE_TOOLS - set(enabled))
330 if disabled:
331 lines.extend(f"• `{t}`" for t in disabled)
332 else:
333 lines.append("_(all enabled)_")
334 await interaction.response.send_message("\n".join(lines), ephemeral=True)
335
336 elif value == "enable":
337 if not name:
338 await interaction.response.send_message(
339 "Provide a tool `name` to enable.", ephemeral=True
340 )
341 return
342 if mcp.enable(name):
343 await interaction.response.send_message(
344 f"✅ Enabled tool `{name}`.", ephemeral=True
345 )
346 else:
347 await interaction.response.send_message(
348 f"❌ Tool `{name}` not found. Use `/tools list` to see options.",
349 ephemeral=True,
350 )
351
352 elif value == "disable":
353 if not name:
354 await interaction.response.send_message(
355 "Provide a tool `name` to disable.", ephemeral=True
356 )
357 return
358 if mcp.disable(name):
359 await interaction.response.send_message(
360 f"✅ Disabled tool `{name}`.", ephemeral=True
361 )
362 else:
363 await interaction.response.send_message(
364 f"❌ Tool `{name}` was not enabled.", ephemeral=True
365 )
366
367
368 # ---------------------------------------------------------------------------
369 # Response finalisation
370 # ---------------------------------------------------------------------------
371 async def _finalize_response(
372 interaction: discord.Interaction,
373 reply: str,
374 tool_results: List[dict],
375 ) -> None:
376 """Edit the interaction response with the final reply + tool summary."""
377 if not reply.strip():
378 if tool_results:
379 tool_text = MCPToolManager.format_tool_results_for_discord(tool_results)
380 await interaction.edit_original_response(content=_truncate(tool_text))
381 else:
382 await interaction.edit_original_response(
383 content="⚠️ The model returned an empty response."
384 )
385 return
386
387 # If tools were used, append a collapsible summary.
388 if tool_results:
389 tool_text = MCPToolManager.format_tool_results_for_discord(tool_results)
390 combined = f"{_truncate(reply)}\n\n{tool_text}"
391 await interaction.edit_original_response(content=_truncate(combined, 1900))
392 else:
393 await interaction.edit_original_response(content=_truncate(reply))
394
395
396 # ---------------------------------------------------------------------------
397 # Lifecycle events
398 # ---------------------------------------------------------------------------
399 @bot.event
400 async def on_ready():
401 log.info("Logged in as %s (id=%s)", bot.user, bot.user.id)
402 log.info("MCP tools enabled: %s", mcp.enabled_names)
403 try:
404 synced = await bot.tree.sync()
405 log.info("Synced %d slash commands", len(synced))
406 except Exception:
407 log.exception("Failed to sync slash commands")
408
409
410 def main():
411 if not TOKEN:
412 raise SystemExit(
413 "DISCORD_TOKEN not set. Put it in .env or export it as an env var."
414 )
415 bot.run(TOKEN)
416
417
418 if __name__ == "__main__":
419 main()
Added projects/discord-bot/mcp_tools.py +186 -0
@@ -0,0 +1,186 @@
1 """
2 MCP tools integration for the Discord bot.
3
4 Wraps g4f's MCPServer to:
5 - Build OpenAI-compatible tool definitions from the registered MCP tools
6 - Execute tool calls and return results in the OpenAI "tool" message format
7 - Provide a configurable allowlist so bot owners can enable only safe tools
8 """
9
10 from __future__ import annotations
11
12 import json
13 import logging
14 from typing import Any, Dict, List, Optional
15
16 from g4f.mcp.server import MCPServer, MCPRequest
17
18 log = logging.getLogger("g4f-discord.mcp")
19
20 # Tools that are safe to expose to Discord users by default.
21 # File/python/patch tools are excluded by default since they operate on the
22 # bot's local ~/.g4f/workspace directory.
23 SAFE_DEFAULT_TOOLS = {
24 "web_search",
25 "web_scrape",
26 "mark_it_down",
27 "text_to_audio",
28 "image_generation",
29 }
30
31 # All tools that *can* be enabled (everything the MCPServer registers).
32 ALL_AVAILABLE_TOOLS = {
33 "web_search",
34 "web_scrape",
35 "image_generation",
36 "text_to_audio",
37 "mark_it_down",
38 "python_execute",
39 "apply_patch",
40 "file_read",
41 "file_read_lines",
42 "file_search",
43 "file_write",
44 "file_list",
45 "file_delete",
46 }
47
48
49 class MCPToolManager:
50 """Manage MCP tool definitions, execution, and the tool-call loop."""
51
52 def __init__(self, enabled_tools: Optional[set[str]] = None, safe_mode: bool = True):
53 """
54 Args:
55 enabled_tools: Set of tool names to expose. Defaults to SAFE_DEFAULT_TOOLS.
56 safe_mode: Passed to MCPServer. When True, python_execute / file_list
57 run in restricted mode.
58 """
59 self.server = MCPServer(safe_mode=safe_mode)
60 self.enabled_tools: set[str] = set(enabled_tools) if enabled_tools else set(SAFE_DEFAULT_TOOLS)
61 self._definitions: List[dict] = []
62 self._rebuild_definitions()
63
64 # ------------------------------------------------------------------
65 # Tool definitions (OpenAI function-calling format)
66 # ------------------------------------------------------------------
67 def _rebuild_definitions(self) -> None:
68 """Build the OpenAI-format tool list from the MCPServer's registered tools."""
69 tool_list = self.server.get_tool_list()
70 defs: List[dict] = []
71 for tool in tool_list:
72 name = tool["name"]
73 if name not in self.enabled_tools:
74 continue
75 defs.append(
76 {
77 "type": "function",
78 "function": {
79 "name": name,
80 "description": tool["description"],
81 "parameters": tool["inputSchema"],
82 },
83 }
84 )
85 self._definitions = defs
86 log.info("MCP tools enabled: %s", [d["function"]["name"] for d in defs])
87
88 @property
89 def definitions(self) -> List[dict]:
90 """OpenAI-compatible tool definitions for chat.completions.create(tools=...)."""
91 return self._definitions
92
93 @property
94 def enabled_names(self) -> List[str]:
95 return sorted(self.enabled_tools)
96
97 def enable(self, name: str) -> bool:
98 """Enable a tool by name. Returns True if it exists and was enabled."""
99 if name in ALL_AVAILABLE_TOOLS and name in self.server.tools:
100 self.enabled_tools.add(name)
101 self._rebuild_definitions()
102 return True
103 return False
104
105 def disable(self, name: str) -> bool:
106 """Disable a tool by name. Returns True if it was disabled."""
107 if name in self.enabled_tools:
108 self.enabled_tools.discard(name)
109 self._rebuild_definitions()
110 return True
111 return False
112
113 # ------------------------------------------------------------------
114 # Tool execution
115 # ------------------------------------------------------------------
116 async def execute_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
117 """Execute a single MCP tool and return its result dict."""
118 if name not in self.server.tools:
119 return {"error": f"Tool not found: {name}"}
120 if name not in self.enabled_tools:
121 return {"error": f"Tool not enabled: {name}"}
122 request = MCPRequest(
123 jsonrpc="2.0",
124 id=0,
125 method="tools/call",
126 params={"name": name, "arguments": arguments},
127 )
128 response = await self.server.handle_request(request)
129 if response.error:
130 return {"error": response.error.get("message", "Unknown MCP error")}
131 return response.result or {}
132
133 async def execute_tool_calls(self, tool_calls: List[Any]) -> List[dict]:
134 """
135 Execute a list of tool calls (from ChatCompletionMessage.tool_calls)
136 and return OpenAI-format tool-result messages.
137
138 Each returned dict has:
139 role="tool", tool_call_id=..., name=..., content=<json string>
140 """
141 results: List[dict] = []
142 for call in tool_calls:
143 fn = call.function
144 name = fn.name
145 try:
146 args = json.loads(fn.arguments) if fn.arguments else {}
147 except (json.JSONDecodeError, TypeError):
148 args = {}
149
150 log.info("Executing MCP tool: %s(%s)", name, args)
151 try:
152 result = await self.execute_tool(name, args)
153 except Exception as e:
154 log.exception("Tool execution failed: %s", name)
155 result = {"error": str(e)}
156
157 results.append(
158 {
159 "role": "tool",
160 "tool_call_id": getattr(call, "id", "call_0"),
161 "name": name,
162 "content": json.dumps(result, ensure_ascii=False, default=str),
163 }
164 )
165 return results
166
167 # ------------------------------------------------------------------
168 # Helpers for Discord display
169 # ------------------------------------------------------------------
170 @staticmethod
171 def format_tool_results_for_discord(tool_results: List[dict]) -> str:
172 """Render tool results as a concise Discord markdown block."""
173 lines: List[str] = []
174 for r in tool_results:
175 name = r.get("name", "tool")
176 content = r.get("content", "")
177 try:
178 parsed = json.loads(content)
179 pretty = json.dumps(parsed, indent=2, ensure_ascii=False, default=str)
180 except (json.JSONDecodeError, TypeError):
181 pretty = content
182 # Truncate to keep Discord messages manageable
183 if len(pretty) > 800:
184 pretty = pretty[:800] + "\n…(truncated)"
185 lines.append(f"🔧 **{name}**\n```json\n{pretty}\n```")
186 return "\n".join(lines)