Skip to content

Commit bb99824

Browse files
committed
feat: add telegram mcp and skill controls
1 parent 1d778c7 commit bb99824

10 files changed

Lines changed: 511 additions & 16 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ General:
131131
- `/auto <task>` - force a one-off `codex exec --full-auto`
132132
- `/plan <task>` - ask Codex for a plan only, without direct file modification intent
133133
- `/model [name|reset]` - show or set the model override for the current chat
134+
- `/skill list` - show skill switches for the current chat
135+
- `/skill on <name>` - enable a skill for the current chat
136+
- `/skill off <name>` - disable a skill for the current chat
134137
- `/sh <command>` - run a safe allowlisted Linux command in the current project (disabled by default)
135138
- `/sh --confirm <command>` - confirm a dangerous command when writable mode is enabled
136139
- `/interrupt` - send `Ctrl+C` to current PTY session
@@ -139,6 +142,11 @@ General:
139142

140143
MCP skill:
141144

145+
- `/mcp list`
146+
- `/mcp status [server]`
147+
- `/mcp reconnect <server>`
148+
- `/mcp enable <server>`
149+
- `/mcp disable <server>`
142150
- `/mcp tools <server>`
143151
- `/mcp call <server> <tool> {"query":"..."}`
144152

@@ -158,6 +166,7 @@ Telegram adaptation notes:
158166
- `/new` is implemented by the bot and resets the current chat session
159167
- `/status` is implemented by the bot and reports local runtime state
160168
- `/repo` is implemented by the bot and switches the per-chat working directory inside `WORKSPACE_ROOT`
169+
- `/skill` is implemented by the bot and keeps per-chat skill switches in runtime state
161170
- `/sh` is implemented by the bot, never invokes a shell interpreter, and only accepts configured command prefixes
162171
- `/sh` is read-only by default; dangerous prefixes can be configured and require `--confirm` when writable mode is enabled
163172
- `/plan` translates to a planning-only prompt instead of passing a raw `/plan` slash command to Codex
@@ -257,6 +266,14 @@ It is useful when you need deterministic operator actions from Telegram, such as
257266

258267
Treat it as an admin-only ops channel, not a general-purpose remote shell.
259268

269+
## MCP and Skill Control Plane
270+
271+
Telegram can manage runtime usage of Bot-side MCP and skills, but not install arbitrary new servers from chat.
272+
273+
- MCP servers are process-level runtime resources: list, inspect, reconnect, enable, disable
274+
- Skills are chat-level routing switches: each chat can enable or disable `github` and `mcp` independently
275+
- Codex's own MCP remains separate and is not managed through these bot commands
276+
260277
## Troubleshooting
261278

262279
- **Bot not responding**: verify `BOT_TOKEN` and `ALLOWED_USER_IDS`

src/bot/handlers.js

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,27 @@ function formatProjectLines(projects, currentWorkdir) {
4444
});
4545
}
4646

