Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bot-mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@gryt/bot": minor
---

`gryt-mcp`: an MCP server, so an AI assistant like Claude Code can read and act on your Gryt servers as a bot. It knocks like any bot, and you approve it in Server settings → Bots and untick what it shouldn't do. It reads channels, threads, search and the member list. With `view_diagnostics` it reads voice rooms, latency and SFU numbers too. It can post, start threads and delete its own messages, and MCP clients ask you before each of those.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ restarts. Lose it and the server sees a stranger knocking, holding nothing.
In a container that means a volume. The example's compose file has one, and says
why in the place where leaving it out would be easy.

## Let an AI read your server

The package also ships `gryt-mcp`, an MCP server. Add it to Claude Code or
another MCP client, and your AI can read channels, threads, search and voice
diagnostics on your servers, and post when you approve it:

```sh
claude mcp add gryt -- npx -y -p @gryt/bot gryt-mcp --server home=chat.example.com
```

It knocks like any bot. Setup and what it asks for are on
[docs.gryt.chat/docs/build/mcp](https://docs.gryt.chat/docs/build/mcp).

## Issues

Please report bugs and request features in the
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"gryt-mcp": "./dist/mcp/main.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
Expand Down
77 changes: 77 additions & 0 deletions src/mcp/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

import { GrytBot } from "../client.ts";
import { loadIdentity } from "../identity.ts";
import { serveStdio } from "./protocol.ts";
import { makeTools, UNTRUSTED_NOTE, type Connection } from "./tools.ts";

/** What the bot asks for when it knocks. The owner unticks what they don't want, per server. */
const WANTS = ["read_messages", "view_members", "send_messages", "delete_own_messages", "view_diagnostics"];

function parseArgs(argv: string[]) {
const servers = new Map<string, string>();
let identityPath = join(homedir(), ".config", "gryt-mcp", "identity.json");
let nickname = "Gryt";
const fromEnv = (process.env.GRYT_MCP_SERVERS ?? "").split(",").filter(Boolean);
const pairs: string[] = [...fromEnv];
for (let i = 0; i < argv.length - 1; i += 2) {
const [flag, value] = [argv[i], argv[i + 1]];
if (flag === "--server") pairs.push(value);
else if (flag === "--identity") identityPath = value;
else if (flag === "--nickname") nickname = value;
}
for (const pair of pairs) {
const eq = pair.indexOf("=");
const [name, host] = eq > 0 ? [pair.slice(0, eq), pair.slice(eq + 1)] : [pair, pair];
servers.set(name.trim(), host.trim());
}
return { servers, identityPath, nickname };
}

const { servers, identityPath, nickname } = parseArgs(process.argv.slice(2));
if (servers.size === 0) {
console.error("gryt-mcp: give at least one --server name=host (or GRYT_MCP_SERVERS=name=host,...).");
process.exit(2);
}

const version = (JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string }).version;
const identity = await loadIdentity(identityPath);
const connections = new Map<string, Connection>();

for (const [name, host] of servers) {
const bot = new GrytBot({
host,
identity,
nickname,
wants: WANTS,
description: "An AI assistant working for this server's owner, through MCP. It only posts when they approve.",
prefix: "",
helpCommand: false,
});
const c: Connection = { name, host, bot, state: "connecting" };
connections.set(name, c);
bot.on("waiting", (message) => Object.assign(c, { state: "waiting", detail: message }));
// `ready` fires once; `channels` comes with every server:details, including after a reconnect.
bot.on("channels", () => Object.assign(c, { state: "ready", detail: undefined }));
bot.on("disconnected", (reason) => Object.assign(c, { state: "connecting", detail: reason }));
bot.on("error", (err) => {
if (c.state !== "ready") Object.assign(c, { state: "failed", detail: err.message });
console.error(`[gryt-mcp] ${name}: ${err.message}`);
});
bot.start().catch((err: Error) => Object.assign(c, { state: "failed", detail: err.message }));
}

console.error(`[gryt-mcp] ${identity.subject} on ${[...servers.keys()].join(", ")}`);

serveStdio(makeTools(connections), {
name: "gryt",
version,
instructions: [
"Read and act on Gryt chat servers as a bot member. Start with list_servers.",
UNTRUSTED_NOTE,
"Post only when the person you work for asked you to. Posts show a BOT tag.",
].join(" "),
});
102 changes: 102 additions & 0 deletions src/mcp/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import type { Message } from "../types.ts";
import { handle } from "./protocol.ts";
import { makeTools, UNTRUSTED_NOTE, type BotLike, type Connection } from "./tools.ts";

const meta = { name: "gryt", version: "0.0.0", instructions: "test" };

function message(over: Partial<Message> = {}): Message {
return {
conversationId: "c1",
messageId: "m1",
senderServerUserId: "u1",
senderNickname: "Ingy",
text: "voice keeps cutting out",
attachments: [],
createdAt: new Date("2026-11-03T10:15:00Z"),
editedAt: null,
replyToMessageId: null,
threadId: null,
isSystem: false,
isOwn: false,
...over,
};
}

function setup(state: Connection["state"] = "ready") {
const sent: unknown[] = [];
const bot = {
channels: [{ id: "c1", name: "general", type: "text" }],
members: [],
permissions: ["read_messages"],
serverInfo: { serverId: "s", name: "S", description: "", permissions: [], role: "bot" },
history: async () => ({ messages: [message()], threads: [], hasMore: false }),
send: async (...args: unknown[]) => {
sent.push(args);
return message({ messageId: "m2", isOwn: true });
},
} as unknown as BotLike;
const connections = new Map<string, Connection>([["pingy", { name: "pingy", host: "localhost:5001", bot, state }]]);
return { tools: makeTools(connections), sent };
}

