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:
parent
cab78d68f7
commit
c21943e40b
66 changed files with 6518 additions and 0 deletions
5
_ts_packages/.gitignore
vendored
Normal file
5
_ts_packages/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
coverage/
|
||||
111
_ts_packages/README.md
Normal file
111
_ts_packages/README.md
Normal 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
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
24
_ts_packages/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
_ts_packages/packages/gmail-adapter/package.json
Normal file
33
_ts_packages/packages/gmail-adapter/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
101
_ts_packages/packages/gmail-adapter/src/client.ts
Normal file
101
_ts_packages/packages/gmail-adapter/src/client.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
22
_ts_packages/packages/gmail-adapter/src/index.ts
Normal file
22
_ts_packages/packages/gmail-adapter/src/index.ts
Normal 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);
|
||||
}
|
||||
93
_ts_packages/packages/gmail-adapter/src/tools.ts
Normal file
93
_ts_packages/packages/gmail-adapter/src/tools.ts
Normal 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");
|
||||
}
|
||||
40
_ts_packages/packages/gmail-adapter/src/types.ts
Normal file
40
_ts_packages/packages/gmail-adapter/src/types.ts
Normal 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;
|
||||
}
|
||||
44
_ts_packages/packages/gmail-adapter/test/client.test.ts
Normal file
44
_ts_packages/packages/gmail-adapter/test/client.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
43
_ts_packages/packages/gmail-adapter/test/tools.test.ts
Normal file
43
_ts_packages/packages/gmail-adapter/test/tools.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
29
_ts_packages/packages/gmail-adapter/test/types.test.ts
Normal file
29
_ts_packages/packages/gmail-adapter/test/types.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
10
_ts_packages/packages/gmail-adapter/tsconfig.json
Normal file
10
_ts_packages/packages/gmail-adapter/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "test/**/*"]
|
||||
}
|
||||
8
_ts_packages/packages/gmail-adapter/vitest.config.ts
Normal file
8
_ts_packages/packages/gmail-adapter/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
32
_ts_packages/packages/grok-adapter/package.json
Normal file
32
_ts_packages/packages/grok-adapter/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
78
_ts_packages/packages/grok-adapter/src/client.ts
Normal file
78
_ts_packages/packages/grok-adapter/src/client.ts
Normal 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 }>;
|
||||
}
|
||||
19
_ts_packages/packages/grok-adapter/src/index.ts
Normal file
19
_ts_packages/packages/grok-adapter/src/index.ts
Normal 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);
|
||||
}
|
||||
40
_ts_packages/packages/grok-adapter/src/tools.ts
Normal file
40
_ts_packages/packages/grok-adapter/src/tools.ts
Normal 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");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
31
_ts_packages/packages/grok-adapter/test/client.test.ts
Normal file
31
_ts_packages/packages/grok-adapter/test/client.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
36
_ts_packages/packages/grok-adapter/test/tools.test.ts
Normal file
36
_ts_packages/packages/grok-adapter/test/tools.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
10
_ts_packages/packages/grok-adapter/tsconfig.json
Normal file
10
_ts_packages/packages/grok-adapter/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "test/**/*"]
|
||||
}
|
||||
8
_ts_packages/packages/grok-adapter/vitest.config.ts
Normal file
8
_ts_packages/packages/grok-adapter/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
38
_ts_packages/packages/mcp-server/package.json
Normal file
38
_ts_packages/packages/mcp-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
53
_ts_packages/packages/mcp-server/src/adapters.ts
Normal file
53
_ts_packages/packages/mcp-server/src/adapters.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
61
_ts_packages/packages/mcp-server/src/errors.ts
Normal file
61
_ts_packages/packages/mcp-server/src/errors.ts
Normal 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 };
|
||||
}
|
||||
123
_ts_packages/packages/mcp-server/src/index.ts
Normal file
123
_ts_packages/packages/mcp-server/src/index.ts
Normal 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);
|
||||
});
|
||||
83
_ts_packages/packages/mcp-server/src/rust-bridge.ts
Normal file
83
_ts_packages/packages/mcp-server/src/rust-bridge.ts
Normal 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;
|
||||
}
|
||||
91
_ts_packages/packages/mcp-server/src/server.ts
Normal file
91
_ts_packages/packages/mcp-server/src/server.ts
Normal 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 };
|
||||
88
_ts_packages/packages/mcp-server/src/tool-registry.ts
Normal file
88
_ts_packages/packages/mcp-server/src/tool-registry.ts
Normal 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;
|
||||
}
|
||||
54
_ts_packages/packages/mcp-server/test/errors.test.ts
Normal file
54
_ts_packages/packages/mcp-server/test/errors.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
26
_ts_packages/packages/mcp-server/test/kei-routing.test.ts
Normal file
26
_ts_packages/packages/mcp-server/test/kei-routing.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
39
_ts_packages/packages/mcp-server/test/rust-bridge.test.ts
Normal file
39
_ts_packages/packages/mcp-server/test/rust-bridge.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
30
_ts_packages/packages/mcp-server/test/server-auth.test.ts
Normal file
30
_ts_packages/packages/mcp-server/test/server-auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
31
_ts_packages/packages/mcp-server/test/tool-registry.test.ts
Normal file
31
_ts_packages/packages/mcp-server/test/tool-registry.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
10
_ts_packages/packages/mcp-server/tsconfig.json
Normal file
10
_ts_packages/packages/mcp-server/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "test/**/*"]
|
||||
}
|
||||
8
_ts_packages/packages/mcp-server/vitest.config.ts
Normal file
8
_ts_packages/packages/mcp-server/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
32
_ts_packages/packages/recall-adapter/package.json
Normal file
32
_ts_packages/packages/recall-adapter/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
63
_ts_packages/packages/recall-adapter/src/client.ts
Normal file
63
_ts_packages/packages/recall-adapter/src/client.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
19
_ts_packages/packages/recall-adapter/src/index.ts
Normal file
19
_ts_packages/packages/recall-adapter/src/index.ts
Normal 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);
|
||||
}
|
||||
69
_ts_packages/packages/recall-adapter/src/tools.ts
Normal file
69
_ts_packages/packages/recall-adapter/src/tools.ts
Normal 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);
|
||||
}
|
||||
40
_ts_packages/packages/recall-adapter/test/client.test.ts
Normal file
40
_ts_packages/packages/recall-adapter/test/client.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
47
_ts_packages/packages/recall-adapter/test/tools.test.ts
Normal file
47
_ts_packages/packages/recall-adapter/test/tools.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
10
_ts_packages/packages/recall-adapter/tsconfig.json
Normal file
10
_ts_packages/packages/recall-adapter/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "test/**/*"]
|
||||
}
|
||||
8
_ts_packages/packages/recall-adapter/vitest.config.ts
Normal file
8
_ts_packages/packages/recall-adapter/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
33
_ts_packages/packages/telegram-adapter/package.json
Normal file
33
_ts_packages/packages/telegram-adapter/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
74
_ts_packages/packages/telegram-adapter/src/client.ts
Normal file
74
_ts_packages/packages/telegram-adapter/src/client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
23
_ts_packages/packages/telegram-adapter/src/index.ts
Normal file
23
_ts_packages/packages/telegram-adapter/src/index.ts
Normal 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);
|
||||
}
|
||||
112
_ts_packages/packages/telegram-adapter/src/tools.ts
Normal file
112
_ts_packages/packages/telegram-adapter/src/tools.ts
Normal 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);
|
||||
}
|
||||
51
_ts_packages/packages/telegram-adapter/src/types.ts
Normal file
51
_ts_packages/packages/telegram-adapter/src/types.ts
Normal 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;
|
||||
}
|
||||
26
_ts_packages/packages/telegram-adapter/test/client.test.ts
Normal file
26
_ts_packages/packages/telegram-adapter/test/client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
44
_ts_packages/packages/telegram-adapter/test/tools.test.ts
Normal file
44
_ts_packages/packages/telegram-adapter/test/tools.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
40
_ts_packages/packages/telegram-adapter/test/types.test.ts
Normal file
40
_ts_packages/packages/telegram-adapter/test/types.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
10
_ts_packages/packages/telegram-adapter/tsconfig.json
Normal file
10
_ts_packages/packages/telegram-adapter/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "test/**/*"]
|
||||
}
|
||||
8
_ts_packages/packages/telegram-adapter/vitest.config.ts
Normal file
8
_ts_packages/packages/telegram-adapter/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
34
_ts_packages/packages/youtube-adapter/package.json
Normal file
34
_ts_packages/packages/youtube-adapter/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
112
_ts_packages/packages/youtube-adapter/src/client.ts
Normal file
112
_ts_packages/packages/youtube-adapter/src/client.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
20
_ts_packages/packages/youtube-adapter/src/index.ts
Normal file
20
_ts_packages/packages/youtube-adapter/src/index.ts
Normal 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);
|
||||
}
|
||||
87
_ts_packages/packages/youtube-adapter/src/tools.ts
Normal file
87
_ts_packages/packages/youtube-adapter/src/tools.ts
Normal 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");
|
||||
}
|
||||
46
_ts_packages/packages/youtube-adapter/src/types.ts
Normal file
46
_ts_packages/packages/youtube-adapter/src/types.ts
Normal 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;
|
||||
}
|
||||
37
_ts_packages/packages/youtube-adapter/test/client.test.ts
Normal file
37
_ts_packages/packages/youtube-adapter/test/client.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
55
_ts_packages/packages/youtube-adapter/test/tools.test.ts
Normal file
55
_ts_packages/packages/youtube-adapter/test/tools.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
29
_ts_packages/packages/youtube-adapter/test/types.test.ts
Normal file
29
_ts_packages/packages/youtube-adapter/test/types.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
10
_ts_packages/packages/youtube-adapter/tsconfig.json
Normal file
10
_ts_packages/packages/youtube-adapter/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "test/**/*"]
|
||||
}
|
||||
8
_ts_packages/packages/youtube-adapter/vitest.config.ts
Normal file
8
_ts_packages/packages/youtube-adapter/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
22
_ts_packages/tsconfig.base.json
Normal file
22
_ts_packages/tsconfig.base.json
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue