feat(ts-packages): 6 TS packages — MCP server + 5 external-API adapters

Total 1465 LOC + 616 test LOC, 78/78 tests pass.
- @keisei/mcp-server (25 tests) — Rust-CLI bridge via execa, stdio+HTTP, HMAC auth, kei() meta-tool
- @keisei/telegram-adapter (16 tests) — grammy Bot, 7 tools
- @keisei/recall-adapter (8 tests) — Zoom via Recall.ai, 5 tools
- @keisei/grok-adapter (6 tests) — xAI OpenAI-compatible, 2 tools
- @keisei/gmail-adapter (11 tests) — googleapis OAuth2, 6 tools (new — LBM gap)
- @keisei/youtube-adapter (12 tests) — YouTube Data API v3, 5 tools (new — LBM gap)

RULE 0.2 exception #4 (TS for MCP/API layer documented in _ts_packages/README.md).
RULE 0.8 — env vars only (TELEGRAM_BOT_TOKEN, XAI_API_KEY, GMAIL_*, YOUTUBE_API_KEY).
Strict TypeScript: strict + exactOptionalPropertyTypes + noUncheckedIndexedAccess.
Genesis-scan clean (0 hits).
This commit is contained in:
Parfii-bot 2026-04-22 12:45:19 +08:00
parent cab78d68f7
commit c21943e40b
66 changed files with 6518 additions and 0 deletions

5
_ts_packages/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
*.tsbuildinfo
.DS_Store
coverage/

111
_ts_packages/README.md Normal file
View file

@ -0,0 +1,111 @@
# KeiSeiKit TypeScript Packages
> v0.14.0 part B: MCP server layer + external-API adapters.
## RULE 0.2 exception
TypeScript is chosen here under **RULE 0.2 exception #4 (Browser/DOM adjacent)** because:
1. The official Model Context Protocol SDK is TypeScript-native; Rust MCP
libraries are immature (as of 2026-04).
2. The API adapters rely on JS-native SDKs with no Rust equivalents:
- `grammy` (type-safe Telegram bot)
- `googleapis` (official Google API SDK for Gmail + YouTube)
- `youtube-transcript` (Tier-1 free transcript extractor)
3. Async, JSON-heavy glue code is TypeScript's sweet spot.
**Core primitives (signing, ledger, graph, memory, refactor, etc.) remain
Rust** in `../_primitives/_rust/`. This TS layer is a THIN wrapper: it
spawns the Rust CLIs as subprocesses and exposes them as MCP tools, plus
the six adapters above that have no Rust equivalent.
## Layout
```
_ts_packages/
├── package.json npm workspace root
├── tsconfig.base.json strict TS 5.x
└── packages/
├── mcp-server/ @keisei/mcp-server
├── telegram-adapter/ @keisei/telegram-adapter
├── recall-adapter/ @keisei/recall-adapter (Zoom via Recall.ai)
├── grok-adapter/ @keisei/grok-adapter (xAI)
├── gmail-adapter/ @keisei/gmail-adapter
└── youtube-adapter/ @keisei/youtube-adapter
```
## Install (for end users)
### 1. Install workspace deps
```bash
cd _ts_packages
npm install
npm run build
```
### 2. Link each package as a global CLI (optional)
```bash
npm i -g ./packages/mcp-server
npm i -g ./packages/telegram-adapter
# ... etc
```
Or install into a Claude agent directory:
```bash
npm i --prefix ~/.claude/agents/_ts_packages/packages/mcp-server \
./_ts_packages/packages/mcp-server
```
## Environment variables (RULE 0.8 — secrets in `~/.claude/secrets/.env`)
| Var | Package | Purpose |
|---|---|---|
| `TELEGRAM_BOT_TOKEN` | telegram-adapter | Bot API token |
| `RECALL_API_KEY` | recall-adapter | Recall.ai API key (Zoom meetings) |
| `XAI_API_KEY` | grok-adapter | xAI Grok API key |
| `GMAIL_CLIENT_ID` | gmail-adapter | Google OAuth2 client id |
| `GMAIL_CLIENT_SECRET` | gmail-adapter | Google OAuth2 client secret |
| `GMAIL_REFRESH_TOKEN` | gmail-adapter | Long-lived OAuth2 refresh token |
| `YOUTUBE_API_KEY` | youtube-adapter | YouTube Data API v3 key |
| `KEI_MCP_AUTH_TOKEN` | mcp-server | HMAC token for tool callers |
| `KEI_RUST_BIN_DIR` | mcp-server | Override directory holding Rust primitive CLIs |
All are read via `process.env`. Hardcoding tokens is **forbidden** (RULE 0.8).
## MCP server integration
The `@keisei/mcp-server` exposes the Rust primitive CLIs as MCP tools. The
pattern is one Rust binary = one MCP tool, with the `kei` meta-tool on
top that routes natural-language queries via `kei-router`.
Stdio mode (for Claude Code native integration):
```bash
npx @keisei/mcp-server --stdio
```
HTTP mode:
```bash
npx @keisei/mcp-server --port 3000 --auth-token-file ~/.claude/mcp-token
```
## Verification
```bash
npm install
npm run build --workspaces
npm run test --workspaces
```
All six packages compile under `strict: true`. Total new LOC: see commit.
## Migration notes
- Zero impact on existing KeiSeiKit users unless they opt into the MCP
server (planned v0.14.1 installer flag `--enable-mcp`).
- The Rust primitives are unchanged; this layer only **wraps** them.
- Gmail and YouTube adapters are **new** (gaps in LBM).

3776
_ts_packages/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
_ts_packages/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "@keisei/ts-packages",
"private": true,
"version": "0.14.0",
"description": "KeiSeiKit TypeScript layer — MCP server and external-API adapters",
"type": "module",
"workspaces": [
"packages/*"
],
"scripts": {
"build": "npm run build --workspaces --if-present",
"test": "npm run test --workspaces --if-present",
"lint": "npm run lint --workspaces --if-present",
"clean": "rm -rf packages/*/dist packages/*/*.tsbuildinfo"
},
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"typescript": "^5.5.0",
"vitest": "^2.0.0",
"tsx": "^4.16.0"
}
}

View file

