返回提交历史
Added
projects/discord-bot/.env.example
+24
-0
Added
projects/discord-bot/README.md
+151
-0
Added
projects/discord-bot/bot.py
+419
-0
Added
projects/discord-bot/mcp_tools.py
+186
-0
XFEstudio/gpt4free
Add discord bot
5cd22a3f
代码差异
4 个文件
+780
-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
@@ -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.
@@ -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()
@@ -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)