const call = (tools: ReturnType<typeof makeTools>, name: string, args: object) =>
handle(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }), tools, meta) as Promise<{
result: { content: { text: string }[]; isError?: boolean };
}>;

describe("the MCP protocol", () => {
it("answers initialize with tools as its only capability", async () => {
const out = (await handle('{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}', [], meta)) as {
result: { capabilities: object };
};
assert.deepEqual(out.result.capabilities, { tools: {} });
});

it("does not answer notifications, and rejects unknown methods", async () => {
assert.equal(await handle('{"jsonrpc":"2.0","method":"notifications/initialized"}', [], meta), null);
const out = (await handle('{"jsonrpc":"2.0","id":2,"method":"nope"}', [], meta)) as { error: { code: number } };
assert.equal(out.error.code, -32601);
});

it("marks read tools read-only and the rest not", async () => {
const { tools } = setup();
const out = (await handle('{"jsonrpc":"2.0","id":3,"method":"tools/list"}', tools, meta)) as {
result: { tools: { name: string; annotations: { readOnlyHint: boolean } }[] };
};
const writes = out.result.tools.filter((t) => !t.annotations.readOnlyHint).map((t) => t.name);
assert.deepEqual(writes.sort(), ["delete_own_message", "send_message", "start_thread"]);
});
});

describe("the tools", () => {
it("reads a channel by name and marks member text as untrusted", async () => {
const { tools } = setup();
const out = await call(tools, "read_channel", { channel: "#General" });
const text = out.result.content[0].text;
assert.ok(text.startsWith(UNTRUSTED_NOTE));
assert.match(text, /2026-11-03 10:15 Ingy \[m1\]: voice keeps cutting out/);
});

it("sends into a thread when given one", async () => {
const { tools, sent } = setup();
const out = await call(tools, "send_message", { channel: "general", text: "on it", thread_id: "t1" });
assert.equal(out.result.content[0].text, "Posted in #general [m2].");
assert.deepEqual(sent, [["c1", "on it", { replyTo: undefined, threadId: "t1" }]]);
});

it("tells the model what to do when the bot is still waiting at the door", async () => {
const { tools } = setup("waiting");
const out = await call(tools, "list_channels", {});
assert.equal(out.result.isError, true);
assert.match(out.result.content[0].text, /Server settings → Bots/);
});

it("names the known servers for an unknown one", async () => {
const { tools } = setup();
const out = await call(tools, "list_channels", { server: "elsewhere" });
assert.match(out.result.content[0].text, /Known: pingy/);
});
});
86 changes: 86 additions & 0 deletions src/mcp/protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { createInterface } from "node:readline";

/** The spec version this was written against. A client asking for another gets this one. */
const PROTOCOL_VERSION = "2025-06-18";

export interface Tool {
name: string;
description: string;
inputSchema: { type: "object"; properties: Record<string, unknown>; required?: string[] };
/** Clients that honour it can allowlist the read tools and ask before the rest. */
readOnly: boolean;
run(args: Record<string, unknown>): Promise<string>;
}

interface Request {
jsonrpc: "2.0";
id?: string | number | null;
method: string;
params?: Record<string, unknown>;
}

export interface ServerMeta {
name: string;
version: string;
instructions: string;
}

/** One JSON-RPC message in, at most one out. Null for a notification, which gets no answer. */
export async function handle(raw: string, tools: Tool[], meta: ServerMeta): Promise<object | null> {
let req: Request;
try {
req = JSON.parse(raw) as Request;
} catch {
return { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } };
}
if (req.id === undefined || req.id === null) return null;
const ok = (result: object) => ({ jsonrpc: "2.0", id: req.id, result });
const fail = (code: number, message: string) => ({ jsonrpc: "2.0", id: req.id, error: { code, message } });

switch (req.method) {
case "initialize":
return ok({
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: meta.name, version: meta.version },
instructions: meta.instructions,
});
case "ping":
return ok({});
case "tools/list":
return ok({
tools: tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
annotations: { readOnlyHint: t.readOnly, destructiveHint: !t.readOnly && t.name.startsWith("delete") },
})),
});
case "tools/call": {
const name = req.params?.name;
const tool = tools.find((t) => t.name === name);
if (!tool) return fail(-32602, `Unknown tool: ${String(name)}`);
try {
const text = await tool.run((req.params?.arguments as Record<string, unknown>) ?? {});
return ok({ content: [{ type: "text", text }] });
} catch (err) {
// A tool failing is an answer the model should read, not a protocol error.
return ok({ content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], isError: true });
}
}
default:
return fail(-32601, `Method not found: ${req.method}`);
}
}

/** Newline-delimited JSON on stdin and stdout. Anything else on stdout breaks the client. */
export function serveStdio(tools: Tool[], meta: ServerMeta): void {
const lines = createInterface({ input: process.stdin });
lines.on("line", (line) => {
if (!line.trim()) return;
void handle(line, tools, meta).then((out) => {
if (out) process.stdout.write(`${JSON.stringify(out)}\n`);
});
});
lines.on("close", () => process.exit(0));
}
Loading
Loading