@ -0,0 +1,33 @@
{
"name": "@keisei/gmail-adapter",
"version": "0.14.0",
"description": "Gmail API adapter for the KeiSei MCP server",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -b",
"test": "vitest run"
},
"dependencies": {
"googleapis": "^144.0.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}

View file

@ -0,0 +1,101 @@
// Gmail API client via googleapis. One class that owns an OAuth2 client
// plus a gmail.users surface. All methods return plain data (no gapi types
// leak outward). Tests inject a mock surface via the `gmailSurface` param.
import { google } from "googleapis";
import type { MessageSummary } from "./types.js";
export interface GmailClientConfig {
clientId: string;
clientSecret: string;
refreshToken: string;
gmailSurface?: GmailSurface;
}
// Narrow shape we actually use. Everything googleapis exposes is optional.
export interface GmailSurface {
list: (q: string | undefined, max: number) => Promise<Array<{ id?: string | null; threadId?: string | null }>>;
get: (id: string) => Promise<{ id?: string | null; threadId?: string | null; snippet?: string | null; payload?: { headers?: Array<{ name?: string | null; value?: string | null }> | null } | null }>;
modify: (id: string, addIds: string[], removeIds: string[]) => Promise<void>;
trash: (id: string) => Promise<void>;
}
export class GmailClient {
private readonly surface: GmailSurface;
constructor(cfg: GmailClientConfig) {
this.surface = cfg.gmailSurface ?? buildDefaultSurface(cfg);
}
async listUnread(max: number): Promise<MessageSummary[]> {
const ids = await this.surface.list("is:unread", max);
return Promise.all(ids.map(async (row) => this.summarize(row.id ?? "")));
}
async search(query: string, max: number): Promise<MessageSummary[]> {
const ids = await this.surface.list(query, max);
return Promise.all(ids.map(async (row) => this.summarize(row.id ?? "")));
}
async getMessage(id: string): Promise<MessageSummary> {
return this.summarize(id);
}
async labelMessage(id: string, label: string): Promise<void> {
await this.surface.modify(id, [label], []);
}
async archive(id: string): Promise<void> {
await this.surface.modify(id, [], ["INBOX"]);
}
async trash(id: string): Promise<void> {
await this.surface.trash(id);
}
private async summarize(id: string): Promise<MessageSummary> {
if (!id) return { id: "" };
const msg = await this.surface.get(id);
const headers = msg.payload?.headers ?? [];
const pick = (name: string): string | undefined => headers.find((h) => h.name?.toLowerCase() === name)?.value ?? undefined;
return {
id: msg.id ?? id,
threadId: msg.threadId ?? undefined,
subject: pick("subject"),
from: pick("from"),
date: pick("date"),
snippet: msg.snippet ?? undefined,
};
}
}
function buildDefaultSurface(cfg: GmailClientConfig): GmailSurface {
if (!cfg.clientId || !cfg.clientSecret || !cfg.refreshToken) {
throw new Error("GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN all required");
}
const oauth = new google.auth.OAuth2(cfg.clientId, cfg.clientSecret);
oauth.setCredentials({ refresh_token: cfg.refreshToken });
const gmail = google.gmail({ version: "v1", auth: oauth });
return {
list: async (q, max) => {
const params: { userId: string; maxResults: number; q?: string } = { userId: "me", maxResults: max };
if (q !== undefined) params.q = q;
const res = await gmail.users.messages.list(params);
const items = res.data.messages ?? [];
return items.map((m: { id?: string | null; threadId?: string | null }) => ({
id: m.id ?? null,
threadId: m.threadId ?? null,
}));
},
get: async (id) => {
const res = await gmail.users.messages.get({ userId: "me", id, format: "metadata" });
return res.data;
},
modify: async (id, addIds, removeIds) => {
await gmail.users.messages.modify({ userId: "me", id, requestBody: { addLabelIds: addIds, removeLabelIds: removeIds } });
},
trash: async (id) => {
await gmail.users.messages.trash({ userId: "me", id });
},
};
}

View file

@ -0,0 +1,22 @@
import { GmailClient } from "./client.js";
import { buildGmailTools, type GmailTool } from "./tools.js";
export { GmailClient } from "./client.js";
export { buildGmailTools } from "./tools.js";
export type { GmailTool } from "./tools.js";
export * from "./types.js";
type Registrar = (tool: GmailTool) => void;
export function registerAdapter(register: Registrar): void {
const clientId = process.env["GMAIL_CLIENT_ID"];
const clientSecret = process.env["GMAIL_CLIENT_SECRET"];
const refreshToken = process.env["GMAIL_REFRESH_TOKEN"];
if (!clientId || !clientSecret || !refreshToken) {
throw new Error(
"GMAIL_{CLIENT_ID,CLIENT_SECRET,REFRESH_TOKEN} env vars required; see ~/.claude/secrets/.env (RULE 0.8).",
);
}
const client = new GmailClient({ clientId, clientSecret, refreshToken });
for (const tool of buildGmailTools(client)) register(tool);
}

View file

@ -0,0 +1,93 @@
import { z } from "zod";
import { GmailClient } from "./client.js";
import {
GetMessageArgs,
LabelArgs,
ListUnreadArgs,
ModifyOnlyArgs,
SearchArgs,
type MessageSummary,
} from "./types.js";
export interface GmailTool {
name: string;
description: string;
inputSchema: z.ZodObject<Record<string, z.ZodTypeAny>>;
handler: (args: Record<string, unknown>) => Promise<string>;
}
export function buildGmailTools(client: GmailClient): GmailTool[] {
return [
{
name: "gmail_list_unread",
description: "List unread messages (up to 500).",
inputSchema: ListUnreadArgs,
handler: async (raw) => {
const args = ListUnreadArgs.parse(raw);
return formatList(await client.listUnread(args.max));
},
},
{
name: "gmail_get_message",
description: "Fetch one message by id; returns headers + snippet.",
inputSchema: GetMessageArgs,
handler: async (raw) => {
const args = GetMessageArgs.parse(raw);
return formatOne(await client.getMessage(args.id));
},
},
{
name: "gmail_search",
description: "Search mailbox using Gmail operators (e.g. 'from:alice has:attachment').",
inputSchema: SearchArgs,
handler: async (raw) => {
const args = SearchArgs.parse(raw);
return formatList(await client.search(args.query, args.max));
},
},
{
name: "gmail_label_message",
description: "Apply a Gmail label id to a message.",
inputSchema: LabelArgs,
handler: async (raw) => {
const args = LabelArgs.parse(raw);
await client.labelMessage(args.id, args.label);
return `labeled ${args.id} with ${args.label}`;
},
},
{
name: "gmail_archive",
description: "Archive a message (removes INBOX label).",
inputSchema: ModifyOnlyArgs,
handler: async (raw) => {
const args = ModifyOnlyArgs.parse(raw);
await client.archive(args.id);
return `archived ${args.id}`;
},
},
{
name: "gmail_trash",
description: "Move a message to Trash.",
inputSchema: ModifyOnlyArgs,
handler: async (raw) => {
const args = ModifyOnlyArgs.parse(raw);
await client.trash(args.id);
return `trashed ${args.id}`;
},
},
];
}
function formatList(msgs: MessageSummary[]): string {
if (msgs.length === 0) return "No messages.";
return msgs.map(formatOne).join("\n---\n");
}
function formatOne(m: MessageSummary): string {
const parts = [`id: ${m.id}`];
if (m.subject) parts.push(`subject: ${m.subject}`);
if (m.from) parts.push(`from: ${m.from}`);
if (m.date) parts.push(`date: ${m.date}`);
if (m.snippet) parts.push(`snippet: ${m.snippet}`);
return parts.join("\n");
}

View file

@ -0,0 +1,40 @@
// Gmail API tool I/O types. Types live in their own file so tests can
// exercise schemas without importing googleapis.
import { z } from "zod";
export const ListUnreadArgs = z.object({
max: z.number().int().positive().max(500).default(20),
});
export type ListUnreadArgs = z.infer<typeof ListUnreadArgs>;
export const GetMessageArgs = z.object({
id: z.string().min(1),
});
export type GetMessageArgs = z.infer<typeof GetMessageArgs>;
export const SearchArgs = z.object({
query: z.string().min(1),
max: z.number().int().positive().max(500).default(20),
});
export type SearchArgs = z.infer<typeof SearchArgs>;
export const LabelArgs = z.object({
id: z.string().min(1),
label: z.string().min(1),
});
export type LabelArgs = z.infer<typeof LabelArgs>;
export const ModifyOnlyArgs = z.object({
id: z.string().min(1),
});
export type ModifyOnlyArgs = z.infer<typeof ModifyOnlyArgs>;
export interface MessageSummary {
id: string;
threadId?: string | undefined;
subject?: string | undefined;
from?: string | undefined;
snippet?: string | undefined;
date?: string | undefined;
}

View file

@ -0,0 +1,44 @@
import { describe, it, expect, vi } from "vitest";
import { GmailClient, type GmailSurface } from "../src/client.js";
function makeSurface(): GmailSurface {
return {
list: vi.fn(async () => [{ id: "m1", threadId: "t1" }, { id: "m2" }]),
get: vi.fn(async (id: string) => ({
id,
snippet: `snip-${id}`,
payload: {
headers: [
{ name: "Subject", value: `subj-${id}` },
{ name: "From", value: "alice@example.com" },
],
},
})),
modify: vi.fn(async () => undefined),
trash: vi.fn(async () => undefined),
};
}
describe("GmailClient", () => {
it("listUnread returns summarized messages", async () => {
const surface = makeSurface();
const c = new GmailClient({ clientId: "", clientSecret: "", refreshToken: "", gmailSurface: surface });
const out = await c.listUnread(10);
expect(out).toHaveLength(2);
expect(out[0]?.subject).toBe("subj-m1");
});
it("labelMessage calls modify with addIds only", async () => {
const surface = makeSurface();
const c = new GmailClient({ clientId: "", clientSecret: "", refreshToken: "", gmailSurface: surface });
await c.labelMessage("m1", "IMPORTANT");
expect(surface.modify).toHaveBeenCalledWith("m1", ["IMPORTANT"], []);
});
it("archive removes INBOX label", async () => {
const surface = makeSurface();
const c = new GmailClient({ clientId: "", clientSecret: "", refreshToken: "", gmailSurface: surface });
await c.archive("m1");
expect(surface.modify).toHaveBeenCalledWith("m1", [], ["INBOX"]);
});
});

View file

@ -0,0 +1,43 @@
import { describe, it, expect, vi } from "vitest";
import { GmailClient, type GmailSurface } from "../src/client.js";
import { buildGmailTools } from "../src/tools.js";
function mkSurface(): GmailSurface {
return {
list: vi.fn(async () => [{ id: "m1" }]),
get: vi.fn(async () => ({ id: "m1", snippet: "hello", payload: { headers: [] } })),
modify: vi.fn(async () => undefined),
trash: vi.fn(async () => undefined),
};
}
describe("gmail tool surface", () => {
it("registers 6 tools", () => {
const c = new GmailClient({ clientId: "", clientSecret: "", refreshToken: "", gmailSurface: mkSurface() });
const names = buildGmailTools(c).map((t) => t.name);
expect(names).toEqual([
"gmail_list_unread",
"gmail_get_message",
"gmail_search",
"gmail_label_message",
"gmail_archive",
"gmail_trash",
]);
});
it("gmail_list_unread formats empty list", async () => {
const surface: GmailSurface = { ...mkSurface(), list: vi.fn(async () => []) };
const c = new GmailClient({ clientId: "", clientSecret: "", refreshToken: "", gmailSurface: surface });
const tool = buildGmailTools(c).find((t) => t.name === "gmail_list_unread");
const out = await tool!.handler({});
expect(out).toBe("No messages.");
});
it("gmail_trash returns ok string", async () => {
const surface = mkSurface();
const c = new GmailClient({ clientId: "", clientSecret: "", refreshToken: "", gmailSurface: surface });
const tool = buildGmailTools(c).find((t) => t.name === "gmail_trash");
const out = await tool!.handler({ id: "m1" });
expect(out).toContain("trashed m1");
});
});

View file

@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest";
import { ListUnreadArgs, SearchArgs, LabelArgs, GetMessageArgs } from "../src/types.js";
describe("gmail schemas", () => {
it("ListUnreadArgs defaults max to 20", () => {
const r = ListUnreadArgs.safeParse({});
expect(r.success).toBe(true);
if (r.success) expect(r.data.max).toBe(20);
});
it("ListUnreadArgs rejects max=0", () => {
const r = ListUnreadArgs.safeParse({ max: 0 });
expect(r.success).toBe(false);
});
it("SearchArgs rejects empty query", () => {
const r = SearchArgs.safeParse({ query: "" });
expect(r.success).toBe(false);
});
it("LabelArgs requires both id and label", () => {
expect(LabelArgs.safeParse({ id: "x" }).success).toBe(false);
expect(LabelArgs.safeParse({ id: "x", label: "L" }).success).toBe(true);
});
it("GetMessageArgs requires non-empty id", () => {
expect(GetMessageArgs.safeParse({ id: "" }).success).toBe(false);
});
});

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules", "test/**/*"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
environment: "node",
},
});

View file

@ -0,0 +1,32 @@
{
"name": "@keisei/grok-adapter",
"version": "0.14.0",
"description": "xAI Grok adapter (deep research + image gen) for the KeiSei MCP server",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -b",
"test": "vitest run"
},
"dependencies": {
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}

View file

@ -0,0 +1,78 @@
// Minimal xAI Grok client. The public endpoints follow the OpenAI
// compatible shape: https://api.x.ai/v1/chat/completions and /images.
// Shapes verified against https://docs.x.ai/api (2026-04).
export type FetchFn = typeof fetch;
export interface GrokClientConfig {
apiKey: string;
baseUrl?: string;
fetchImpl?: FetchFn;
researchModel?: string;
imageModel?: string;
}
const DEFAULT_BASE = "https://api.x.ai/v1";
export class GrokClient {
private readonly apiKey: string;
private readonly baseUrl: string;
private readonly fetchImpl: FetchFn;
private readonly researchModel: string;
private readonly imageModel: string;
constructor(cfg: GrokClientConfig) {
if (!cfg.apiKey) throw new Error("XAI_API_KEY is required");
this.apiKey = cfg.apiKey;
this.baseUrl = cfg.baseUrl ?? DEFAULT_BASE;
this.fetchImpl = cfg.fetchImpl ?? fetch;
this.researchModel = cfg.researchModel ?? "grok-4-heavy";
this.imageModel = cfg.imageModel ?? "grok-2-image";
}
async deepResearch(query: string): Promise<string> {
const body = {
model: this.researchModel,
messages: [{ role: "user", content: query }],
};
const data = (await this.postJson("/chat/completions", body)) as ChatCompletionResponse;
const first = data.choices[0];
return first?.message?.content ?? "";
}
async imageGenerate(prompt: string, pro = false): Promise<string[]> {
const body = {
model: this.imageModel,
prompt,
n: 1,
quality: pro ? "pro" : "standard",
};
const data = (await this.postJson("/images/generations", body)) as ImageResponse;
return (data.data ?? []).map((d) => d.url).filter((u): u is string => typeof u === "string");
}
private async postJson(path: string, body: unknown): Promise<unknown> {
const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`grok ${path} -> ${res.status}: ${text}`);
}
return res.json();
}
}
interface ChatCompletionResponse {
choices: Array<{ message: { content: string } }>;
}
interface ImageResponse {
data: Array<{ url?: string }>;
}

