Initial commit: Leexi MCP server (read-only, 6 tools)
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
@@ -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
|
||||||
|
```
|
||||||
Generated
+1858
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 (1–100)");
|
||||||
|
|
||||||
|
// ─── 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>;
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -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, 1–100 (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) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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, 1–100 (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) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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, 1–100 (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, 1–100 (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
@@ -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",
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user