47-
export function registerHandlers({ bot, router, ptyManager, shellManager, skills, scheduler }) {
47+
function formatSkillLines(skillStates) {
48+
return skillStates.map((skill) => `- ${skill.name}: ${skill.enabled ? "on" : "off"}`);
49+
}
50+
51+
export function registerHandlers({
52+
bot,
53+
router,
54+
ptyManager,
55+
shellManager,
56+
skills,
57+
skillRegistry,
58+
scheduler
59+
}) {
4860
bot.start(async (ctx) => {
4961
await sendChunkedMarkdown(
5062
ctx,
5163
[
5264
"codex-telegram-claws ready.",
5365
"普通消息和编码任务会路由到 Codex。",
5466
"MCP 只在显式 /mcp 命令下调用。",
55-
"试试: /status, /repo, /pwd, /exec, /auto, /plan, /model, /new, /sh",
67+
"试试: /status, /repo, /pwd, /exec, /auto, /plan, /model, /skill, /new, /sh",
5668
"GitHub 指令示例: /gh commit \"feat: init\""
5769
].join("\n")
5870
);
@@ -76,19 +88,24 @@ export function registerHandlers({ bot, router, ptyManager, shellManager, skills
7688
"/auto <task> - 强制用 codex exec --full-auto 运行任务",
7789
"/plan <task> - 仅生成执行计划,不直接修改代码",
7890
"/model [name|reset] - 查看或设置当前 chat 的模型",
91+
"/skill list - 查看当前 chat 的 skill 开关",
92+
"/skill on <name> - 启用 skill",
93+
"/skill off <name> - 禁用 skill",
7994
"/sh <command> - 运行受限 Linux 命令 (默认关闭)",
8095
"/sh --confirm <command> - 确认执行高风险命令",
8196
"/interrupt - 向 Codex CLI 发送 Ctrl+C",
8297
"/stop - 终止当前 chat 的 PTY 会话",
8398
"/cron_now - 立即触发一次日报推送",
8499
"/gh ... - GitHub skill",
85-
"/mcp ... - MCP skill (显式调用)"
100+
"/mcp ... - MCP skill 管理与显式调用"
86101
].join("\n")
87102
);
88103
});
89104

90105
bot.command("status", async (ctx) => {
91106
const status = ptyManager.getStatus(ctx.chat.id);
107+
const skillStates = skillRegistry.list(ctx.chat.id);
108+
const mcpServers = skills.mcp.mcpClient.listServers();
92109
await sendChunkedMarkdown(
93110
ctx,
94111
[
@@ -110,7 +127,12 @@ export function registerHandlers({ bot, router, ptyManager, shellManager, skills
110127
? `enabled, ${shellManager.isReadOnly() ? "read-only" : "writable"} (${shellManager.getAllowedCommands().length} prefixes)`
111128
: "disabled"
112129
}`,
113-
`mcp servers: ${status.mcpServers.length ? status.mcpServers.join(", ") : "none"}`
130+
`skills: ${skillStates.map((skill) => `${skill.name}:${skill.enabled ? "on" : "off"}`).join(", ") || "none"}`,
131+
`mcp servers: ${
132+
mcpServers.length
133+
? mcpServers.map((server) => `${server.name}:${server.enabled ? "on" : "off"}/${server.connected ? "up" : "down"}`).join(", ")
134+
: "none"
135+
}`
114136
].join("\n")
115137
);
116138
});
@@ -220,6 +242,46 @@ export function registerHandlers({ bot, router, ptyManager, shellManager, skills
220242
}
221243
});
222244

245+
bot.command("skill", async (ctx) => {
246+
const payload = extractCommandPayload(ctx.message.text, "skill");
247+
if (!payload || /^list$/i.test(payload)) {
248+
await sendChunkedMarkdown(
249+
ctx,
250+
[
251+
"Skills:",
252+
...formatSkillLines(skillRegistry.list(ctx.chat.id)),
253+
"",
254+
"Usage: /skill list | /skill on <name> | /skill off <name>"
255+
].join("\n")
256+
);
257+
return;
258+
}
259+
260+
const [action, rawName] = payload.split(/\s+/, 2);
261+
if (!/^(on|off)$/i.test(action) || !rawName) {
262+
await sendChunkedMarkdown(ctx, "用法: /skill list | /skill on <name> | /skill off <name>");
263+
return;
264+
}
265+
266+
try {
267+
if (/^on$/i.test(action)) {
268+
skillRegistry.enable(ctx.chat.id, rawName);
269+
} else {
270+
skillRegistry.disable(ctx.chat.id, rawName);
271+
}
272+
273+
await sendChunkedMarkdown(
274+
ctx,
275+
[
276+
`skill ${rawName}${/^on$/i.test(action) ? "启用" : "禁用"}。`,
277+
...formatSkillLines(skillRegistry.list(ctx.chat.id))
278+
].join("\n")
279+
);
280+
} catch (error) {
281+
await sendChunkedMarkdown(ctx, `Skill 管理失败: ${error.message}`);
282+
}
283+
});
284+
223285
bot.command("new", async (ctx) => {
224286
const closed = ptyManager.closeSession(ctx.chat.id);
225287
await sendChunkedMarkdown(
@@ -412,6 +474,11 @@ export function registerHandlers({ bot, router, ptyManager, shellManager, skills
412474
});
413475

414476
bot.command("gh", async (ctx) => {
477+
if (!skillRegistry.isEnabled(ctx.chat.id, "github")) {
478+
await sendChunkedMarkdown(ctx, "GitHub skill 当前 chat 已禁用。使用 /skill on github 重新启用。");
479+
return;
480+
}
481+
415482
try {
416483
const text = extractCommandPayload(ctx.message.text, "gh") || "help";
417484
const result = await skills.github.execute({
@@ -426,6 +493,11 @@ export function registerHandlers({ bot, router, ptyManager, shellManager, skills
426493
});
427494

428495
bot.command("mcp", async (ctx) => {
496+
if (!skillRegistry.isEnabled(ctx.chat.id, "mcp")) {
497+
await sendChunkedMarkdown(ctx, "MCP skill 当前 chat 已禁用。使用 /skill on mcp 重新启用。");
498+
return;
499+
}
500+
429501
try {
430502
const text = ctx.message.text.trim();
431503
const result = await skills.mcp.execute({ text, ctx });
@@ -456,7 +528,9 @@ export function registerHandlers({ bot, router, ptyManager, shellManager, skills
456528
if (!text || text.startsWith("/")) return;
457529

458530
try {
459-
const route = await router.routeMessage(text);
531+
const route = await router.routeMessage(text, {
532+
chatId: ctx.chat.id
533+
});
460534
if (route.target === "pty") {
461535
const result = await ptyManager.sendPrompt(ctx, route.prompt);
462536
if (!result.started) {
@@ -476,7 +550,8 @@ export function registerHandlers({ bot, router, ptyManager, shellManager, skills
476550

477551
const result = await skill.execute({
478552
text: route.payload,
479-
ctx
553+
ctx,
554+
workdir: ptyManager.getStatus(ctx.chat.id).workdir
480555
});
481556
await sendSkillResult(ctx, result);
482557
} catch (error) {

src/index.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createAuthMiddleware } from "./bot/middleware.js";
44
import { registerHandlers } from "./bot/handlers.js";
55
import { Router } from "./orchestrator/router.js";
66
import { McpClient } from "./orchestrator/mcpClient.js";
7+
import { SkillRegistry } from "./orchestrator/skillRegistry.js";
78
import { McpSkill } from "./orchestrator/skills/mcpSkill.js";
89
import { GitHubSkill } from "./orchestrator/skills/githubSkill.js";
910
import { PtyManager } from "./runner/ptyManager.js";
@@ -28,9 +29,11 @@ const skills = {
2829
github: githubSkill,
2930
mcp: mcpSkill
3031
};
32+
const skillRegistry = new SkillRegistry(skills);
3133

3234
const router = new Router({
33-
skills
35+
skills,
36+
isSkillEnabled: (chatId, skillName) => skillRegistry.isEnabled(chatId, skillName)
3437
});
3538

3639
const ptyManager = new PtyManager({
@@ -53,6 +56,7 @@ registerHandlers({
5356
ptyManager,
5457
shellManager,
5558
skills,
59+
skillRegistry,
5660
scheduler
5761
});
5862

src/orchestrator/mcpClient.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,51 @@ export class McpClient {
2828
constructor(config) {
2929
this.config = config;
3030
this.connections = new Map();
31+
this.disabledServers = new Set();
3132
}
3233

3334
hasServers() {
3435
return this.config.mcp.servers.length > 0;
3536
}
3637

38+
getServerConfig(serverName) {
39+
return this.config.mcp.servers.find((server) => server.name === serverName) || null;
40+
}
41+
42+
hasServer(serverName) {
43+
return Boolean(this.getServerConfig(serverName));
44+
}
45+
46+
isServerEnabled(serverName) {
47+
return this.hasServer(serverName) && !this.disabledServers.has(serverName);
48+
}
49+
50+
isServerConnected(serverName) {
51+
return this.connections.has(serverName);
52+
}
53+
54+
listServers() {
55+
return this.config.mcp.servers.map((server) => ({
56+
name: server.name,
57+
command: server.command,
58+
args: server.args,
59+
cwd: server.cwd,
60+
enabled: this.isServerEnabled(server.name),
61+
connected: this.isServerConnected(server.name)
62+
}));
63+
}
64+
3765
async connectAll() {
3866
for (const server of this.config.mcp.servers) {
3967
await this.connectServer(server);
4068
}
4169
}
4270

4371
async connectServer(server) {
72+
if (this.disabledServers.has(server.name)) {
73+
return;
74+
}
75+
4476
if (this.connections.has(server.name)) return;
4577

4678
const transport = new StdioClientTransport({
@@ -67,6 +99,67 @@ export class McpClient {
6799
this.connections.set(server.name, { client, transport });
68100
}
69101

102+
async connectServerByName(serverName) {
103+
const server = this.getServerConfig(serverName);
104+
if (!server) {
105+
throw new Error(`Unknown MCP server: ${serverName}`);
106+
}
107+
108+
if (this.disabledServers.has(serverName)) {
109+
throw new Error(`MCP server is disabled: ${serverName}`);
110+
}
111+
112+
await this.connectServer(server);
113+
}
114+
115+
async disconnectServer(serverName) {
116+
const conn = this.connections.get(serverName);
117+
if (!conn) return false;
118+
119+
try {
120+
await conn.transport?.close?.();
121+
} catch {
122+
// Ignore close errors on runtime disconnect.
123+
}
124+
125+
this.connections.delete(serverName);
126+
return true;
127+
}
128+
129+
async reconnectServer(serverName) {
130+
if (!this.hasServer(serverName)) {
131+
throw new Error(`Unknown MCP server: ${serverName}`);
132+
}
133+
134+
if (this.disabledServers.has(serverName)) {
135+
throw new Error(`MCP server is disabled: ${serverName}`);
136+
}
137+
138+
await this.disconnectServer(serverName);
139+
await this.connectServerByName(serverName);
140+
return this.listServers().find((server) => server.name === serverName) || null;
141+
}
142+
143+
async disableServer(serverName) {
144+
if (!this.hasServer(serverName)) {
145+
throw new Error(`Unknown MCP server: ${serverName}`);
146+
}
147+
148+
this.disabledServers.add(serverName);
149+
await this.disconnectServer(serverName);
150+
return this.listServers().find((server) => server.name === serverName) || null;
151+
}
152+
153+
async enableServer(serverName) {
154+
if (!this.hasServer(serverName)) {
155+
throw new Error(`Unknown MCP server: ${serverName}`);
156+
}
157+
158+
this.disabledServers.delete(serverName);
159+
await this.connectServerByName(serverName);
160+
return this.listServers().find((server) => server.name === serverName) || null;
161+
}
162+
70163
async listTools(serverName) {
71164
const conn = this.connections.get(serverName);
72165
if (!conn) throw new Error(`MCP server not connected: ${serverName}`);

src/orchestrator/router.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,26 @@ function likelyCodingTask(text) {
2828
}
2929

3030
export class Router {
31-
constructor({ skills }) {
31+
constructor({ skills, isSkillEnabled = () => true }) {
3232
this.skills = skills;
33+
this.isSkillEnabled = isSkillEnabled;
3334
}
3435

35-
async routeMessage(text) {
36+
async routeMessage(text, options = {}) {
3637
const raw = text.trim();
38+
const chatId = options.chatId;
3739
const githubSkill = this.skills.github;
3840
const mcpSkill = this.skills.mcp;
3941

40-
if (githubSkill && githubSkill.supports(raw)) {
42+
if (githubSkill && this.isSkillEnabled(chatId, "github") && githubSkill.supports(raw)) {
4143
return {
4244
target: "skill",
4345
skill: "github",
4446
payload: raw
4547
};
4648
}
4749

48-
if (mcpSkill && mcpSkill.supports(raw)) {
50+
if (mcpSkill && this.isSkillEnabled(chatId, "mcp") && mcpSkill.supports(raw)) {
4951
return {
5052
target: "skill",
5153
skill: "mcp",

0 commit comments

Comments
 (0)