View file

@ -0,0 +1,19 @@
import { GrokClient } from "./client.js";
import { buildGrokTools, type GrokTool } from "./tools.js";
export { GrokClient } from "./client.js";
export { buildGrokTools } from "./tools.js";
export type { GrokTool } from "./tools.js";
type Registrar = (tool: GrokTool) => void;
export function registerAdapter(register: Registrar): void {
const apiKey = process.env["XAI_API_KEY"];
if (!apiKey) {
throw new Error(
"XAI_API_KEY env var is missing; set it in ~/.claude/secrets/.env (RULE 0.8).",
);
}
const client = new GrokClient({ apiKey });
for (const tool of buildGrokTools(client)) register(tool);
}

View file

@ -0,0 +1,40 @@
import { z } from "zod";
import { GrokClient } from "./client.js";
export interface GrokTool {
name: string;
description: string;
inputSchema: z.ZodObject<Record<string, z.ZodTypeAny>>;
handler: (args: Record<string, unknown>) => Promise<string>;
}
const ResearchArgs = z.object({ query: z.string().min(1) });
const ImagineArgs = z.object({
prompt: z.string().min(1),
quality: z.enum(["standard", "pro"]).default("standard"),
});
export function buildGrokTools(client: GrokClient): GrokTool[] {
return [
{
name: "grok_research",
description: "Deep research via Grok heavy model. Returns assistant message content.",
inputSchema: ResearchArgs,
handler: async (raw) => {
const args = ResearchArgs.parse(raw);
return client.deepResearch(args.query);
},
},
{
name: "grok_imagine",
description: "Generate an image from a prompt via Grok Imagine.",
inputSchema: ImagineArgs,
handler: async (raw) => {
const args = ImagineArgs.parse(raw);
const urls = await client.imageGenerate(args.prompt, args.quality === "pro");
if (urls.length === 0) return "No image returned.";
return urls.join("\n");
},
},
];
}

View file

@ -0,0 +1,31 @@
import { describe, it, expect, vi } from "vitest";
import { GrokClient } from "../src/client.js";
function makeFetchMock(payload: unknown, ok = true, status = 200): typeof fetch {
return vi.fn(async () => ({
ok,
status,
async text() { return JSON.stringify(payload); },
async json() { return payload; },
} as unknown as Response)) as unknown as typeof fetch;
}
describe("GrokClient", () => {
it("rejects empty API key", () => {
expect(() => new GrokClient({ apiKey: "" })).toThrow(/XAI_API_KEY/);
});
it("deepResearch returns assistant content", async () => {
const fetchImpl = makeFetchMock({ choices: [{ message: { content: "hello" } }] });
const c = new GrokClient({ apiKey: "k", fetchImpl });
const out = await c.deepResearch("q");
expect(out).toBe("hello");
});
it("imageGenerate extracts URLs from response", async () => {
const fetchImpl = makeFetchMock({ data: [{ url: "https://x/img.png" }] });
const c = new GrokClient({ apiKey: "k", fetchImpl });
const urls = await c.imageGenerate("a cat", true);
expect(urls).toEqual(["https://x/img.png"]);
});
});

View file

@ -0,0 +1,36 @@
import { describe, it, expect, vi } from "vitest";
import { GrokClient } from "../src/client.js";
import { buildGrokTools } from "../src/tools.js";
function makeFetchMock(payload: unknown): typeof fetch {
return vi.fn(async () => ({
ok: true,
status: 200,
async text() { return JSON.stringify(payload); },
async json() { return payload; },
} as unknown as Response)) as unknown as typeof fetch;
}
describe("grok tools", () => {
it("exposes 2 tools", () => {
const c = new GrokClient({ apiKey: "k", fetchImpl: makeFetchMock({}) });
const tools = buildGrokTools(c);
expect(tools.map((t) => t.name)).toEqual(["grok_research", "grok_imagine"]);
});
it("grok_research validates non-empty query", async () => {
const c = new GrokClient({ apiKey: "k", fetchImpl: makeFetchMock({}) });
const tool = buildGrokTools(c).find((t) => t.name === "grok_research");
await expect(tool!.handler({ query: "" })).rejects.toBeTruthy();
});
it("grok_imagine defaults quality to standard", async () => {
const c = new GrokClient({
apiKey: "k",
fetchImpl: makeFetchMock({ data: [{ url: "u" }] }),
});
const tool = buildGrokTools(c).find((t) => t.name === "grok_imagine");
const out = await tool!.handler({ prompt: "x" });
expect(out).toContain("u");
});
});

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules", "test/**/*"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
environment: "node",
},
});

View file

@ -0,0 +1,38 @@
{
"name": "@keisei/mcp-server",
"version": "0.14.0",
"description": "MCP server exposing KeiSeiKit Rust primitives as Model Context Protocol tools",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"keisei-mcp-server": "./dist/index.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -b",
"test": "vitest run",
"dev": "tsx src/index.ts --stdio"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"execa": "^9.0.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}

View file

@ -0,0 +1,53 @@
// Register external API adapters (Telegram, Recall, Grok, Gmail, YouTube)
// dynamically IF the sibling packages are installed in the runtime. Each
// adapter exports `registerAdapter(register)` by convention.
import type { ToolDefinition } from "./tool-registry.js";
export type AdapterRegistrar = (tool: ToolDefinition) => void;
interface AdapterModule {
registerAdapter: (register: AdapterRegistrar) => void;
}
const ADAPTER_PACKAGES: readonly string[] = [
"@keisei/telegram-adapter",
"@keisei/recall-adapter",
"@keisei/grok-adapter",
"@keisei/gmail-adapter",
"@keisei/youtube-adapter",
];
export async function loadAllAdapters(
register: AdapterRegistrar,
logger: (msg: string) => void = () => {},
): Promise<{ loaded: string[]; skipped: string[] }> {
const loaded: string[] = [];
const skipped: string[] = [];
for (const pkg of ADAPTER_PACKAGES) {
const ok = await tryLoadOne(pkg, register, logger);
if (ok) loaded.push(pkg);
else skipped.push(pkg);
}
return { loaded, skipped };
}
async function tryLoadOne(
pkg: string,
register: AdapterRegistrar,
logger: (msg: string) => void,
): Promise<boolean> {
try {
const mod = (await import(pkg)) as AdapterModule;
if (typeof mod.registerAdapter !== "function") {
logger(`adapter ${pkg}: missing registerAdapter()`);
return false;
}
mod.registerAdapter(register);
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger(`adapter ${pkg}: not installed (${msg})`);
return false;
}
}

View file

@ -0,0 +1,61 @@
// Typed error hierarchy for MCP server. One class per failure mode.
// Keeps the main handler branches flat and the JSON-RPC error codes consistent.
export class McpServerError extends Error {
public readonly code: number;
public readonly data: unknown;
constructor(message: string, code: number, data?: unknown) {
super(message);
this.name = new.target.name;
this.code = code;
this.data = data;
}
}
export class AuthError extends McpServerError {
constructor(message = "unauthorized", data?: unknown) {
super(message, -32001, data);
}
}
export class ToolNotFoundError extends McpServerError {
constructor(toolName: string) {
super(`tool not found: ${toolName}`, -32601, { tool: toolName });
}
}
export class RustBridgeError extends McpServerError {
constructor(message: string, data?: unknown) {
super(`rust bridge: ${message}`, -32002, data);
}
}
export class SchemaError extends McpServerError {
constructor(message: string, data?: unknown) {
super(`schema: ${message}`, -32602, data);
}
}
export class TimeoutError extends McpServerError {
constructor(toolName: string, ms: number) {
super(`tool ${toolName} timed out after ${ms}ms`, -32003, { tool: toolName, ms });
}
}
export function isMcpError(err: unknown): err is McpServerError {
return err instanceof McpServerError;
}
export function toErrorPayload(err: unknown): { code: number; message: string; data?: unknown } {
if (isMcpError(err)) {
const payload: { code: number; message: string; data?: unknown } = {
code: err.code,
message: err.message,
};
if (err.data !== undefined) payload.data = err.data;
return payload;
}
const message = err instanceof Error ? err.message : String(err);
return { code: -32000, message };
}

View file

@ -0,0 +1,123 @@
#!/usr/bin/env node
// Entry point: parse argv, select transport (stdio or HTTP), start McpServer.
import fs from "node:fs/promises";
import path from "node:path";
import { McpServer } from "./server.js";
interface CliArgs {
stdio: boolean;
port?: number;
authTokenFile?: string;
rustBinDir: string;
}
function parseArgv(argv: readonly string[]): CliArgs {
const out: CliArgs = {
stdio: false,
rustBinDir: process.env["KEI_RUST_BIN_DIR"] ?? defaultBinDir(),
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--stdio") out.stdio = true;
else if (a === "--port") out.port = Number(argv[++i] ?? "");
else if (a === "--auth-token-file") {
const v = argv[++i];
if (v !== undefined) out.authTokenFile = v;
} else if (a === "--rust-bin-dir") {
const v = argv[++i];
if (v !== undefined) out.rustBinDir = v;
}
}
return out;
}
function defaultBinDir(): string {
const home = process.env["HOME"] ?? "";
return path.join(home, ".claude", "agents", "_primitives", "_rust", "target", "release");
}
async function readTokenFile(p: string | undefined): Promise<string | undefined> {
if (!p) return process.env["KEI_MCP_AUTH_TOKEN"];
const raw = await fs.readFile(p, "utf8");
return raw.trim();
}
async function main(): Promise<void> {
const args = parseArgv(process.argv.slice(2));
const token = args.stdio ? undefined : await readTokenFile(args.authTokenFile);
const server = new McpServer({
rustBinDir: args.rustBinDir,
...(token !== undefined ? { authToken: token } : {}),
});
await server.loadAdapters((m) => process.stderr.write(`[adapters] ${m}\n`));
if (args.stdio) await runStdio(server);
else await runHttp(server, args.port ?? 3000);
}
async function runStdio(server: McpServer): Promise<void> {
process.stderr.write(`[keisei-mcp] stdio mode; ${server.listTools().length} tools\n`);
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) {
for (const line of String(chunk).split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const resp = await dispatchStdioLine(server, trimmed);
process.stdout.write(resp + "\n");
}
}
}
async function dispatchStdioLine(server: McpServer, line: string): Promise<string> {
try {
const payload = JSON.parse(line) as { tool: string; args?: Record<string, unknown> };
const call = payload.args !== undefined
? { tool: payload.tool, args: payload.args }
: { tool: payload.tool };
const resp = await server.handle(call);
return JSON.stringify(resp);
} catch (err) {
return JSON.stringify({ ok: false, error: { code: -32700, message: String(err) } });
}
}
async function runHttp(server: McpServer, port: number): Promise<void> {
const http = await import("node:http");
const srv = http.createServer((req, res) => void handleHttp(server, req, res));
srv.listen(port, () =>
process.stderr.write(`[keisei-mcp] http :${port}; ${server.listTools().length} tools\n`),
);
}
async function handleHttp(server: McpServer, req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse): Promise<void> {
if (req.method !== "POST") {
res.writeHead(405);
res.end();
return;
}
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
try {
const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as {
tool: string;
args?: Record<string, unknown>;
};
const authHeader = req.headers["authorization"];
const header = typeof authHeader === "string" ? authHeader.replace(/^Bearer\s+/i, "") : undefined;
const resp = await server.handle({
tool: body.tool,
...(body.args !== undefined ? { args: body.args } : {}),
...(header !== undefined ? { authHeader: header } : {}),
});
res.writeHead(resp.ok ? 200 : 400, { "content-type": "application/json" });
res.end(JSON.stringify(resp));
} catch (err) {
res.writeHead(400, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: false, error: { code: -32700, message: String(err) } }));
}
}
main().catch((err: unknown) => {
process.stderr.write(`[keisei-mcp] fatal: ${String(err)}\n`);
process.exit(1);
});

View file

@ -0,0 +1,83 @@
// Bridge layer: spawn Rust primitive CLIs and marshal JSON args <-> CLI flags.
// One Rust binary = one MCP tool. Subprocess lifecycle is isolated per call.
import { execa } from "execa";
import path from "node:path";
import { RustBridgeError, TimeoutError } from "./errors.js";
const DEFAULT_TIMEOUT_MS = 30_000;
export interface RustCallRequest {
binary: string;
args: readonly string[];
stdin?: string;
timeoutMs?: number;
}
export interface RustCallResult {
stdout: string;
stderr: string;
exitCode: number;
}
export interface RustBridgeConfig {
binDir: string;
defaultTimeoutMs?: number;
}
export class RustBridge {
private readonly binDir: string;
private readonly defaultTimeoutMs: number;
constructor(cfg: RustBridgeConfig) {
this.binDir = cfg.binDir;
this.defaultTimeoutMs = cfg.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
}
async call(req: RustCallRequest): Promise<RustCallResult> {
const binPath = this.resolveBin(req.binary);
const timeoutMs = req.timeoutMs ?? this.defaultTimeoutMs;
try {
const opts = {
timeout: timeoutMs,
reject: false as const,
env: process.env,
...(req.stdin !== undefined ? { input: req.stdin } : {}),
};
const child = execa(binPath, [...req.args], opts);
const result = await child;
if (result.timedOut) throw new TimeoutError(req.binary, timeoutMs);
return {
stdout: typeof result.stdout === "string" ? result.stdout : "",
stderr: typeof result.stderr === "string" ? result.stderr : "",
exitCode: result.exitCode ?? -1,
};
} catch (err) {
if (err instanceof TimeoutError) throw err;
const msg = err instanceof Error ? err.message : String(err);
throw new RustBridgeError(msg, { binary: req.binary });
}
}
private resolveBin(binary: string): string {
if (!/^[a-z0-9][a-z0-9_-]*$/i.test(binary)) {
throw new RustBridgeError(`invalid binary name: ${binary}`);
}
return path.join(this.binDir, binary);
}
}
// Convert a JSON object of named args to CLI flags: {foo_bar: "v"} => ["--foo-bar", "v"]
export function jsonArgsToCli(args: Record<string, unknown>): string[] {
const out: string[] = [];
for (const [key, raw] of Object.entries(args)) {
if (raw === undefined || raw === null) continue;
const flag = `--${key.replace(/_/g, "-")}`;
if (typeof raw === "boolean") {
if (raw) out.push(flag);
continue;
}
out.push(flag, String(raw));
}
return out;
}

View file

@ -0,0 +1,91 @@
// MCP server assembly: wire registry + adapters + auth into a JSON-RPC dispatcher.
// Transport-agnostic; index.ts chooses stdio or HTTP.
import crypto from "node:crypto";
import { z } from "zod";
import { buildRegistry, lookupTool, type ToolDefinition } from "./tool-registry.js";
import { RustBridge } from "./rust-bridge.js";
import { loadAllAdapters } from "./adapters.js";
import { AuthError, SchemaError, toErrorPayload } from "./errors.js";
export interface ServerConfig {
rustBinDir: string;
authToken?: string;
timeoutMs?: number;
}
export interface JsonRpcCall {
tool: string;
args?: Record<string, unknown>;
authHeader?: string | undefined;
}
export interface JsonRpcResponse {
ok: boolean;
result?: string;
error?: { code: number; message: string; data?: unknown };
}
export class McpServer {
private readonly registry: Map<string, ToolDefinition>;
private readonly authToken: string | undefined;
constructor(cfg: ServerConfig) {
const bridge = new RustBridge({
binDir: cfg.rustBinDir,
...(cfg.timeoutMs !== undefined ? { defaultTimeoutMs: cfg.timeoutMs } : {}),
});
this.registry = buildRegistry(bridge);
this.authToken = cfg.authToken;
}
async loadAdapters(logger?: (msg: string) => void): Promise<{ loaded: string[]; skipped: string[] }> {
return loadAllAdapters((tool) => this.registry.set(tool.name, tool), logger);
}
listTools(): Array<{ name: string; description: string }> {
return Array.from(this.registry.values()).map((t) => ({
name: t.name,
description: t.description,
}));
}
async handle(call: JsonRpcCall): Promise<JsonRpcResponse> {
try {
this.checkAuth(call.authHeader);
const tool = lookupTool(this.registry, call.tool);
const args = this.validateArgs(tool, call.args ?? {});
const out = await tool.handler(args);
return { ok: true, result: out };
} catch (err) {
return { ok: false, error: toErrorPayload(err) };
}
}
private checkAuth(header: string | undefined): void {
if (!this.authToken) return; // auth disabled (stdio mode)
if (!header) throw new AuthError("missing auth token");
if (!safeEqual(header, this.authToken)) throw new AuthError("invalid auth token");
}
private validateArgs(
tool: ToolDefinition,
raw: Record<string, unknown>,
): Record<string, unknown> {
const parsed = tool.inputSchema.safeParse(raw);
if (!parsed.success) {
throw new SchemaError(parsed.error.message, { tool: tool.name });
}
return parsed.data as Record<string, unknown>;
}
}
function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
if (ba.length !== bb.length) return false;
return crypto.timingSafeEqual(ba, bb);
}
// Exported for tests
export const __testing__ = { safeEqual, schema: z };

View file

@ -0,0 +1,88 @@
// Tool registry: auto-register each Rust primitive CLI as one MCP tool.
// Plus the meta-tool kei(query) that routes natural language via kei-router.
import { z } from "zod";
import { jsonArgsToCli, RustBridge } from "./rust-bridge.js";
import { ToolNotFoundError } from "./errors.js";
export interface ToolDefinition {
name: string;
description: string;
inputSchema: z.ZodObject<Record<string, z.ZodTypeAny>>;
handler: (args: Record<string, unknown>) => Promise<string>;
}
// Primitive CLIs exposed 1:1 as tools. Each Rust binary accepts flags as
// --kebab-case; tool names stay snake_case for MCP convention.
export const RUST_PRIMITIVE_TOOLS: ReadonlyArray<{ binary: string; desc: string }> = [
{ binary: "kei-ledger", desc: "Append-only event ledger; sign, verify, append, list." },
{ binary: "kei-memory", desc: "Local key-value memory store with SQLite backend." },
{ binary: "kei-store", desc: "Content-addressed blob store." },
{ binary: "kei-graph-check", desc: "Validate graph invariants in a project." },
{ binary: "kei-refactor-engine", desc: "Apply structural refactors from a plan file." },
{ binary: "kei-conflict-scan", desc: "Scan a tree for merge/rebase conflict markers." },
{ binary: "kei-migrate", desc: "Run schema or directory migrations." },
{ binary: "kei-changelog", desc: "Generate changelog from commit/tag history." },
{ binary: "genesis-scan", desc: "Scan a tree for Genesis/patent-sensitive patterns." },
{ binary: "ssh-check", desc: "Validate SSH config + known_hosts consistency." },
{ binary: "firewall-diff", desc: "Diff two firewall rule dumps." },
{ binary: "tokens-sync", desc: "Sync design tokens from Figma export to code." },
{ binary: "visual-diff", desc: "Compare rendered screenshots pixel-wise." },
{ binary: "mock-render", desc: "Render HTML mock templates for preview." },
];
export function buildRegistry(bridge: RustBridge): Map<string, ToolDefinition> {
const map = new Map<string, ToolDefinition>();
for (const t of RUST_PRIMITIVE_TOOLS) map.set(t.binary, wrapPrimitive(bridge, t));
map.set("kei", buildKeiMetaTool(bridge));
return map;
}
function wrapPrimitive(
bridge: RustBridge,
entry: { binary: string; desc: string },
): ToolDefinition {
return {
name: entry.binary,
description: entry.desc,
inputSchema: z.object({ args: z.record(z.unknown()).optional() }),
handler: async (rawArgs) => {
const parsed = (rawArgs["args"] as Record<string, unknown> | undefined) ?? {};
const cli = jsonArgsToCli(parsed);
const result = await bridge.call({ binary: entry.binary, args: cli });
if (result.exitCode !== 0) {
return `exit=${result.exitCode}\nstderr=${result.stderr}\nstdout=${result.stdout}`;
}
return result.stdout;
},
};
}
function buildKeiMetaTool(bridge: RustBridge): ToolDefinition {
return {
name: "kei",
description:
"Meta-tool: routes a natural-language query to the right primitive via kei-router.",
inputSchema: z.object({ query: z.string().min(1) }),
handler: async (rawArgs) => {
const query = String(rawArgs["query"] ?? "");
const result = await bridge.call({
binary: "kei-router",
args: ["--query", query],
});
if (result.exitCode !== 0) {
return `router failed exit=${result.exitCode}\nstderr=${result.stderr}`;
}
return result.stdout;
},
};
}
export function lookupTool(
registry: ReadonlyMap<string, ToolDefinition>,
name: string,
): ToolDefinition {
const t = registry.get(name);
if (!t) throw new ToolNotFoundError(name);
return t;
}

