extract Minecraft tools to shared toolkit; gate Redstone replies to authorized senders
Redstone previously only saw the 23 tg_* tools — it had no idea the
Minecraft stack existed, so questions like "is the server up?" went
unanswered. This change extracts the 15 Minecraft tools (lifecycle,
rcon, backup, players, seen) into the same shared catalog the Telegram
tools already used, so both Gemini and external MCP clients see them.
mcp/lib/types.ts (new) holds the shared shape: Tool<P>, ToolCtx,
createTgClient, createMcRuntime (a Bun.spawn-based wrapper for docker
compose / docker exec), and toolToGeminiFunction (zod → Gemini schema,
now also stripping exclusiveMinimum/Maximum since Gemini rejects them).
mcp/lib/minecraft-tools.ts (new) is the catalog itself. Eight handlers
are flagged requiresAdmin: rcon, backup, and all destructive player_*
writes plus seen_list. mcp/server.ts trusts the caller (Claude / Paul
on the host) and ignores the flag; bot/bot.ts honours it at dispatch
time, returning {error: "...requires admin role..."} to Gemini so it
can explain to the user instead of attempting the call.
mcp/server.ts shrinks from 423 lines to 70 — a single loop over both
catalogs replaces the hand-rolled registrations.
bot/bot.ts wires both catalogs into the function declarations and adds
the admin gate. It also gains a defensive re-check on every incoming
group/DM text: the Redstone handler now does its own lookup of
ctx.from.id against the users table and refuses to reply unless
status='active'. This is belt-and-braces — the auth middleware already
short-circuits unauthorized callers earlier in the chain, but with
privacy-mode-off groups now feeding every message through, a future
refactor that reorders middleware shouldn't be able to make Redstone
respond to strangers.
Drops thinkingConfig from the Gemini call (gemini-2.5-flash-lite
rejects it outright with HTTP 400).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
265
mcp/lib/minecraft-tools.ts
Normal file
265
mcp/lib/minecraft-tools.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
// Minecraft tool catalog (server lifecycle, rcon, backup, player DB).
|
||||
// Imported by mcp/server.ts and bot/bot.ts via the shared toolkit.
|
||||
import { z } from "zod";
|
||||
import { tool, type Tool } from "./types.ts";
|
||||
|
||||
const MC_NAME_RE = /^[A-Za-z0-9_]{3,16}$/;
|
||||
|
||||
type Role = "admin" | "user";
|
||||
type Status = "active" | "pending";
|
||||
type DbUser = {
|
||||
telegram_id: number;
|
||||
name: string | null;
|
||||
role: Role;
|
||||
minecraft_name: string | null;
|
||||
username: string | null;
|
||||
status: Status;
|
||||
};
|
||||
|
||||
const USER_COLS = "telegram_id, name, role, minecraft_name, username, status";
|
||||
|
||||
export const minecraftTools: Tool<z.ZodRawShape>[] = [
|
||||
// ---- server lifecycle ----
|
||||
tool({
|
||||
name: "server_status",
|
||||
title: "Server status",
|
||||
description: "Report whether the minecraft and autossh tunnel containers are running.",
|
||||
parameters: {},
|
||||
handler: async (_a, { mc }) => {
|
||||
const [server, tunnel] = await Promise.all([
|
||||
mc.containerRunning(mc.config.serverContainer),
|
||||
mc.containerRunning(mc.config.pfContainer),
|
||||
]);
|
||||
return {
|
||||
minecraft: { container: mc.config.serverContainer, running: server },
|
||||
autossh: { container: mc.config.pfContainer, running: tunnel },
|
||||
};
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "server_up",
|
||||
title: "Start services",
|
||||
description: "docker compose up -d. Pass a service name (minecraft / autossh / bot) to start just one; omit for the whole stack.",
|
||||
parameters: { service: z.string().optional() },
|
||||
handler: async ({ service }, { mc }) => {
|
||||
const r = await (service ? mc.compose("up", "-d", service) : mc.compose("up", "-d"));
|
||||
return { ok: r.ok, code: r.code, output: [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)" };
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "server_down",
|
||||
title: "Stop services",
|
||||
description: "docker compose down. Pass a service to stop just one (uses `stop` instead of `down`).",
|
||||
parameters: { service: z.string().optional() },
|
||||
handler: async ({ service }, { mc }) => {
|
||||
const r = service ? await mc.compose("stop", service) : await mc.compose("down");
|
||||
return { ok: r.ok, code: r.code, output: [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)" };
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "server_restart",
|
||||
title: "Restart a service",
|
||||
description: "docker compose restart of a single service (defaults to minecraft).",
|
||||
parameters: { service: z.string().optional() },
|
||||
handler: async ({ service }, { mc }) => {
|
||||
const svc = service ?? mc.config.serverSvc;
|
||||
const r = await mc.compose("restart", svc);
|
||||
return { ok: r.ok, code: r.code, output: [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)" };
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "server_logs",
|
||||
title: "Container logs",
|
||||
description: "Tail recent logs from one of the project's containers.",
|
||||
parameters: {
|
||||
container: z.enum(["minecraft", "autossh", "bot"]).optional()
|
||||
.describe("Which container; default minecraft."),
|
||||
lines: z.number().int().positive().max(2000).optional()
|
||||
.describe("How many lines to return; default 100."),
|
||||
},
|
||||
handler: async ({ container, lines }, { mc }) => {
|
||||
const name = container === "autossh" ? mc.config.pfContainer
|
||||
: container === "bot" ? "minecraft-bot"
|
||||
: mc.config.serverContainer;
|
||||
const r = await mc.sh(["docker", "logs", "--tail", String(lines ?? 100), name]);
|
||||
return { ok: r.ok, output: [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)" };
|
||||
},
|
||||
}),
|
||||
|
||||
// ---- rcon (admin-only) ----
|
||||
tool({
|
||||
name: "rcon",
|
||||
title: "RCON command",
|
||||
description: "Run an RCON command on the live server via `rcon-cli` inside the minecraft container. Examples: 'list', 'say hello', 'whitelist add Steve', 'op Steve', 'gamemode creative @a'. Server must be running.",
|
||||
parameters: { command: z.string().min(1).describe("Full RCON command, e.g. 'list' or 'say hi'.") },
|
||||
requiresAdmin: true,
|
||||
handler: async ({ command }, { mc }) => {
|
||||
if (!(await mc.containerRunning(mc.config.serverContainer))) {
|
||||
throw new Error(`minecraft container '${mc.config.serverContainer}' is not running — start it with server_up first.`);
|
||||
}
|
||||
const parts = command.trim().split(/\s+/);
|
||||
const r = await mc.sh(["docker", "exec", mc.config.serverContainer, "rcon-cli", ...parts]);
|
||||
if (!r.ok) throw new Error(`rcon failed (exit ${r.code}): ${[r.out, r.err].filter(Boolean).join(" ")}`);
|
||||
return { command, output: r.out || "(no output)" };
|
||||
},
|
||||
}),
|
||||
|
||||
// ---- backup (admin-only) ----
|
||||
tool({
|
||||
name: "backup",
|
||||
title: "Run world backup",
|
||||
description: "Runs backup.sh: flushes the world via rcon if the server is running, archives data/world to backups/, and uploads it as a Gitea release asset if a token is configured. May take a while for large worlds.",
|
||||
parameters: {
|
||||
label: z.string().regex(/^[A-Za-z0-9._-]+$/).max(40).optional()
|
||||
.describe("Optional label suffix for the archive filename, e.g. 'pre-update'."),
|
||||
},
|
||||
requiresAdmin: true,
|
||||
handler: async ({ label }, { mc }) => {
|
||||
const args = label ? [mc.config.backupSh, label] : [mc.config.backupSh];
|
||||
const r = await mc.sh(args);
|
||||
return {
|
||||
ok: r.ok,
|
||||
code: r.code,
|
||||
output: [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)",
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
// ---- players (users db) ----
|
||||
tool({
|
||||
name: "players_list",
|
||||
title: "List players",
|
||||
description: "List all rows in the bot users table (telegram_id, name, role, minecraft_name, username, status). Optional status / role filter.",
|
||||
parameters: {
|
||||
status: z.enum(["active", "pending"]).optional(),
|
||||
role: z.enum(["admin", "user"]).optional(),
|
||||
},
|
||||
handler: async ({ status, role }, { db }) => {
|
||||
let rows = db.prepare(`SELECT ${USER_COLS} FROM users ORDER BY role DESC, added_at ASC`).all() as DbUser[];
|
||||
if (status) rows = rows.filter((r) => r.status === status);
|
||||
if (role) rows = rows.filter((r) => r.role === role);
|
||||
return { count: rows.length, players: rows };
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "player_get",
|
||||
title: "Get player",
|
||||
description: "Look up a single player. Provide exactly one of telegram_id, minecraft_name, or telegram_username.",
|
||||
parameters: {
|
||||
telegram_id: z.number().int().optional(),
|
||||
minecraft_name: z.string().optional(),
|
||||
telegram_username: z.string().optional(),
|
||||
},
|
||||
handler: async ({ telegram_id, minecraft_name, telegram_username }, { db }) => {
|
||||
const provided = [telegram_id, minecraft_name, telegram_username].filter((v) => v !== undefined).length;
|
||||
if (provided !== 1) throw new Error("Provide exactly one of telegram_id, minecraft_name, telegram_username.");
|
||||
const sql = `SELECT ${USER_COLS} FROM users WHERE ` + (
|
||||
telegram_id !== undefined ? "telegram_id = ?"
|
||||
: minecraft_name !== undefined ? "minecraft_name = ?"
|
||||
: "username = ?"
|
||||
);
|
||||
const arg = telegram_id ?? minecraft_name ?? telegram_username!.replace(/^@/, "");
|
||||
const row = db.prepare(sql).get(arg) as DbUser | undefined;
|
||||
if (!row) throw new Error("Not found.");
|
||||
return row;
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "player_upsert",
|
||||
title: "Add or update a player",
|
||||
description: "Insert a player by telegram_id, or update their name/role if they exist. Use player_set_minecraft_name to attach an in-game name.",
|
||||
parameters: {
|
||||
telegram_id: z.number().int(),
|
||||
name: z.string().nullable().optional(),
|
||||
role: z.enum(["admin", "user"]).default("user"),
|
||||
},
|
||||
requiresAdmin: true,
|
||||
handler: async ({ telegram_id, name, role }, { db }) => {
|
||||
db.prepare(
|
||||
`INSERT INTO users (telegram_id, name, role) VALUES (?, ?, ?)
|
||||
ON CONFLICT(telegram_id) DO UPDATE SET name = COALESCE(excluded.name, users.name), role = excluded.role`,
|
||||
).run(telegram_id, name ?? null, role);
|
||||
return { saved: db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) };
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "player_set_minecraft_name",
|
||||
title: "Set/clear a player's Minecraft name",
|
||||
description: "Update the minecraft_name for an existing player. Pass null/empty to clear. Names must match Mojang's rules (3–16 chars, A–Z, 0–9, _).",
|
||||
parameters: {
|
||||
telegram_id: z.number().int(),
|
||||
minecraft_name: z.string().nullable().describe("New name, or null/'' to clear."),
|
||||
},
|
||||
requiresAdmin: true,
|
||||
handler: async ({ telegram_id, minecraft_name }, { db }) => {
|
||||
const existing = db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) as DbUser | undefined;
|
||||
if (!existing) throw new Error(`No player with telegram_id ${telegram_id}.`);
|
||||
const clean = minecraft_name && minecraft_name.length > 0 ? minecraft_name : null;
|
||||
if (clean !== null && !MC_NAME_RE.test(clean)) throw new Error(`'${clean}' is not a valid Minecraft name (3–16 chars, A–Z, 0–9, _).`);
|
||||
if (clean !== null) {
|
||||
const taken = db.prepare(`SELECT telegram_id FROM users WHERE minecraft_name = ?`).get(clean) as { telegram_id: number } | undefined;
|
||||
if (taken && taken.telegram_id !== telegram_id) throw new Error(`'${clean}' is already linked to telegram_id ${taken.telegram_id}.`);
|
||||
}
|
||||
db.prepare(`UPDATE users SET minecraft_name = ? WHERE telegram_id = ?`).run(clean, telegram_id);
|
||||
return { updated: db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) };
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "player_set_role",
|
||||
title: "Change a player's role",
|
||||
description: "Promote/demote between 'admin' and 'user'.",
|
||||
parameters: {
|
||||
telegram_id: z.number().int(),
|
||||
role: z.enum(["admin", "user"]),
|
||||
},
|
||||
requiresAdmin: true,
|
||||
handler: async ({ telegram_id, role }, { db }) => {
|
||||
const existing = db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id);
|
||||
if (!existing) throw new Error(`No player with telegram_id ${telegram_id}.`);
|
||||
db.prepare(`UPDATE users SET role = ? WHERE telegram_id = ?`).run(role, telegram_id);
|
||||
return { updated: db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) };
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "player_set_status",
|
||||
title: "Set a player's status",
|
||||
description: "Mark a player as 'active' (approved) or 'pending' (awaiting approval).",
|
||||
parameters: {
|
||||
telegram_id: z.number().int(),
|
||||
status: z.enum(["active", "pending"]),
|
||||
},
|
||||
requiresAdmin: true,
|
||||
handler: async ({ telegram_id, status }, { db }) => {
|
||||
const existing = db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id);
|
||||
if (!existing) throw new Error(`No player with telegram_id ${telegram_id}.`);
|
||||
db.prepare(`UPDATE users SET status = ? WHERE telegram_id = ?`).run(status, telegram_id);
|
||||
return { updated: db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) };
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "player_remove",
|
||||
title: "Delete a player",
|
||||
description: "Remove a player record from the users table.",
|
||||
parameters: { telegram_id: z.number().int() },
|
||||
requiresAdmin: true,
|
||||
handler: async ({ telegram_id }, { db }) => {
|
||||
const before = db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) as DbUser | undefined;
|
||||
if (!before) throw new Error(`No player with telegram_id ${telegram_id}.`);
|
||||
db.prepare(`DELETE FROM users WHERE telegram_id = ?`).run(telegram_id);
|
||||
return { removed: before };
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "seen_list",
|
||||
title: "List seen-but-unauthorized users",
|
||||
description: "List Telegram users the bot has observed but who aren't in the users table yet — useful for approving pairings.",
|
||||
parameters: {},
|
||||
requiresAdmin: true,
|
||||
handler: async (_a, { db }) => {
|
||||
const rows = db.prepare(
|
||||
"SELECT telegram_id, username, name, first_seen, last_seen FROM seen_users ORDER BY last_seen DESC",
|
||||
).all();
|
||||
return { count: rows.length, seen: rows };
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -1,52 +1,7 @@
|
||||
// Shared Telegram-action toolkit.
|
||||
// Imported by mcp/server.ts (registers each as an MCP tool) and by bot/bot.ts
|
||||
// (exposes each as a Gemini function declaration and dispatches calls).
|
||||
// Imports of npm packages resolve via this directory's node_modules (i.e. mcp/node_modules)
|
||||
// regardless of which process loads the file.
|
||||
// Telegram tool catalog. Shared types + helpers live in ./types.ts.
|
||||
import { z } from "zod";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
import type { Database } from "bun:sqlite";
|
||||
|
||||
// ---- Telegram HTTP client ----
|
||||
|
||||
export type TgClient = {
|
||||
call: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>;
|
||||
};
|
||||
|
||||
export function createTgClient(token: string): TgClient {
|
||||
if (!token) throw new Error("createTgClient: empty token");
|
||||
const base = `https://api.telegram.org/bot${token}`;
|
||||
return {
|
||||
async call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const res = await fetch(`${base}/${method}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
const data = (await res.json()) as { ok: boolean; result?: T; description?: string; error_code?: number };
|
||||
if (!data.ok) throw new Error(`${method}: ${data.description} (code ${data.error_code})`);
|
||||
return data.result as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Tool context passed to every handler ----
|
||||
|
||||
export type ToolCtx = {
|
||||
tg: TgClient;
|
||||
db: Database;
|
||||
};
|
||||
|
||||
// ---- Tool definition shape ----
|
||||
|
||||
export type Tool<Params extends z.ZodRawShape = z.ZodRawShape> = {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
parameters: Params;
|
||||
handler: (args: z.infer<z.ZodObject<Params>>, ctx: ToolCtx) => Promise<unknown>;
|
||||
};
|
||||
import { tool, type Tool } from "./types.ts";
|
||||
export { createTgClient, toolToGeminiFunction, type ToolCtx, type Tool, type TgClient } from "./types.ts";
|
||||
|
||||
// ---- Shared sub-schemas ----
|
||||
|
||||
@@ -83,11 +38,6 @@ function buildKeyboard(rows: z.infer<typeof ButtonSchema>[][] | undefined) {
|
||||
|
||||
// ---- The tool catalog ----
|
||||
|
||||
// Helper to keep the array strictly typed without losing each tool's specific param shape.
|
||||
function tool<P extends z.ZodRawShape>(t: Tool<P>): Tool<z.ZodRawShape> {
|
||||
return t as Tool<z.ZodRawShape>;
|
||||
}
|
||||
|
||||
export const telegramTools: Tool<z.ZodRawShape>[] = [
|
||||
// ---- discovery ----
|
||||
tool({
|
||||
@@ -418,44 +368,3 @@ export const telegramTools: Tool<z.ZodRawShape>[] = [
|
||||
}),
|
||||
];
|
||||
|
||||
// ---- JSON Schema conversion for Gemini function declarations ----
|
||||
|
||||
// Gemini's parameters block accepts a JSON-Schema-ish dialect. zod-to-json-schema
|
||||
// emits JSON-Schema draft-07; the field shapes we use (string/integer/number/boolean/array/object/enum)
|
||||
// pass through unchanged. We strip `$schema` since Gemini ignores it.
|
||||
function cleanForGemini(s: unknown): unknown {
|
||||
if (Array.isArray(s)) return s.map(cleanForGemini);
|
||||
if (s && typeof s === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(s as Record<string, unknown>)) {
|
||||
if (k === "$schema" || k === "additionalProperties" || k === "default") continue;
|
||||
// Gemini doesn't accept `union/anyOf` for chat fields — flatten to "string" for compatibility.
|
||||
if (k === "anyOf" || k === "oneOf") {
|
||||
// Pick the first non-null subtype heuristically.
|
||||
const variants = (v as unknown[]).filter((x) => x && (x as Record<string, unknown>).type !== "null");
|
||||
if (variants.length === 1) return cleanForGemini(variants[0]);
|
||||
// For mixed types we fall back to string.
|
||||
return { type: "string" };
|
||||
}
|
||||
out[k] = cleanForGemini(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function toolToGeminiFunction(t: Tool<z.ZodRawShape>) {
|
||||
// Gemini's schema dialect rejects $ref/$defs/$schema/additionalProperties, so:
|
||||
// - $refStrategy "none" inlines shared sub-schemas (e.g. ButtonSchema reused across tools).
|
||||
// - cleanForGemini strips the rest.
|
||||
const schema = zodToJsonSchema(z.object(t.parameters), {
|
||||
$refStrategy: "none",
|
||||
target: "openApi3",
|
||||
});
|
||||
const params = cleanForGemini(schema) as Record<string, unknown>;
|
||||
return {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: params.type ? params : { type: "object", properties: {} },
|
||||
};
|
||||
}
|
||||
|
||||
135
mcp/lib/types.ts
Normal file
135
mcp/lib/types.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
// Shared types + helpers used by both telegram-tools and minecraft-tools.
|
||||
import { z } from "zod";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
import type { Database } from "bun:sqlite";
|
||||
|
||||
// ---- Telegram HTTP client ----
|
||||
|
||||
export type TgClient = {
|
||||
call: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>;
|
||||
};
|
||||
|
||||
export function createTgClient(token: string): TgClient {
|
||||
if (!token) throw new Error("createTgClient: empty token");
|
||||
const base = `https://api.telegram.org/bot${token}`;
|
||||
return {
|
||||
async call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const res = await fetch(`${base}/${method}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
const data = (await res.json()) as { ok: boolean; result?: T; description?: string; error_code?: number };
|
||||
if (!data.ok) throw new Error(`${method}: ${data.description} (code ${data.error_code})`);
|
||||
return data.result as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Minecraft runtime (docker/host shell) ----
|
||||
|
||||
export type ShResult = { ok: boolean; code: number; out: string; err: string };
|
||||
|
||||
export type McConfig = {
|
||||
mcDir: string;
|
||||
composeFile: string;
|
||||
composeProject: string;
|
||||
serverSvc: string;
|
||||
serverContainer: string;
|
||||
pfSvc: string;
|
||||
pfContainer: string;
|
||||
backupSh: string;
|
||||
};
|
||||
|
||||
export type McRuntime = {
|
||||
config: McConfig;
|
||||
sh: (cmd: string[]) => Promise<ShResult>;
|
||||
compose: (...args: string[]) => Promise<ShResult>;
|
||||
containerRunning: (name: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function createMcRuntime(config: McConfig): McRuntime {
|
||||
async function sh(cmd: string[]): Promise<ShResult> {
|
||||
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
|
||||
const [out, err] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
const code = await proc.exited;
|
||||
return { ok: code === 0, code, out: out.trimEnd(), err: err.trimEnd() };
|
||||
}
|
||||
return {
|
||||
config,
|
||||
sh,
|
||||
compose: (...args) => sh([
|
||||
"docker", "compose",
|
||||
"--project-directory", config.mcDir,
|
||||
"-p", config.composeProject,
|
||||
"-f", config.composeFile,
|
||||
...args,
|
||||
]),
|
||||
async containerRunning(name) {
|
||||
const r = await sh(["docker", "inspect", "-f", "{{.State.Running}}", name]);
|
||||
return r.ok && r.out === "true";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Tool definition shape ----
|
||||
|
||||
export type ToolCtx = {
|
||||
tg: TgClient;
|
||||
db: Database;
|
||||
mc: McRuntime;
|
||||
};
|
||||
|
||||
export type Tool<Params extends z.ZodRawShape = z.ZodRawShape> = {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
parameters: Params;
|
||||
handler: (args: z.infer<z.ZodObject<Params>>, ctx: ToolCtx) => Promise<unknown>;
|
||||
/** Bot-side: only admins may invoke. MCP server ignores this (caller is trusted). */
|
||||
requiresAdmin?: boolean;
|
||||
};
|
||||
|
||||
// Strip per-tool shape so a heterogeneous catalog type-checks.
|
||||
export function tool<P extends z.ZodRawShape>(t: Tool<P>): Tool<z.ZodRawShape> {
|
||||
return t as Tool<z.ZodRawShape>;
|
||||
}
|
||||
|
||||
// ---- Zod -> Gemini function declaration ----
|
||||
|
||||
function cleanForGemini(s: unknown): unknown {
|
||||
if (Array.isArray(s)) return s.map(cleanForGemini);
|
||||
if (s && typeof s === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(s as Record<string, unknown>)) {
|
||||
if (k === "$schema" || k === "additionalProperties" || k === "default") continue;
|
||||
// Gemini's schema dialect uses minimum/maximum only, not the exclusive variants.
|
||||
if (k === "exclusiveMinimum" || k === "exclusiveMaximum") continue;
|
||||
if (k === "anyOf" || k === "oneOf") {
|
||||
const variants = (v as unknown[]).filter((x) => x && (x as Record<string, unknown>).type !== "null");
|
||||
if (variants.length === 1) return cleanForGemini(variants[0]);
|
||||
return { type: "string" };
|
||||
}
|
||||
out[k] = cleanForGemini(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function toolToGeminiFunction(t: Tool<z.ZodRawShape>) {
|
||||
const schema = zodToJsonSchema(z.object(t.parameters), {
|
||||
$refStrategy: "none",
|
||||
target: "openApi3",
|
||||
});
|
||||
const params = cleanForGemini(schema) as Record<string, unknown>;
|
||||
return {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: params.type ? params : { type: "object", properties: {} },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user