Initial commit: Leexi MCP server (read-only, 6 tools)

This commit is contained in:
2026-04-22 12:16:07 +02:00
commit d4687fd545
13 changed files with 3370 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
.env
+95
View File
@@ -0,0 +1,95 @@
# leexi-mcp-server
Read-only MCP server for the [Leexi public API](https://docs.public-api.leexi.ai/reference/public-api). Provides access to meeting notes, transcripts, action items, and call metadata.
## Tools
| Tool | Description |
|------|-------------|
| `leexi_list_users` | List workspace users (name, email, UUID, team) |
| `leexi_list_teams` | List workspace teams |
| `leexi_list_calls` | List calls/meetings with filtering (date, owner, source, participant, customer) |
| `leexi_get_call` | Full call details: chapters, tasks, topics, speakers, AI summaries, transcript |
| `leexi_list_meeting_events` | List scheduled meeting events |
| `leexi_get_meeting_event` | Meeting event details with associated call recordings |
All tools support `response_format` parameter: `'markdown'` (default, human-readable) or `'json'` (structured data).
## Setup
### 1. Get API credentials
Generate API keys at https://app.leexi.ai/settings/api_keys
### 2. Build
```bash
cd ~/Workspace/AI/leexi-mcp-server
npm install
npm run build
```
### 3. Configure in OpenCode
Add to `~/.config/opencode/config.json` (or the relevant MCP config):
```json
{
"mcpServers": {
"leexi": {
"command": "node",
"args": ["/home/ssavinel/Workspace/AI/leexi-mcp-server/dist/index.js"],
"env": {
"LEEXI_KEY_ID": "<your-key-id>",
"LEEXI_KEY_SECRET": "<your-key-secret>"
}
}
}
}
```
Or for Claude Code (`~/.claude/claude_desktop_config.json`):
```json
{
"mcpServers": {
"leexi": {
"command": "node",
"args": ["/home/ssavinel/Workspace/AI/leexi-mcp-server/dist/index.js"],
"env": {
"LEEXI_KEY_ID": "<your-key-id>",
"LEEXI_KEY_SECRET": "<your-key-secret>"
}
}
}
}
```
### 4. Test with MCP Inspector (optional)
```bash
LEEXI_KEY_ID=<key> LEEXI_KEY_SECRET=<secret> npx @modelcontextprotocol/inspector node dist/index.js
```
## Data quality notes
- **Leexi AI summaries**: Machine-generated and may be imprecise. The `leexi_get_call` markdown output labels these clearly and suggests using chapters/transcript instead.
- **Speaker names**: In room-based calls, Leexi may use the room/device name as a speaker, representing multiple people. The tool flags speakers without email/phone as potentially being room names.
- **Multi-language transcripts**: Calls mixing languages may have transcript artifacts. The raw data is passed through for the consuming LLM to interpret.
## Architecture
```
src/
├── index.ts # Entry point, McpServer + stdio transport
├── types.ts # TypeScript interfaces for Leexi API responses
├── constants.ts # API base URL, character limit
├── services/
│ └── leexi-client.ts # Shared HTTP client (Basic auth, error handling)
├── schemas/
│ └── input-schemas.ts # Zod input validation for all tools
└── tools/
├── users.ts # leexi_list_users, leexi_list_teams
├── calls.ts # leexi_list_calls, leexi_get_call
└── meetings.ts # leexi_list_meeting_events, leexi_get_meeting_event
```
+1858
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "leexi-mcp-server",
"version": "1.0.0",
"description": "MCP server for Leexi API — read-only access to meeting notes, transcripts, and action items",
"type": "module",
"main": "dist/index.js",
"bin": {
"leexi-mcp-server": "dist/index.js"
},
"scripts": {
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"build": "tsc",
"clean": "rm -rf dist"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"axios": "^1.7.9",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
+4
View File
@@ -0,0 +1,4 @@
export const API_BASE_URL = "https://public-api.leexi.ai/v1";
export const CHARACTER_LIMIT = 25_000;
export const DEFAULT_PAGE_SIZE = 20;
export const MAX_PAGE_SIZE = 100;
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
/**
* Leexi MCP Server
*
* Read-only MCP server for the Leexi public API.
* Provides access to meeting notes, transcripts, action items, and call metadata.
*
* Environment variables:
* LEEXI_KEY_ID — API key ID (from https://app.leexi.ai/settings/api_keys)
* LEEXI_KEY_SECRET — API key secret
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerUserTools } from "./tools/users.js";
import { registerCallTools } from "./tools/calls.js";
import { registerMeetingTools } from "./tools/meetings.js";
const server = new McpServer({
name: "leexi-mcp-server",
version: "1.0.0",
});
// Register all tools
registerUserTools(server);
registerCallTools(server);
registerMeetingTools(server);
// Run with stdio transport
async function main(): Promise<void> {
if (!process.env.LEEXI_KEY_ID || !process.env.LEEXI_KEY_SECRET) {
console.error(
"ERROR: LEEXI_KEY_ID and LEEXI_KEY_SECRET environment variables are required.\n" +
"Generate API keys at https://app.leexi.ai/settings/api_keys"
);
process.exit(1);
}
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("leexi-mcp-server running via stdio");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
+156
View File
@@ -0,0 +1,156 @@
import { z } from "zod";
import { ResponseFormat } from "../types.js";
// ─── Shared ──────────────────────────────────────────────────────────────────
const responseFormatField = z
.nativeEnum(ResponseFormat)
.default(ResponseFormat.MARKDOWN)
.describe("Output format: 'markdown' for human-readable or 'json' for structured data");
const pageField = z
.number()
.int()
.min(1)
.default(1)
.describe("Page number (1-based)");
const itemsField = z
.number()
.int()
.min(1)
.max(100)
.default(20)
.describe("Results per page (1100)");
// ─── Users ───────────────────────────────────────────────────────────────────
export const ListUsersInputSchema = z
.object({
page: pageField,
items: itemsField,
response_format: responseFormatField,
})
.strict();
export type ListUsersInput = z.infer<typeof ListUsersInputSchema>;
// ─── Teams ───────────────────────────────────────────────────────────────────
export const ListTeamsInputSchema = z
.object({
page: pageField,
items: itemsField,
response_format: responseFormatField,
})
.strict();
export type ListTeamsInput = z.infer<typeof ListTeamsInputSchema>;
// ─── Calls (list) ────────────────────────────────────────────────────────────
export const ListCallsInputSchema = z
.object({
page: pageField,
items: itemsField,
order: z
.enum([
"created_at desc",
"created_at asc",
"performed_at desc",
"performed_at asc",
"updated_at desc",
"updated_at asc",
])
.default("performed_at desc")
.describe("Sort order"),
date_filter: z
.enum(["created_at", "performed_at", "updated_at"])
.default("performed_at")
.describe("Which date field the from/to filters apply to"),
from: z
.string()
.optional()
.describe("Start date filter (ISO 8601, e.g. 2024-06-01T00:00:00.000Z)"),
to: z
.string()
.optional()
.describe("End date filter (ISO 8601)"),
source: z
.string()
.optional()
.describe("Integration slug filter (e.g. 'aircall', 'gmeet_bot')"),
owner_uuid: z
.array(z.string())
.optional()
.describe("Filter by owner user UUID(s)"),
participating_user_uuid: z
.array(z.string())
.optional()
.describe("Filter by participating user UUID(s)"),
customer_email_address: z
.array(z.string())
.optional()
.describe("Filter by customer email address(es)"),
customer_phone_number: z
.array(z.string())
.optional()
.describe("Filter by customer phone number(s)"),
with_simple_transcript: z
.boolean()
.default(false)
.describe("Include paragraph-level transcript in list results"),
response_format: responseFormatField,
})
.strict();
export type ListCallsInput = z.infer<typeof ListCallsInputSchema>;
// ─── Call (detail) ───────────────────────────────────────────────────────────
export const GetCallInputSchema = z
.object({
uuid: z.string().describe("Call UUID"),
response_format: responseFormatField,
})
.strict();
export type GetCallInput = z.infer<typeof GetCallInputSchema>;
// ─── Meeting Events (list) ───────────────────────────────────────────────────
export const ListMeetingEventsInputSchema = z
.object({
page: pageField,
items: itemsField,
order: z
.enum([
"created_at desc",
"created_at asc",
"start_time desc",
"start_time asc",
"end_time desc",
"end_time asc",
])
.default("start_time desc")
.describe("Sort order"),
created_by: z
.enum(["calendar", "manual", "api"])
.optional()
.describe("Filter by origin type"),
response_format: responseFormatField,
})
.strict();
export type ListMeetingEventsInput = z.infer<typeof ListMeetingEventsInputSchema>;
// ─── Meeting Event (detail) ──────────────────────────────────────────────────
export const GetMeetingEventInputSchema = z
.object({
uuid: z.string().describe("Meeting event UUID"),
response_format: responseFormatField,
})
.strict();
export type GetMeetingEventInput = z.infer<typeof GetMeetingEventInputSchema>;
+74
View File
@@ -0,0 +1,74 @@
import axios, { AxiosError, AxiosInstance } from "axios";
import { API_BASE_URL } from "../constants.js";
let client: AxiosInstance | null = null;
export function getClient(): AxiosInstance {
if (client) return client;
const keyId = process.env.LEEXI_KEY_ID;
const keySecret = process.env.LEEXI_KEY_SECRET;
if (!keyId || !keySecret) {
throw new Error(
"Missing LEEXI_KEY_ID or LEEXI_KEY_SECRET environment variables. " +
"Generate API keys at https://app.leexi.ai/settings/api_keys"
);
}
const encoded = Buffer.from(`${keyId}:${keySecret}`).toString("base64");
client = axios.create({
baseURL: API_BASE_URL,
timeout: 30_000,
headers: {
Authorization: `Basic ${encoded}`,
"Content-Type": "application/json",
Accept: "application/json",
},
});
return client;
}
export async function apiGet<T>(
endpoint: string,
params?: Record<string, unknown>
): Promise<T> {
const http = getClient();
const response = await http.get<T>(endpoint, { params });
return response.data;
}
export function handleApiError(error: unknown): string {
if (error instanceof AxiosError) {
if (error.response) {
const status = error.response.status;
const body =
typeof error.response.data === "object"
? JSON.stringify(error.response.data)
: String(error.response.data);
switch (status) {
case 400:
return `Error: Bad request. ${body}`;
case 401:
return "Error: Unauthorized. Check your LEEXI_KEY_ID and LEEXI_KEY_SECRET.";
case 403:
return "Error: Forbidden. Your API key may lack the required permissions.";
case 404:
return "Error: Resource not found. Check the UUID is correct.";
case 429:
return "Error: Rate limit exceeded. Wait before making more requests.";
default:
return `Error: Leexi API returned status ${status}. ${body}`;
}
} else if (error.code === "ECONNABORTED") {
return "Error: Request to Leexi timed out. Try again.";
} else if (error.code === "ECONNREFUSED") {
return "Error: Could not connect to Leexi API. Check your network.";
}
}
return `Error: ${error instanceof Error ? error.message : String(error)}`;
}
+406
View File
@@ -0,0 +1,406 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { apiGet, handleApiError } from "../services/leexi-client.js";
import {
ListCallsInputSchema,
GetCallInputSchema,
type ListCallsInput,
type GetCallInput,
} from "../schemas/input-schemas.js";
import type {
PaginatedResponse,
LeexiCallListItem,
LeexiCallDetail,
LeexiCallDetailResponse,
LeexiSpeaker,
LeexiChapter,
LeexiTask,
LeexiPrompt,
LeexiCallTopic,
LeexiTranscriptSegment,
} from "../types.js";
import { ResponseFormat } from "../types.js";
import { CHARACTER_LIMIT } from "../constants.js";
// ─── Formatting helpers ──────────────────────────────────────────────────────
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
function formatTimestamp(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
function flagSpeakerQuality(speaker: LeexiSpeaker): string | null {
// Flag speakers that look like room/device names rather than real people
if (!speaker.is_user && !speaker.email_address && !speaker.phone_number) {
return "possible room/device name — may represent multiple people";
}
return null;
}
function formatCallListItemMarkdown(call: LeexiCallListItem): string {
const lines: string[] = [
`### ${call.title || "(untitled)"}`,
`- **Date**: ${call.performed_at}`,
`- **Duration**: ${formatDuration(call.duration)}`,
`- **Owner**: ${call.owner.name} (${call.owner.email})`,
`- **Direction**: ${call.direction} | **Source**: ${call.source}`,
`- **UUID**: \`${call.uuid}\``,
];
if (call.participating_users.length > 1) {
const others = call.participating_users
.filter((u) => u.uuid !== call.owner.uuid)
.map((u) => `${u.name} (${u.email})`)
.join(", ");
if (others) lines.push(`- **Other participants**: ${others}`);
}
if (call.prompts.length > 0) {
const summary = call.prompts.find((p) => p.category === "summary");
if (summary && summary.completions.length > 0) {
const text = summary.completions[0];
lines.push(`- **Leexi Summary**: ${text.length > 200 ? text.slice(0, 200) + "..." : text}`);
}
}
if (call.tasks.length > 0) {
lines.push(`- **Tasks**: ${call.tasks.length} action item(s)`);
}
return lines.join("\n");
}
function formatCallDetailMarkdown(call: LeexiCallDetail): string {
const sections: string[] = [];
// Header
sections.push(
`# ${call.title || "(untitled)"}`,
"",
`| Field | Value |`,
`|-------|-------|`,
`| Date | ${call.performed_at} |`,
`| Duration | ${formatDuration(call.duration)} |`,
`| Owner | ${call.owner.name} (${call.owner.email}) |`,
`| Direction | ${call.direction} |`,
`| Source | ${call.source} |`,
`| Locale | ${call.locale} |`,
`| UUID | \`${call.uuid}\` |`,
`| Leexi URL | ${call.leexi_url} |`,
""
);
if (call.description) {
sections.push(`**Description**: ${call.description}`, "");
}
// Chapters
if (call.chapters.length > 0) {
sections.push("## Chapters", "");
for (const ch of sortChapters(call.chapters)) {
sections.push(`### ${ch.title} (${formatTimestamp(ch.start_time)})`, "", ch.text, "");
}
}
// Tasks
if (call.tasks.length > 0) {
sections.push("## Action Items", "");
for (const task of call.tasks) {
sections.push(formatTaskMarkdown(task));
}
sections.push("");
}
// Topics
if (call.call_topics.length > 0) {
sections.push("## Topics Discussed", "");
for (const topic of call.call_topics) {
sections.push(formatTopicMarkdown(topic, call.speakers));
}
sections.push("");
}
// Speakers
sections.push("## Speakers", "");
for (const speaker of call.speakers) {
sections.push(formatSpeakerMarkdown(speaker));
}
sections.push("");
// Leexi AI Summaries (labeled with provenance)
if (call.prompts.length > 0) {
sections.push(
"## Leexi AI Summaries",
"",
"_Note: These summaries are generated by Leexi's AI and may be imprecise. " +
"Prefer the chapters and transcript for accurate information._",
""
);
for (const prompt of call.prompts) {
sections.push(formatPromptMarkdown(prompt));
}
sections.push("");
}
// Transcript
if (call.transcript && call.transcript.length > 0) {
sections.push("## Transcript", "");
const transcriptText = formatTranscriptMarkdown(call.transcript, call.speakers);
sections.push(transcriptText);
} else if (call.simple_transcript) {
sections.push("## Transcript", "", call.simple_transcript);
}
return sections.join("\n");
}
function sortChapters(chapters: LeexiChapter[]): LeexiChapter[] {
return [...chapters].sort((a, b) => a.index - b.index);
}
function formatTaskMarkdown(task: LeexiTask): string {
const checkbox = task.done ? "[x]" : "[ ]";
const assignee = task.speaker ? ` (${task.speaker.name})` : "";
return `- ${checkbox} **${task.subject}**${assignee}${task.description ? `: ${task.description}` : ""}`;
}
function formatTopicMarkdown(
topic: LeexiCallTopic,
speakers: LeexiSpeaker[]
): string {
const speaker = topic.speaker
? topic.speaker.name
: speakers[0]?.name ?? "Unknown";
return `- **${topic.topic_name}** — "${topic.keyphrase}" (${formatTimestamp(topic.start_time)}${formatTimestamp(topic.end_time)}, ${speaker})`;
}
function formatSpeakerMarkdown(speaker: LeexiSpeaker): string {
const lines: string[] = [
`### ${speaker.name}${speaker.is_user ? " (internal)" : " (external)"}`,
`- Talk time: ${formatDuration(speaker.duration)} | Longest monologue: ${formatDuration(speaker.longest_monologue)}`,
];
if (speaker.email_address) lines.push(`- Email: ${speaker.email_address}`);
if (speaker.phone_number) lines.push(`- Phone: ${speaker.phone_number}`);
const flag = flagSpeakerQuality(speaker);
if (flag) {
lines.push(`- **Data quality note**: ${flag}`);
}
return lines.join("\n");
}
function formatPromptMarkdown(prompt: LeexiPrompt): string {
const lines = [`### ${prompt.title} (${prompt.category})`];
for (const completion of prompt.completions) {
lines.push("", completion);
}
return lines.join("\n");
}
function formatTranscriptMarkdown(
segments: LeexiTranscriptSegment[],
speakers: LeexiSpeaker[]
): string {
const lines: string[] = [];
for (const seg of segments) {
const speakerName =
speakers[seg.speaker_index]?.name ?? `Speaker ${seg.speaker_index}`;
const text = seg.items.map((w) => w.content).join(" ");
lines.push(
`**${speakerName}** (${formatTimestamp(seg.start_time)}${formatTimestamp(seg.end_time)}):`,
text,
""
);
}
return lines.join("\n");
}
// ─── Registration ────────────────────────────────────────────────────────────
export function registerCallTools(server: McpServer): void {
// ── leexi_list_calls ───────────────────────────────────────────────────
server.registerTool(
"leexi_list_calls",
{
title: "List Leexi Calls & Meetings",
description: `List calls and meetings in the Leexi workspace with filtering options.
Supports filtering by date range, owner, source integration, participant, customer email, and customer phone number. Returns call metadata, speakers, chapters summary, AI summaries, and task count. Use leexi_get_call with a UUID for full transcript and details.
Args:
- page (number): Page number, 1-based (default: 1)
- items (number): Results per page, 1100 (default: 20)
- order (string): Sort order (default: 'performed_at desc')
- date_filter (string): Which date field from/to apply to (default: 'performed_at')
- from (string, optional): Start date (ISO 8601)
- to (string, optional): End date (ISO 8601)
- source (string, optional): Integration slug (e.g. 'aircall', 'gmeet_bot')
- owner_uuid (string[], optional): Filter by owner UUID(s)
- participating_user_uuid (string[], optional): Filter by participant UUID(s)
- customer_email_address (string[], optional): Filter by customer email(s)
- customer_phone_number (string[], optional): Filter by customer phone(s)
- with_simple_transcript (boolean): Include paragraph-level transcript (default: false)
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns paginated list of calls with metadata, speakers, chapters, AI summaries, and tasks.`,
inputSchema: ListCallsInputSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params: ListCallsInput) => {
try {
const queryParams: Record<string, unknown> = {
page: params.page,
items: params.items,
order: params.order,
date_filter: params.date_filter,
with_simple_transcript: params.with_simple_transcript,
};
if (params.from) queryParams.from = params.from;
if (params.to) queryParams.to = params.to;
if (params.source) queryParams.source = params.source;
if (params.owner_uuid) queryParams["owner_uuid[]"] = params.owner_uuid;
if (params.participating_user_uuid)
queryParams["participating_user_uuid[]"] = params.participating_user_uuid;
if (params.customer_email_address)
queryParams["customer_email_address[]"] = params.customer_email_address;
if (params.customer_phone_number)
queryParams["customer_phone_number[]"] = params.customer_phone_number;
const data = await apiGet<PaginatedResponse<LeexiCallListItem>>(
"/calls",
queryParams
);
const output = {
calls: data.data,
pagination: data.pagination,
};
let text: string;
if (params.response_format === ResponseFormat.MARKDOWN) {
const { pagination } = data;
const lines = [
`# Leexi Calls (page ${pagination.page}, ${data.data.length}/${pagination.count})`,
"",
];
if (data.data.length === 0) {
lines.push("_No calls found matching the filters._");
}
for (const call of data.data) {
lines.push(formatCallListItemMarkdown(call), "");
}
if (pagination.page * pagination.items < pagination.count) {
lines.push(
`_More results available — request page ${pagination.page + 1}_`
);
}
text = lines.join("\n");
} else {
text = JSON.stringify(output, null, 2);
}
return {
content: [{ type: "text" as const, text }],
structuredContent: output,
};
} catch (error) {
return {
content: [{ type: "text" as const, text: handleApiError(error) }],
};
}
}
);
// ── leexi_get_call ────────────────────────────────────────────────────
server.registerTool(
"leexi_get_call",
{
title: "Get Leexi Call Details",
description: `Get full details of a single call or meeting by UUID. Returns chapters, action items, topics, speakers, Leexi AI summaries, and the full word-level transcript.
Data quality notes:
- Leexi AI summaries are machine-generated and may be imprecise — prefer chapters and transcript.
- External speakers without email/phone may be room or device names representing multiple people.
- Multi-language calls may have transcript artifacts.
The markdown output prioritizes: Chapters > Action Items > Topics > Speakers > AI Summaries > Transcript.
Args:
- uuid (string): Call UUID (get from leexi_list_calls)
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns full call details including transcript.`,
inputSchema: GetCallInputSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params: GetCallInput) => {
try {
const data = await apiGet<LeexiCallDetailResponse>(
`/calls/${params.uuid}`
);
const call = data.data;
let text: string;
if (params.response_format === ResponseFormat.MARKDOWN) {
text = formatCallDetailMarkdown(call);
// Truncate if too long
if (text.length > CHARACTER_LIMIT) {
// Try to keep everything except transcript
const withoutTranscript = text.split("## Transcript")[0];
if (withoutTranscript && withoutTranscript.length < CHARACTER_LIMIT) {
text =
withoutTranscript +
`## Transcript\n\n_Transcript truncated (${text.length} chars exceeded ${CHARACTER_LIMIT} limit). ` +
`Request with response_format='json' for the full transcript data._`;
} else {
text =
text.slice(0, CHARACTER_LIMIT) +
`\n\n_Response truncated at ${CHARACTER_LIMIT} characters. ` +
`Request with response_format='json' for complete data._`;
}
}
} else {
text = JSON.stringify(call, null, 2);
if (text.length > CHARACTER_LIMIT) {
// Return without transcript to stay within limit
const { transcript, ...rest } = call;
const reduced = {
...rest,
transcript_truncated: true,
transcript_segment_count: transcript?.length ?? 0,
note: `Full transcript (${transcript?.length ?? 0} segments) omitted to stay within character limit. Fetch the recording or use the Leexi UI for the full transcript.`,
};
text = JSON.stringify(reduced, null, 2);
}
}
return {
content: [{ type: "text" as const, text }],
};
} catch (error) {
return {
content: [{ type: "text" as const, text: handleApiError(error) }],
};
}
}
);
}
+255
View File
@@ -0,0 +1,255 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { apiGet, handleApiError } from "../services/leexi-client.js";
import {
ListMeetingEventsInputSchema,
GetMeetingEventInputSchema,
type ListMeetingEventsInput,
type GetMeetingEventInput,
} from "../schemas/input-schemas.js";
import type {
PaginatedResponse,
LeexiMeetingEventListItem,
LeexiMeetingEventDetailResponse,
LeexiMeetingEventDetail,
} from "../types.js";
import { ResponseFormat } from "../types.js";
// ─── Formatting helpers ──────────────────────────────────────────────────────
function formatMeetingEventListMarkdown(event: LeexiMeetingEventListItem): string {
const lines: string[] = [
`### ${event.title || "(untitled)"}`,
`- **When**: ${event.start_time}${event.end_time}`,
`- **Provider**: ${event.meeting_provider ?? "unknown"}`,
`- **UUID**: \`${event.uuid}\``,
];
if (event.organizer) {
lines.push(`- **Organizer**: ${event.organizer.email}`);
}
if (event.attendees.length > 0) {
lines.push(
`- **Attendees**: ${event.attendees.map((a) => a.email).join(", ")}`
);
}
const flags: string[] = [];
if (event.to_record) flags.push("to_record");
if (event.bot_scheduled) flags.push("bot_scheduled");
if (event.bot_running) flags.push("bot_running");
if (event.internal) flags.push("internal");
if (flags.length > 0) lines.push(`- **Flags**: ${flags.join(", ")}`);
if (event.bot_runs.length > 0) {
lines.push(`- **Bot runs**: ${event.bot_runs.length}`);
}
lines.push(`- **Origin**: ${event.origin} | **Active**: ${event.active}`);
return lines.join("\n");
}
function formatMeetingEventDetailMarkdown(event: LeexiMeetingEventDetail): string {
const sections: string[] = [];
sections.push(
`# ${event.title || "(untitled)"}`,
"",
`| Field | Value |`,
`|-------|-------|`,
`| When | ${event.start_time}${event.end_time} |`,
`| Provider | ${event.meeting_provider ?? "unknown"} |`,
`| Meeting URL | ${event.meeting_url} |`,
`| UUID | \`${event.uuid}\` |`,
`| Origin | ${event.origin} |`,
`| Internal | ${event.internal} |`,
`| Direction | ${event.direction ?? "—"} |`,
`| To record | ${event.to_record} |`,
`| Bot scheduled | ${event.bot_scheduled} |`,
`| Bot running | ${event.bot_running} |`,
`| Active | ${event.active} |`,
""
);
if (event.description) {
sections.push(`**Description**: ${event.description}`, "");
}
if (event.organizer) {
sections.push(`**Organizer**: ${event.organizer.email}`, "");
}
if (event.attendees.length > 0) {
sections.push(
"## Attendees",
"",
...event.attendees.map((a) => `- ${a.email}`),
""
);
}
if (event.bot_runs.length > 0) {
sections.push("## Bot Runs", "");
for (const run of event.bot_runs) {
sections.push(
`- **${run.uuid}**: ${run.start_time}${run.end_time}` +
(run.recording_start_time
? ` (recording started ${run.recording_start_time})`
: "") +
(run.end_reason ? ` — ended: ${run.end_reason}` : "")
);
}
sections.push("");
}
if (event.integration_user) {
const iu = event.integration_user;
sections.push(
"## Integration",
"",
`- **User**: ${iu.name} (${iu.email})`,
`- **Integration**: ${iu.integration.name} (${iu.integration.slug})`,
""
);
if (iu.calls.length > 0) {
sections.push("### Associated Calls", "");
for (const call of iu.calls) {
sections.push(
`- \`${call.uuid}\`${call.performed_at} (${call.source}, ${Math.round(call.duration)}s)` +
(call.leexi_url ? ` [View](${call.leexi_url})` : "")
);
}
sections.push("");
}
}
return sections.join("\n");
}
// ─── Registration ────────────────────────────────────────────────────────────
export function registerMeetingTools(server: McpServer): void {
// ── leexi_list_meeting_events ──────────────────────────────────────────
server.registerTool(
"leexi_list_meeting_events",
{
title: "List Leexi Meeting Events",
description: `List scheduled meeting events in the Leexi workspace. Meeting events represent calendar entries that may or may not have been recorded. Use leexi_get_meeting_event for details and associated call recordings.
Args:
- page (number): Page number, 1-based (default: 1)
- items (number): Results per page, 1100 (default: 20)
- order (string): Sort order (default: 'start_time desc')
- created_by (string, optional): Filter by origin — 'calendar', 'manual', or 'api'
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns paginated list of meeting events.`,
inputSchema: ListMeetingEventsInputSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params: ListMeetingEventsInput) => {
try {
const queryParams: Record<string, unknown> = {
page: params.page,
items: params.items,
order: params.order,
};
if (params.created_by) queryParams.created_by = params.created_by;
const data = await apiGet<PaginatedResponse<LeexiMeetingEventListItem>>(
"/meeting_events",
queryParams
);
const output = {
meeting_events: data.data,
pagination: data.pagination,
};
let text: string;
if (params.response_format === ResponseFormat.MARKDOWN) {
const { pagination } = data;
const lines = [
`# Leexi Meeting Events (page ${pagination.page}, ${data.data.length}/${pagination.count})`,
"",
];
if (data.data.length === 0) {
lines.push("_No meeting events found._");
}
for (const event of data.data) {
lines.push(formatMeetingEventListMarkdown(event), "");
}
if (pagination.page * pagination.items < pagination.count) {
lines.push(
`_More results available — request page ${pagination.page + 1}_`
);
}
text = lines.join("\n");
} else {
text = JSON.stringify(output, null, 2);
}
return {
content: [{ type: "text" as const, text }],
structuredContent: output,
};
} catch (error) {
return {
content: [{ type: "text" as const, text: handleApiError(error) }],
};
}
}
);
// ── leexi_get_meeting_event ───────────────────────────────────────────
server.registerTool(
"leexi_get_meeting_event",
{
title: "Get Leexi Meeting Event Details",
description: `Get full details of a single meeting event by UUID. Returns meeting metadata, attendees, bot run history, integration info, and links to associated call recordings. Use the call UUIDs from the response with leexi_get_call for transcripts and notes.
Args:
- uuid (string): Meeting event UUID (get from leexi_list_meeting_events)
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns meeting event details with associated call links.`,
inputSchema: GetMeetingEventInputSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params: GetMeetingEventInput) => {
try {
const data = await apiGet<LeexiMeetingEventDetailResponse>(
`/meeting_events/${params.uuid}`
);
const event = data.data;
let text: string;
if (params.response_format === ResponseFormat.MARKDOWN) {
text = formatMeetingEventDetailMarkdown(event);
} else {
text = JSON.stringify(event, null, 2);
}
return {
content: [{ type: "text" as const, text }],
};
} catch (error) {
return {
content: [{ type: "text" as const, text: handleApiError(error) }],
};
}
}
);
}
+161
View File
@@ -0,0 +1,161 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { apiGet, handleApiError } from "../services/leexi-client.js";
import {
ListUsersInputSchema,
ListTeamsInputSchema,
type ListUsersInput,
type ListTeamsInput,
} from "../schemas/input-schemas.js";
import type {
PaginatedResponse,
LeexiUser,
LeexiTeam,
} from "../types.js";
import { ResponseFormat } from "../types.js";
// ─── Formatting helpers ──────────────────────────────────────────────────────
function formatUserMarkdown(user: LeexiUser): string {
const lines: string[] = [
`### ${user.name}`,
`- **Email**: ${user.email}`,
`- **UUID**: \`${user.uuid}\``,
];
if (user.team) {
lines.push(`- **Team**: ${user.team.name}${user.team.active ? "" : " (inactive)"}`);
}
return lines.join("\n");
}
function formatTeamMarkdown(team: LeexiTeam): string {
return `### ${team.name}\n- **UUID**: \`${team.uuid}\`\n- **Active**: ${team.active}`;
}
// ─── Registration ────────────────────────────────────────────────────────────
export function registerUserTools(server: McpServer): void {
// ── leexi_list_users ────────────────────────────────────────────────────
server.registerTool(
"leexi_list_users",
{
title: "List Leexi Users",
description: `List all users in the Leexi workspace. Returns user name, email, UUID, and team assignment. Use UUIDs from this endpoint as owner_uuid or participating_user_uuid filters in leexi_list_calls.
Args:
- page (number): Page number, 1-based (default: 1)
- items (number): Results per page, 1100 (default: 20)
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns paginated list of users with name, email, UUID, and team info.`,
inputSchema: ListUsersInputSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params: ListUsersInput) => {
try {
const data = await apiGet<PaginatedResponse<LeexiUser>>("/users", {
page: params.page,
items: params.items,
});
const output = {
users: data.data,
pagination: data.pagination,
};
let text: string;
if (params.response_format === ResponseFormat.MARKDOWN) {
const { pagination } = data;
const lines = [
`# Leexi Users (page ${pagination.page}, ${data.data.length}/${pagination.count})`,
"",
];
for (const user of data.data) {
lines.push(formatUserMarkdown(user), "");
}
if (pagination.page * pagination.items < pagination.count) {
lines.push(
`_More results available — request page ${pagination.page + 1}_`
);
}
text = lines.join("\n");
} else {
text = JSON.stringify(output, null, 2);
}
return {
content: [{ type: "text" as const, text }],
structuredContent: output,
};
} catch (error) {
return {
content: [{ type: "text" as const, text: handleApiError(error) }],
};
}
}
);
// ── leexi_list_teams ───────────────────────────────────────────────────
server.registerTool(
"leexi_list_teams",
{
title: "List Leexi Teams",
description: `List all teams in the Leexi workspace. Returns team name, UUID, and active status.
Args:
- page (number): Page number, 1-based (default: 1)
- items (number): Results per page, 1100 (default: 20)
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns paginated list of teams.`,
inputSchema: ListTeamsInputSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params: ListTeamsInput) => {
try {
const data = await apiGet<PaginatedResponse<LeexiTeam>>("/teams", {
page: params.page,
items: params.items,
});
const output = {
teams: data.data,
pagination: data.pagination,
};
let text: string;
if (params.response_format === ResponseFormat.MARKDOWN) {
const { pagination } = data;
const lines = [
`# Leexi Teams (page ${pagination.page}, ${data.data.length}/${pagination.count})`,
"",
];
for (const team of data.data) {
lines.push(formatTeamMarkdown(team), "");
}
text = lines.join("\n");
} else {
text = JSON.stringify(output, null, 2);
}
return {
content: [{ type: "text" as const, text }],
structuredContent: output,
};
} catch (error) {
return {
content: [{ type: "text" as const, text: handleApiError(error) }],
};
}
}
);
}
+262
View File
@@ -0,0 +1,262 @@
// ─── Pagination ──────────────────────────────────────────────────────────────
export interface LeexiPagination {
page: number;
items: number;
count: number;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: LeexiPagination;
}
// ─── Users & Teams ───────────────────────────────────────────────────────────
export interface LeexiTeam {
uuid: string;
name: string;
active: boolean;
}
export interface LeexiUser {
uuid: string;
name: string;
email: string;
created_at: string;
updated_at: string;
team: LeexiTeam | null;
}
// ─── Speakers ────────────────────────────────────────────────────────────────
export interface LeexiSpeaker {
uuid: string;
name: string;
index: number;
is_user: boolean;
email_address: string | null;
phone_number: string | null;
duration: number;
longest_monologue: number;
}
// ─── Chapters ────────────────────────────────────────────────────────────────
export interface LeexiChapter {
uuid: string;
index: number;
title: string;
text: string;
start_time: number;
}
// ─── Prompts (AI-generated summaries) ────────────────────────────────────────
export interface LeexiPrompt {
uuid: string;
category: string;
title: string;
completions: string[];
}
// ─── Tasks ───────────────────────────────────────────────────────────────────
export interface LeexiTaskEditor {
uuid: string;
name: string;
email: string;
active: boolean;
}
export interface LeexiTask {
uuid: string;
subject: string;
description: string;
done: boolean;
active: boolean;
created_at: string;
updated_at: string;
last_editor: LeexiTaskEditor | null;
speaker: LeexiSpeaker | null;
}
// ─── Call Topics ─────────────────────────────────────────────────────────────
export interface LeexiCallTopic {
uuid: string;
topic_name: string;
keyphrase: string;
start_time: number;
end_time: number;
created_at: string;
updated_at: string;
speaker: Pick<LeexiSpeaker, "uuid" | "name" | "index" | "is_user" | "email_address" | "phone_number"> | null;
}
// ─── Transcript ──────────────────────────────────────────────────────────────
export interface LeexiTranscriptWord {
content: string;
start_time: number;
end_time: number;
}
export interface LeexiTranscriptSegment {
speaker_index: number;
start_time: number;
end_time: number;
items: LeexiTranscriptWord[];
}
// ─── Conversation Type ───────────────────────────────────────────────────────
export interface LeexiConversationType {
uuid: string;
slug: string;
active: boolean;
}
// ─── Owner / Participant ─────────────────────────────────────────────────────
export interface LeexiUserRef {
uuid: string;
name: string;
email: string;
}
// ─── Call (list response) ────────────────────────────────────────────────────
export interface LeexiCallListItem {
uuid: string;
title: string;
description: string;
source: string;
source_id: string;
direction: string;
duration: number;
is_video: boolean;
locale: string;
visible: boolean;
created_at: string;
updated_at: string;
performed_at: string;
leexi_url: string;
recording_url: string | null;
transcript_url: string | null;
audio_archived_at: string | null;
video_archived_at: string | null;
transcript_archived_at: string | null;
completions_archived_at: string | null;
owner: LeexiUserRef;
participating_users: LeexiUserRef[];
speakers: LeexiSpeaker[];
chapters: LeexiChapter[];
prompts: LeexiPrompt[];
tasks: LeexiTask[];
conversation_type: LeexiConversationType | null;
customer_email_addresses: string[];
customer_phone_numbers: string[];
deal: unknown;
feedbacks: unknown[];
scorecards: unknown[];
meeting_event: unknown;
simple_transcript?: string;
}
// ─── Call (detail response — extends list item) ──────────────────────────────
export interface LeexiCallDetail extends LeexiCallListItem {
call_topics: LeexiCallTopic[];
transcript: LeexiTranscriptSegment[];
}
export interface LeexiCallDetailResponse {
data: LeexiCallDetail;
}
// ─── Meeting Events ──────────────────────────────────────────────────────────
export interface LeexiBotRun {
uuid: string;
start_time: string;
end_time: string;
recording_start_time: string | null;
end_reason: string | null;
}
export interface LeexiMeetingEventListItem {
uuid: string;
title: string;
organizer: { email: string } | null;
attendees: { email: string }[];
meeting_url: string;
meeting_provider: string | null;
internal: boolean;
direction: string | null;
start_time: string;
end_time: string;
description: string | null;
owned: boolean;
to_record: boolean;
bot_scheduled: boolean;
bot_running: boolean;
bot_runs: LeexiBotRun[];
origin: string;
created_at: string;
updated_at: string;
recording_notified: boolean;
active: boolean;
}
export interface LeexiMeetingEventCallRef {
uuid: string;
source: string;
source_id: string;
created_at: string;
updated_at: string;
performed_at: string;
locale: string;
duration: number;
direction: string;
is_video: boolean;
visible: boolean;
recording_url: string | null;
transcript_url: string | null;
leexi_url: string;
}
export interface LeexiIntegrationUser {
uuid: string;
name: string;
email: string;
active: boolean;
user: LeexiUserRef;
integration: {
active: boolean;
category: string;
logo: string;
name: string;
slug: string;
url: string;
uuid: string;
};
calls: LeexiMeetingEventCallRef[];
}
export interface LeexiMeetingEventDetail extends LeexiMeetingEventListItem {
integration_user: LeexiIntegrationUser | null;
}
export interface LeexiMeetingEventDetailResponse {
success: boolean;
data: LeexiMeetingEventDetail;
message: string;
}
// ─── Response format enum ────────────────────────────────────────────────────
export enum ResponseFormat {
MARKDOWN = "markdown",
JSON = "json",
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}