View file

@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import {
AuthError,
McpServerError,
RustBridgeError,
SchemaError,
ToolNotFoundError,
TimeoutError,
isMcpError,
toErrorPayload,
} from "../src/errors.js";
describe("errors hierarchy", () => {
it("AuthError has JSON-RPC code -32001", () => {
const e = new AuthError();
expect(e).toBeInstanceOf(McpServerError);
expect(e.code).toBe(-32001);
expect(isMcpError(e)).toBe(true);
});
it("ToolNotFoundError carries the tool name in data", () => {
const e = new ToolNotFoundError("kei-foo");
expect(e.code).toBe(-32601);
expect((e.data as { tool: string }).tool).toBe("kei-foo");
});
it("RustBridgeError prefixes message", () => {
const e = new RustBridgeError("spawn failed");
expect(e.message).toContain("rust bridge");
});
it("SchemaError has JSON-RPC code -32602", () => {
const e = new SchemaError("bad input");
expect(e.code).toBe(-32602);
});
it("TimeoutError records ms and tool", () => {
const e = new TimeoutError("kei-ledger", 1234);
expect(e.code).toBe(-32003);
expect((e.data as { ms: number }).ms).toBe(1234);
});
it("toErrorPayload handles MCP errors", () => {
const p = toErrorPayload(new AuthError("nope"));
expect(p.code).toBe(-32001);
expect(p.message).toBe("nope");
});
it("toErrorPayload handles plain Errors", () => {
const p = toErrorPayload(new Error("boom"));
expect(p.code).toBe(-32000);
expect(p.message).toBe("boom");
});
});

View file

@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { McpServer } from "../src/server.js";
describe("kei() meta-tool routing", () => {
it("rejects empty query via zod validation", async () => {
const srv = new McpServer({ rustBinDir: "/tmp/stub" });
const resp = await srv.handle({ tool: "kei", args: { query: "" } });
expect(resp.ok).toBe(false);
expect(resp.error?.code).toBe(-32602);
});
it("rejects missing query via zod validation", async () => {
const srv = new McpServer({ rustBinDir: "/tmp/stub" });
const resp = await srv.handle({ tool: "kei", args: {} });
expect(resp.ok).toBe(false);
expect(resp.error?.code).toBe(-32602);
});
it("accepts a non-empty query and routes via kei-router (resolves with non-zero exit)", async () => {
const srv = new McpServer({ rustBinDir: "/tmp/stub" });
const resp = await srv.handle({ tool: "kei", args: { query: "list ledger entries" } });
// Schema passes → meta-tool runs → router binary missing → handler formats result string.
expect(resp.ok).toBe(true);
expect(resp.result).toContain("router failed");
});
});

View file

@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { jsonArgsToCli, RustBridge } from "../src/rust-bridge.js";
import { RustBridgeError } from "../src/errors.js";
describe("jsonArgsToCli", () => {
it("converts snake_case keys to --kebab-case flags", () => {
expect(jsonArgsToCli({ foo_bar: "value" })).toEqual(["--foo-bar", "value"]);
});
it("emits booleans as presence-only flags", () => {
expect(jsonArgsToCli({ verbose: true })).toEqual(["--verbose"]);
expect(jsonArgsToCli({ verbose: false })).toEqual([]);
});
it("skips null and undefined values", () => {
expect(jsonArgsToCli({ a: null, b: undefined, c: "x" })).toEqual(["--c", "x"]);
});
it("stringifies numeric values", () => {
expect(jsonArgsToCli({ count: 42 })).toEqual(["--count", "42"]);
});
});
describe("RustBridge binary resolution", () => {
it("rejects illegal binary names", async () => {
const bridge = new RustBridge({ binDir: "/tmp" });
await expect(bridge.call({ binary: "../etc/passwd", args: [] })).rejects.toBeInstanceOf(
RustBridgeError,
);
});
it("accepts valid snake_case and kebab-case names (resolves with non-zero exit on ENOENT)", async () => {
const bridge = new RustBridge({ binDir: "/tmp" });
const result = await bridge.call({ binary: "kei-ledger", args: [], timeoutMs: 500 });
// execa is configured with reject:false → a missing binary resolves with exitCode != 0
// (validation passed — this was the assertion under test).
expect(result.exitCode).not.toBe(0);
});
});

View file

@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { McpServer } from "../src/server.js";
describe("server auth", () => {
it("rejects calls without a token when auth is enabled", async () => {
const srv = new McpServer({ rustBinDir: "/tmp/stub", authToken: "secret" });
const resp = await srv.handle({ tool: "kei-ledger", args: { args: {} } });
expect(resp.ok).toBe(false);
expect(resp.error?.code).toBe(-32001);
});
it("rejects calls with a wrong token", async () => {
const srv = new McpServer({ rustBinDir: "/tmp/stub", authToken: "secret" });
const resp = await srv.handle({
tool: "kei-ledger",
args: { args: {} },
authHeader: "wrong",
});
expect(resp.ok).toBe(false);
expect(resp.error?.code).toBe(-32001);
});
it("allows calls when auth is disabled (stdio mode)", async () => {
const srv = new McpServer({ rustBinDir: "/tmp/stub" });
const resp = await srv.handle({ tool: "does-not-exist", args: {} });
// auth passes → fails on tool lookup instead
expect(resp.ok).toBe(false);
expect(resp.error?.code).toBe(-32601);
});
});

View file

@ -0,0 +1,21 @@
import { describe, it, expect } from "vitest";
import { McpServer } from "../src/server.js";
describe("server handshake + tool listing", () => {
it("listTools returns every primitive plus kei", () => {
const srv = new McpServer({ rustBinDir: "/tmp/stub" });
const tools = srv.listTools();
const names = new Set(tools.map((t) => t.name));
expect(names.has("kei")).toBe(true);
expect(names.has("kei-ledger")).toBe(true);
expect(names.has("genesis-scan")).toBe(true);
expect(tools.length).toBeGreaterThanOrEqual(15);
});
it("every listed tool has a non-empty description", () => {
const srv = new McpServer({ rustBinDir: "/tmp/stub" });
for (const t of srv.listTools()) {
expect(t.description.length).toBeGreaterThan(0);
}
});
});

View file

@ -0,0 +1,31 @@
import { describe, it, expect } from "vitest";
import { buildRegistry, lookupTool, RUST_PRIMITIVE_TOOLS } from "../src/tool-registry.js";
import { RustBridge } from "../src/rust-bridge.js";
import { ToolNotFoundError } from "../src/errors.js";
describe("tool registry", () => {
const bridge = new RustBridge({ binDir: "/tmp/stub" });
const registry = buildRegistry(bridge);
it("registers one tool per Rust primitive", () => {
for (const t of RUST_PRIMITIVE_TOOLS) {
expect(registry.has(t.binary)).toBe(true);
}
});
it("registers the kei meta-tool", () => {
const t = lookupTool(registry, "kei");
expect(t.name).toBe("kei");
expect(t.description).toContain("Meta-tool");
});
it("lookupTool throws ToolNotFoundError for unknown names", () => {
expect(() => lookupTool(registry, "nonexistent-tool")).toThrow(ToolNotFoundError);
});
it("tool description is non-empty for each primitive", () => {
for (const t of RUST_PRIMITIVE_TOOLS) {
expect(t.desc.length).toBeGreaterThan(10);
}
});
});

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules", "test/**/*"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
environment: "node",
},
});

View file

@ -0,0 +1,32 @@
{
"name": "@keisei/recall-adapter",
"version": "0.14.0",
"description": "Recall.ai adapter (Zoom meeting capture) for the KeiSei MCP server",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -b",
"test": "vitest run"
},
"dependencies": {
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}

View file

@ -0,0 +1,63 @@
// Minimal client for the Recall.ai v1 REST API.
// Docs: https://docs.recall.ai/reference (verified 2026-04).
export type FetchFn = typeof fetch;
export interface RecallClientConfig {
apiKey: string;
baseUrl?: string;
fetchImpl?: FetchFn;
}
const DEFAULT_BASE_URL = "https://api.recall.ai/api/v1";
export class RecallClient {
private readonly apiKey: string;
private readonly baseUrl: string;
private readonly fetchImpl: FetchFn;
constructor(cfg: RecallClientConfig) {
if (!cfg.apiKey) throw new Error("RECALL_API_KEY is required");
this.apiKey = cfg.apiKey;
this.baseUrl = cfg.baseUrl ?? DEFAULT_BASE_URL;
this.fetchImpl = cfg.fetchImpl ?? fetch;
}
async listBots(): Promise<unknown> {
return this.request("GET", "/bot/");
}
async getBot(botId: string): Promise<unknown> {
return this.request("GET", `/bot/${encodeURIComponent(botId)}/`);
}
async joinMeeting(meetingUrl: string, botName = "KeiSei"): Promise<unknown> {
return this.request("POST", "/bot/", { meeting_url: meetingUrl, bot_name: botName });
}
async leaveMeeting(botId: string): Promise<unknown> {
return this.request("POST", `/bot/${encodeURIComponent(botId)}/leave_call/`);
}
async getTranscript(botId: string): Promise<unknown> {
return this.request("GET", `/bot/${encodeURIComponent(botId)}/transcript/`);
}
private async request(method: string, path: string, body?: unknown): Promise<unknown> {
const headers: Record<string, string> = {
Authorization: `Token ${this.apiKey}`,
Accept: "application/json",
};
const init: RequestInit = { method, headers };
if (body !== undefined) {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
}
const res = await this.fetchImpl(`${this.baseUrl}${path}`, init);
if (!res.ok) {
const text = await res.text();
throw new Error(`recall ${method} ${path} -> ${res.status}: ${text}`);
}
return res.json();
}
}

View file

@ -0,0 +1,19 @@
import { RecallClient } from "./client.js";
import { buildRecallTools, type RecallTool } from "./tools.js";
export { RecallClient } from "./client.js";
export { buildRecallTools } from "./tools.js";
export type { RecallTool } from "./tools.js";
type Registrar = (tool: RecallTool) => void;
export function registerAdapter(register: Registrar): void {
const apiKey = process.env["RECALL_API_KEY"];
if (!apiKey) {
throw new Error(
"RECALL_API_KEY env var is missing; set it in ~/.claude/secrets/.env (RULE 0.8).",
);
}
const client = new RecallClient({ apiKey });
for (const tool of buildRecallTools(client)) register(tool);
}

