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:
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