View file

@ -0,0 +1,69 @@
// Recall.ai tool definitions. Each tool wraps one client method and returns
// JSON-stringified output for the MCP transport.
import { z } from "zod";
import { RecallClient } from "./client.js";
export interface RecallTool {
name: string;
description: string;
inputSchema: z.ZodObject<Record<string, z.ZodTypeAny>>;
handler: (args: Record<string, unknown>) => Promise<string>;
}
const BotIdArgs = z.object({ bot_id: z.string().min(1) });
const JoinArgs = z.object({
meeting_url: z.string().url(),
bot_name: z.string().optional(),
});
export function buildRecallTools(client: RecallClient): RecallTool[] {
return [
{
name: "zoom_status",
description: "Status of a deployed Recall.ai bot (bot_id required).",
inputSchema: BotIdArgs,
handler: async (raw) => {
const args = BotIdArgs.parse(raw);
return pretty(await client.getBot(args.bot_id));
},
},
{
name: "zoom_bots",
description: "List all Recall.ai bots for this account.",
inputSchema: z.object({}),
handler: async () => pretty(await client.listBots()),
},
{
name: "zoom_join",
description: "Deploy a Recall.ai bot to a meeting URL.",
inputSchema: JoinArgs,
handler: async (raw) => {
const args = JoinArgs.parse(raw);
return pretty(await client.joinMeeting(args.meeting_url, args.bot_name));
},
},
{
name: "zoom_leave",
description: "Recall an active bot from a meeting.",
inputSchema: BotIdArgs,
handler: async (raw) => {
const args = BotIdArgs.parse(raw);
return pretty(await client.leaveMeeting(args.bot_id));
},
},
{
name: "zoom_chat",
description: "Fetch transcript for a bot's meeting.",
inputSchema: BotIdArgs,
handler: async (raw) => {
const args = BotIdArgs.parse(raw);
return pretty(await client.getTranscript(args.bot_id));
},
},
];
}
function pretty(x: unknown): string {
return JSON.stringify(x, null, 2);
}

View file

@ -0,0 +1,40 @@
import { describe, it, expect, vi } from "vitest";
import { RecallClient } from "../src/client.js";
function makeFetchMock(payload: unknown, ok = true, status = 200): typeof fetch {
return vi.fn(async () => {
return {
ok,
status,
async text() { return JSON.stringify(payload); },
async json() { return payload; },
} as unknown as Response;
}) as unknown as typeof fetch;
}
describe("RecallClient", () => {
it("rejects empty API key", () => {
expect(() => new RecallClient({ apiKey: "" })).toThrow(/RECALL_API_KEY/);
});
it("listBots calls GET /bot/", async () => {
const fetchImpl = makeFetchMock([{ id: "b1" }]);
const c = new RecallClient({ apiKey: "k", fetchImpl });
const out = (await c.listBots()) as Array<{ id: string }>;
expect(out[0]?.id).toBe("b1");
expect(fetchImpl).toHaveBeenCalledOnce();
});
it("joinMeeting POSTs meeting_url", async () => {
const fetchImpl = makeFetchMock({ id: "new" });
const c = new RecallClient({ apiKey: "k", fetchImpl });
const out = await c.joinMeeting("https://zoom.us/j/123");
expect((out as { id: string }).id).toBe("new");
});
it("propagates non-2xx responses as Error", async () => {
const fetchImpl = makeFetchMock({ detail: "forbidden" }, false, 403);
const c = new RecallClient({ apiKey: "k", fetchImpl });
await expect(c.getBot("b1")).rejects.toThrow(/403/);
});
});

View file

@ -0,0 +1,47 @@
import { describe, it, expect, vi } from "vitest";
import { RecallClient } from "../src/client.js";
import { buildRecallTools } from "../src/tools.js";
function makeFetchMock(payload: unknown): typeof fetch {
return vi.fn(async () => ({
ok: true,
status: 200,
async text() { return JSON.stringify(payload); },
async json() { return payload; },
} as unknown as Response)) as unknown as typeof fetch;
}
describe("recall tool surface", () => {
it("exposes 5 tools", () => {
const c = new RecallClient({ apiKey: "k", fetchImpl: makeFetchMock({}) });
const tools = buildRecallTools(c);
expect(tools.map((t) => t.name)).toEqual([
"zoom_status",
"zoom_bots",
"zoom_join",
"zoom_leave",
"zoom_chat",
]);
});
it("zoom_join validates meeting_url as URL", async () => {
const c = new RecallClient({ apiKey: "k", fetchImpl: makeFetchMock({}) });
const tool = buildRecallTools(c).find((t) => t.name === "zoom_join");
await expect(tool!.handler({ meeting_url: "not a url" })).rejects.toBeTruthy();
});
it("zoom_status returns JSON string", async () => {
const c = new RecallClient({ apiKey: "k", fetchImpl: makeFetchMock({ id: "x" }) });
const tool = buildRecallTools(c).find((t) => t.name === "zoom_status");
const out = await tool!.handler({ bot_id: "x" });
expect(out).toContain('"id": "x"');
});
it("zoom_bots hits list endpoint", async () => {
const fetchImpl = makeFetchMock([{ id: "a" }]);
const c = new RecallClient({ apiKey: "k", fetchImpl });
const tool = buildRecallTools(c).find((t) => t.name === "zoom_bots");
const out = await tool!.handler({});
expect(out).toContain("a");
});
});

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules", "test/**/*"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
environment: "node",
},
});

View file

@ -0,0 +1,33 @@
{
"name": "@keisei/telegram-adapter",
"version": "0.14.0",
"description": "Telegram Bot API adapter for the KeiSei MCP server",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -b",
"test": "vitest run"
},
"dependencies": {
"grammy": "^1.28.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}

View file

@ -0,0 +1,74 @@
// Thin wrapper over grammy's Bot class. One class = one responsibility:
// own the Bot instance, expose a narrow surface used by tool handlers.
import { Bot, InputFile } from "grammy";
import type { ContactRecord, GroupRecord } from "./types.js";
export interface TelegramClientConfig {
token: string;
}
export class TelegramClient {
private readonly bot: Bot;
private readonly contactsCache: Map<number, ContactRecord> = new Map();
private readonly groupsCache: Map<number, GroupRecord> = new Map();
constructor(cfg: TelegramClientConfig) {
if (!cfg.token) throw new Error("TELEGRAM_BOT_TOKEN is required");
this.bot = new Bot(cfg.token);
}
async status(): Promise<{ username: string; id: number }> {
const me = await this.bot.api.getMe();
return { username: me.username, id: me.id };
}
async chatInfo(chat: string | number): Promise<{ id: number; title: string; type: string }> {
const info = await this.bot.api.getChat(chat);
const title = "title" in info && info.title ? info.title :
"first_name" in info && info.first_name ? info.first_name : String(info.id);
return { id: info.id, title, type: info.type };
}
async sendText(chat: string | number, text: string): Promise<number> {
const msg = await this.bot.api.sendMessage(chat, text);
return msg.message_id;
}
async sendDocument(chat: string | number, filePath: string, caption?: string): Promise<number> {
const msg = await this.bot.api.sendDocument(chat, new InputFile(filePath), caption !== undefined ? { caption } : {});
return msg.message_id;
}
async sendPhoto(chat: string | number, filePath: string, caption?: string): Promise<number> {
const msg = await this.bot.api.sendPhoto(chat, new InputFile(filePath), caption !== undefined ? { caption } : {});
return msg.message_id;
}
async sendVideo(chat: string | number, filePath: string, caption?: string): Promise<number> {
const msg = await this.bot.api.sendVideo(chat, new InputFile(filePath), caption !== undefined ? { caption } : {});
return msg.message_id;
}
async sendVoice(chat: string | number, filePath: string, caption?: string): Promise<number> {
const msg = await this.bot.api.sendVoice(chat, new InputFile(filePath), caption !== undefined ? { caption } : {});
return msg.message_id;
}
listGroups(): GroupRecord[] {
return Array.from(this.groupsCache.values());
}
listContacts(): ContactRecord[] {
return Array.from(this.contactsCache.values());
}
// Test helpers to seed cache; kept internal via underscore prefix.
_seedContact(c: ContactRecord): void {
this.contactsCache.set(c.userId, c);
}
_seedGroup(g: GroupRecord): void {
this.groupsCache.set(g.chatId, g);
}
}

View file

@ -0,0 +1,23 @@
// Public entry: exports registerAdapter() for the MCP server loader,
// plus the class + tool builder for programmatic use.
import { TelegramClient } from "./client.js";
import { buildTelegramTools, type TelegramTool } from "./tools.js";
export { TelegramClient } from "./client.js";
export { buildTelegramTools } from "./tools.js";
export type { TelegramTool } from "./tools.js";
export * from "./types.js";
type Registrar = (tool: TelegramTool) => void;
export function registerAdapter(register: Registrar): void {
const token = process.env["TELEGRAM_BOT_TOKEN"];
if (!token) {
throw new Error(
"TELEGRAM_BOT_TOKEN env var is missing; set it in ~/.claude/secrets/.env (RULE 0.8).",
);
}
const client = new TelegramClient({ token });
for (const tool of buildTelegramTools(client)) register(tool);
}

View file

@ -0,0 +1,112 @@
// Tool definitions for the Telegram adapter. Each tool is a small wrapper
// around TelegramClient + a zod schema, returning a string to MCP.
import { z } from "zod";
import { TelegramClient } from "./client.js";
import {
ChatInfoArgs,
SendFileArgs,
SendTextArgs,
SendVoiceArgs,
} from "./types.js";
export interface TelegramTool {
name: string;
description: string;
inputSchema: z.ZodObject<Record<string, z.ZodTypeAny>>;
handler: (args: Record<string, unknown>) => Promise<string>;
}
export function buildTelegramTools(client: TelegramClient): TelegramTool[] {
return [
{
name: "telegram_status",
description: "Telegram bot identity and connectivity.",
inputSchema: z.object({}),
handler: async () => {
const s = await client.status();
return `bot=@${s.username} id=${s.id}`;
},
},
{
name: "telegram_groups",
description: "List groups the bot has observed.",
inputSchema: z.object({}),
handler: async () => {
const gs = client.listGroups();
if (gs.length === 0) return "No groups tracked yet.";
return gs.map((g) => `${g.chatId} | ${g.title} [${g.type}]`).join("\n");
},
},
{
name: "telegram_contacts",
description: "List known Telegram contacts.",
inputSchema: z.object({}),
handler: async () => {
const cs = client.listContacts();
if (cs.length === 0) return "No contacts yet.";
return cs.map(formatContact).join("\n");
},
},
{
name: "telegram_chat_info",
description: "Chat metadata for a given chat ID or @username.",
inputSchema: ChatInfoArgs,
handler: async (raw) => {
const args = ChatInfoArgs.parse(raw);
const info = await client.chatInfo(args.chat);
return `id=${info.id}\ntitle=${info.title}\ntype=${info.type}`;
},
},
{
name: "telegram_send",
description: "Send a text message.",
inputSchema: SendTextArgs,
handler: async (raw) => {
const args = SendTextArgs.parse(raw);
const id = await client.sendText(args.chat, args.text);
return `sent message_id=${id}`;
},
},
{
name: "telegram_send_file",
description: "Send a document, photo, or video file.",
inputSchema: SendFileArgs,
handler: async (raw) => {
const args = SendFileArgs.parse(raw);
const id = await dispatchFile(client, args);
return `sent ${args.kind} message_id=${id}`;
},
},
{
name: "telegram_send_voice",
description: "Send a pre-recorded voice note file.",
inputSchema: SendVoiceArgs,
handler: async (raw) => {
const args = SendVoiceArgs.parse(raw);
const id = await client.sendVoice(args.chat, args.file, args.caption);
return `sent voice message_id=${id}`;
},
},
];
}
function formatContact(c: {
userId: number;
firstName: string;
lastName?: string | undefined;
username?: string | undefined;
}): string {
const name = c.lastName ? `${c.firstName} ${c.lastName}` : c.firstName;
const handle = c.username ? ` @${c.username}` : "";
return `${c.userId} | ${name}${handle}`;
}
async function dispatchFile(
client: TelegramClient,
args: { chat: string | number; file: string; kind: "document" | "photo" | "video"; caption?: string | undefined },
): Promise<number> {
if (args.kind === "photo") return client.sendPhoto(args.chat, args.file, args.caption);
if (args.kind === "video") return client.sendVideo(args.chat, args.file, args.caption);
return client.sendDocument(args.chat, args.file, args.caption);
}

View file

@ -0,0 +1,51 @@
// Tool I/O types for the Telegram adapter. Kept separate so both the
// adapter and consumers can import schemas without pulling grammy.
import { z } from "zod";
export const TelegramChatRef = z.union([
z.number().int(),
z.string().min(1),
]);
export type TelegramChatRef = z.infer<typeof TelegramChatRef>;
export const SendTextArgs = z.object({
chat: TelegramChatRef,
text: z.string().min(1),
});
export type SendTextArgs = z.infer<typeof SendTextArgs>;
export const SendFileArgs = z.object({
chat: TelegramChatRef,
file: z.string().min(1),
kind: z.enum(["document", "photo", "video"]).default("document"),
caption: z.string().optional(),
});
export type SendFileArgs = z.infer<typeof SendFileArgs>;
export const SendVoiceArgs = z.object({
chat: TelegramChatRef,
file: z.string().min(1),
caption: z.string().optional(),
});
export type SendVoiceArgs = z.infer<typeof SendVoiceArgs>;
export const ChatInfoArgs = z.object({
chat: TelegramChatRef,
});
export type ChatInfoArgs = z.infer<typeof ChatInfoArgs>;
export interface ContactRecord {
userId: number;
firstName: string;
lastName?: string | undefined;
username?: string | undefined;
lastSeen?: number | undefined;
}
export interface GroupRecord {
chatId: number;
title: string;
type: string;
lastMsg?: number | undefined;
}

View file

@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { TelegramClient } from "../src/client.js";
describe("TelegramClient cache", () => {
it("rejects empty token at construction", () => {
expect(() => new TelegramClient({ token: "" })).toThrow(/TELEGRAM_BOT_TOKEN/);
});
it("listGroups is empty by default", () => {
const c = new TelegramClient({ token: "123:ABC" });
expect(c.listGroups()).toEqual([]);
});
it("listContacts is empty by default", () => {
const c = new TelegramClient({ token: "123:ABC" });
expect(c.listContacts()).toEqual([]);
});
it("_seedContact and _seedGroup populate caches", () => {
const c = new TelegramClient({ token: "123:ABC" });
c._seedContact({ userId: 99, firstName: "Alice" });
c._seedGroup({ chatId: -100, title: "Test", type: "supergroup" });
expect(c.listContacts()).toHaveLength(1);
expect(c.listGroups()).toHaveLength(1);
});
});

View file

@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import { TelegramClient } from "../src/client.js";
import { buildTelegramTools } from "../src/tools.js";
describe("telegram tool surface", () => {
const c = new TelegramClient({ token: "123:ABC" });
const tools = buildTelegramTools(c);
it("exposes 7 tools total", () => {
expect(tools.map((t) => t.name)).toEqual([
"telegram_status",
"telegram_groups",
"telegram_contacts",
"telegram_chat_info",
"telegram_send",
"telegram_send_file",
"telegram_send_voice",
]);
});
it("telegram_groups returns placeholder text when empty", async () => {
const tool = tools.find((t) => t.name === "telegram_groups");
const out = await tool!.handler({});
expect(out).toContain("No groups");
});
it("telegram_contacts formats seeded contact", async () => {
c._seedContact({ userId: 42, firstName: "Bob", username: "bobby" });
const tool = tools.find((t) => t.name === "telegram_contacts");
const out = await tool!.handler({});
expect(out).toContain("42");
expect(out).toContain("Bob");
expect(out).toContain("@bobby");
});
it("telegram_send rejects missing args via schema", async () => {
const tool = tools.find((t) => t.name === "telegram_send");
await expect(tool!.handler({})).rejects.toBeTruthy();
});
it("tool descriptions are non-empty", () => {
for (const t of tools) expect(t.description.length).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { SendTextArgs, SendFileArgs, SendVoiceArgs, ChatInfoArgs } from "../src/types.js";
describe("zod schemas", () => {
it("SendTextArgs accepts numeric chat id", () => {
const r = SendTextArgs.safeParse({ chat: 12345, text: "hi" });
expect(r.success).toBe(true);
});
it("SendTextArgs accepts string chat handle", () => {
const r = SendTextArgs.safeParse({ chat: "@username", text: "hi" });
expect(r.success).toBe(true);
});
it("SendTextArgs rejects empty text", () => {
const r = SendTextArgs.safeParse({ chat: 1, text: "" });
expect(r.success).toBe(false);
});
it("SendFileArgs defaults kind to document", () => {
const r = SendFileArgs.safeParse({ chat: 1, file: "/x" });
expect(r.success).toBe(true);
if (r.success) expect(r.data.kind).toBe("document");
});
it("SendFileArgs rejects unknown kind", () => {
const r = SendFileArgs.safeParse({ chat: 1, file: "/x", kind: "sticker" });
expect(r.success).toBe(false);
});
it("SendVoiceArgs requires file path", () => {
const r = SendVoiceArgs.safeParse({ chat: 1 });
expect(r.success).toBe(false);
});
it("ChatInfoArgs requires chat", () => {
const r = ChatInfoArgs.safeParse({});
expect(r.success).toBe(false);
});
});

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules", "test/**/*"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
environment: "node",
},
});

View file

@ -0,0 +1,34 @@
{
"name": "@keisei/youtube-adapter",
"version": "0.14.0",
"description": "YouTube Data API v3 adapter for the KeiSei MCP server",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -b",
"test": "vitest run"
},
"dependencies": {
"googleapis": "^144.0.0",
"youtube-transcript": "^1.2.1",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}

View file

@ -0,0 +1,112 @@
// YouTube Data API v3 client wrapper. The surface is intentionally narrow —
// subscriptions list, search, videos.list(statistics), plus a transcript
// helper using the `youtube-transcript` package.
import { google } from "googleapis";
import type { TranscriptLine, VideoStats, VideoSummary } from "./types.js";
export interface YouTubeClientConfig {
apiKey: string;
surface?: YouTubeSurface;
transcriptFn?: TranscriptFn;
}
export type TranscriptFn = (videoId: string) => Promise<TranscriptLine[]>;
export interface YouTubeSurface {
subscriptions: (max: number) => Promise<VideoSummary[]>;
channelVideos: (channelId: string, since: string | undefined, max: number) => Promise<VideoSummary[]>;
search: (query: string, max: number) => Promise<VideoSummary[]>;
stats: (videoId: string) => Promise<VideoStats>;
}
export class YouTubeClient {
private readonly surface: YouTubeSurface;
private readonly transcriptFn: TranscriptFn;
constructor(cfg: YouTubeClientConfig) {
this.surface = cfg.surface ?? buildDefaultSurface(cfg.apiKey);
this.transcriptFn = cfg.transcriptFn ?? defaultTranscriptFn;
}
subscriptions(max: number): Promise<VideoSummary[]> {
return this.surface.subscriptions(max);
}
newVideos(channelId: string, since: string | undefined, max: number): Promise<VideoSummary[]> {
return this.surface.channelVideos(channelId, since, max);
}
search(query: string, max: number): Promise<VideoSummary[]> {
return this.surface.search(query, max);
}
stats(videoId: string): Promise<VideoStats> {
return this.surface.stats(videoId);
}
transcript(videoId: string): Promise<TranscriptLine[]> {
return this.transcriptFn(videoId);
}
}
interface TranscriptModule {
YoutubeTranscript: {
fetchTranscript: (videoId: string) => Promise<Array<{ text: string; offset: number; duration: number }>>;
};
}
async function defaultTranscriptFn(videoId: string): Promise<TranscriptLine[]> {
// Deferred import: the upstream package ships dual-module with a broken
// CJS entry, so eager `import` at top-level fails under ESM + vitest.
const mod = (await import("youtube-transcript")) as unknown as TranscriptModule;
const rows = await mod.YoutubeTranscript.fetchTranscript(videoId);
return rows.map((r) => ({ text: r.text, offset: r.offset, duration: r.duration }));
}
function buildDefaultSurface(apiKey: string): YouTubeSurface {
if (!apiKey) throw new Error("YOUTUBE_API_KEY is required");
const yt = google.youtube({ version: "v3", auth: apiKey });
return {
subscriptions: async (max) => {
const res = await yt.subscriptions.list({ part: ["snippet"], mine: true, maxResults: max });
return (res.data.items ?? []).map(itemToSummary);
},
channelVideos: async (channelId, since, max) => {
const res = await yt.search.list({
part: ["snippet"],
channelId,
order: "date",
maxResults: max,
...(since !== undefined ? { publishedAfter: since } : {}),
});
return (res.data.items ?? []).map(itemToSummary);
},
search: async (query, max) => {
const res = await yt.search.list({ part: ["snippet"], q: query, maxResults: max });
return (res.data.items ?? []).map(itemToSummary);
},
stats: async (videoId) => {
const res = await yt.videos.list({ part: ["statistics"], id: [videoId] });
const s = res.data.items?.[0]?.statistics ?? {};
return {
videoId,
viewCount: s.viewCount ?? undefined,
likeCount: s.likeCount ?? undefined,
commentCount: s.commentCount ?? undefined,
};
},
};
}
function itemToSummary(item: { id?: { videoId?: string | null } | string | null; snippet?: { title?: string | null; channelTitle?: string | null; resourceId?: { videoId?: string | null } | null; publishedAt?: string | null } | null }): VideoSummary {
const vid = typeof item.id === "object" && item.id !== null
? (item.id.videoId ?? "")
: (item.snippet?.resourceId?.videoId ?? "");
return {
videoId: vid,
title: item.snippet?.title ?? undefined,
channel: item.snippet?.channelTitle ?? undefined,
publishedAt: item.snippet?.publishedAt ?? undefined,
};
}

View file

@ -0,0 +1,20 @@
import { YouTubeClient } from "./client.js";
import { buildYouTubeTools, type YouTubeTool } from "./tools.js";
export { YouTubeClient } from "./client.js";
export { buildYouTubeTools } from "./tools.js";
export type { YouTubeTool } from "./tools.js";
export * from "./types.js";
type Registrar = (tool: YouTubeTool) => void;
export function registerAdapter(register: Registrar): void {
const apiKey = process.env["YOUTUBE_API_KEY"];
if (!apiKey) {
throw new Error(
"YOUTUBE_API_KEY env var is missing; set it in ~/.claude/secrets/.env (RULE 0.8).",
);
}
const client = new YouTubeClient({ apiKey });
for (const tool of buildYouTubeTools(client)) register(tool);
}

View file

@ -0,0 +1,87 @@
import { z } from "zod";
import { YouTubeClient } from "./client.js";
import {
NewVideosArgs,
SearchArgs,
SubscriptionsArgs,
VideoIdArgs,
type TranscriptLine,
type VideoStats,
type VideoSummary,
} from "./types.js";
export interface YouTubeTool {
name: string;
description: string;
inputSchema: z.ZodObject<Record<string, z.ZodTypeAny>>;
handler: (args: Record<string, unknown>) => Promise<string>;
}
export function buildYouTubeTools(client: YouTubeClient): YouTubeTool[] {
return [
{
name: "youtube_subscriptions",
description: "List the authenticated user's channel subscriptions.",
inputSchema: SubscriptionsArgs,
handler: async (raw) => {
const args = SubscriptionsArgs.parse(raw);
return formatList(await client.subscriptions(args.max));
},
},
{
name: "youtube_new_videos",
description: "Latest videos from a given channel (optional --since ISO8601).",
inputSchema: NewVideosArgs,
handler: async (raw) => {
const args = NewVideosArgs.parse(raw);
return formatList(await client.newVideos(args.channel_id, args.since, args.max));
},
},
{
name: "youtube_search",
description: "Search YouTube for a query string.",
inputSchema: SearchArgs,
handler: async (raw) => {
const args = SearchArgs.parse(raw);
return formatList(await client.search(args.query, args.max));
},
},
{
name: "youtube_transcript",
description: "Fetch the transcript (captions) of a video as plain text.",
inputSchema: VideoIdArgs,
handler: async (raw) => {
const args = VideoIdArgs.parse(raw);
return formatTranscript(await client.transcript(args.video_id));
},
},
{
name: "youtube_video_stats",
description: "View/like/comment counts for a given video.",
inputSchema: VideoIdArgs,
handler: async (raw) => {
const args = VideoIdArgs.parse(raw);
return formatStats(await client.stats(args.video_id));
},
},
];
}
function formatList(items: VideoSummary[]): string {
if (items.length === 0) return "No results.";
return items.map((v) => `${v.videoId} | ${v.channel ?? "?"} | ${v.title ?? "?"}`).join("\n");
}
function formatTranscript(lines: TranscriptLine[]): string {
if (lines.length === 0) return "No transcript available.";
return lines.map((l) => l.text).join(" ");
}
function formatStats(s: VideoStats): string {
return [
`video: ${s.videoId}`,
`views: ${s.viewCount ?? "?"}`,
`likes: ${s.likeCount ?? "?"}`,
`comments: ${s.commentCount ?? "?"}`,
].join("\n");
}

View file

@ -0,0 +1,46 @@
// YouTube Data API v3 tool I/O types.
import { z } from "zod";
export const SubscriptionsArgs = z.object({
max: z.number().int().positive().max(50).default(25),
});
export type SubscriptionsArgs = z.infer<typeof SubscriptionsArgs>;
export const NewVideosArgs = z.object({
channel_id: z.string().min(1),
since: z.string().optional(),
max: z.number().int().positive().max(50).default(10),
});
export type NewVideosArgs = z.infer<typeof NewVideosArgs>;
export const SearchArgs = z.object({
query: z.string().min(1),
max: z.number().int().positive().max(50).default(10),
});
export type SearchArgs = z.infer<typeof SearchArgs>;
export const VideoIdArgs = z.object({
video_id: z.string().min(1),
});
export type VideoIdArgs = z.infer<typeof VideoIdArgs>;
export interface VideoSummary {
videoId: string;
title?: string | undefined;
channel?: string | undefined;
publishedAt?: string | undefined;
}
export interface VideoStats {
videoId: string;
viewCount?: string | undefined;
likeCount?: string | undefined;
commentCount?: string | undefined;
}
export interface TranscriptLine {
text: string;
offset: number;
duration: number;
}

View file

@ -0,0 +1,37 @@
import { describe, it, expect, vi } from "vitest";
import { YouTubeClient, type YouTubeSurface } from "../src/client.js";
function makeSurface(): YouTubeSurface {
return {
subscriptions: vi.fn(async () => [{ videoId: "v1", title: "Sub channel", channel: "c1" }]),
channelVideos: vi.fn(async () => [{ videoId: "v2", title: "Latest" }]),
search: vi.fn(async () => [{ videoId: "v3", title: "Result" }]),
stats: vi.fn(async () => ({ videoId: "v1", viewCount: "100", likeCount: "5", commentCount: "1" })),
};
}
describe("YouTubeClient", () => {
it("subscriptions delegates to surface", async () => {
const s = makeSurface();
const c = new YouTubeClient({ apiKey: "k", surface: s });
const out = await c.subscriptions(10);
expect(out[0]?.videoId).toBe("v1");
expect(s.subscriptions).toHaveBeenCalledWith(10);
});
it("transcript uses injected fn", async () => {
const c = new YouTubeClient({
apiKey: "k",
surface: makeSurface(),
transcriptFn: async () => [{ text: "hi", offset: 0, duration: 1 }],
});
const out = await c.transcript("vid");
expect(out).toHaveLength(1);
});
it("stats returns video statistics", async () => {
const c = new YouTubeClient({ apiKey: "k", surface: makeSurface() });
const out = await c.stats("v1");
expect(out.viewCount).toBe("100");
});
});

View file

@ -0,0 +1,55 @@
import { describe, it, expect, vi } from "vitest";
import { YouTubeClient, type YouTubeSurface } from "../src/client.js";
import { buildYouTubeTools } from "../src/tools.js";
function makeSurface(): YouTubeSurface {
return {
subscriptions: vi.fn(async () => []),
channelVideos: vi.fn(async () => []),
search: vi.fn(async () => [{ videoId: "v1", title: "T", channel: "C" }]),
stats: vi.fn(async () => ({ videoId: "v1", viewCount: "9" })),
};
}
describe("youtube tool surface", () => {
it("registers 5 tools", () => {
const c = new YouTubeClient({ apiKey: "k", surface: makeSurface(), transcriptFn: async () => [] });
const names = buildYouTubeTools(c).map((t) => t.name);
expect(names).toEqual([
"youtube_subscriptions",
"youtube_new_videos",
"youtube_search",
"youtube_transcript",
"youtube_video_stats",
]);
});
it("youtube_subscriptions handles empty list", async () => {
const c = new YouTubeClient({ apiKey: "k", surface: makeSurface(), transcriptFn: async () => [] });
const tool = buildYouTubeTools(c).find((t) => t.name === "youtube_subscriptions");
const out = await tool!.handler({});
expect(out).toBe("No results.");
});
it("youtube_search formats result line", async () => {
const c = new YouTubeClient({ apiKey: "k", surface: makeSurface(), transcriptFn: async () => [] });
const tool = buildYouTubeTools(c).find((t) => t.name === "youtube_search");
const out = await tool!.handler({ query: "rust" });
expect(out).toContain("v1");
expect(out).toContain("T");
});
it("youtube_transcript joins lines with spaces", async () => {
const c = new YouTubeClient({
apiKey: "k",
surface: makeSurface(),
transcriptFn: async () => [
{ text: "hello", offset: 0, duration: 1 },
{ text: "world", offset: 1, duration: 1 },
],
});
const tool = buildYouTubeTools(c).find((t) => t.name === "youtube_transcript");
const out = await tool!.handler({ video_id: "x" });
expect(out).toBe("hello world");
});
});

View file

@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest";
import { SubscriptionsArgs, NewVideosArgs, SearchArgs, VideoIdArgs } from "../src/types.js";
describe("youtube schemas", () => {
it("SubscriptionsArgs defaults max to 25", () => {
const r = SubscriptionsArgs.safeParse({});
expect(r.success).toBe(true);
if (r.success) expect(r.data.max).toBe(25);
});
it("SubscriptionsArgs rejects max > 50", () => {
const r = SubscriptionsArgs.safeParse({ max: 51 });
expect(r.success).toBe(false);
});
it("NewVideosArgs requires channel_id", () => {
expect(NewVideosArgs.safeParse({}).success).toBe(false);
expect(NewVideosArgs.safeParse({ channel_id: "UC1" }).success).toBe(true);
});
it("SearchArgs rejects empty query", () => {
expect(SearchArgs.safeParse({ query: "" }).success).toBe(false);
});
it("VideoIdArgs requires non-empty id", () => {
expect(VideoIdArgs.safeParse({ video_id: "" }).success).toBe(false);
expect(VideoIdArgs.safeParse({ video_id: "abc" }).success).toBe(true);
});
});

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules", "test/**/*"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
environment: "node",
},
});

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"incremental": true
}
}