- Updated .env.example to include new configuration options for the UltiAI branding and API endpoints. - Enhanced Nginx configuration to support new API routes for the MCP and WebSocket connections. - Introduced sub-filters for branding adjustments in Nginx responses. - Added new JavaScript patch for API endpoint adjustments. - Implemented tests for new API functionalities and improved error handling in the AI gateway.
727 lines
29 KiB
JavaScript
727 lines
29 KiB
JavaScript
import { randomUUID } from "node:crypto";
|
|
import express from "express";
|
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
import { z } from "zod";
|
|
const PORT = Number(process.env.MCP_PORT ?? 3100);
|
|
const API_BASE = (process.env.ULTID_API_URL ?? "http://localhost:8080/api/v1").replace(/\/$/, "");
|
|
const TOOL_GROUPS = {
|
|
mail: [
|
|
"mail_search",
|
|
"mail_read",
|
|
"mail_list",
|
|
"mail_send",
|
|
"mail_update_labels",
|
|
"mail_update_flags",
|
|
"mail_delete",
|
|
],
|
|
drive: [
|
|
"drive_list",
|
|
"drive_file_info",
|
|
"drive_create_file",
|
|
"drive_create_folder",
|
|
"drive_delete",
|
|
"drive_move",
|
|
"drive_rename",
|
|
"drive_share_create",
|
|
"drive_share_update",
|
|
"drive_share_delete",
|
|
],
|
|
contacts: [
|
|
"contacts_search",
|
|
"contacts_list_books",
|
|
"contacts_list",
|
|
"contacts_get",
|
|
"contacts_create",
|
|
"contacts_update",
|
|
"contacts_delete",
|
|
],
|
|
search: ["suite_search"],
|
|
docs: ["docs_read", "docs_save"],
|
|
agenda: [
|
|
"calendar_list",
|
|
"calendar_create",
|
|
"calendar_update",
|
|
"calendar_delete",
|
|
"calendar_list_events",
|
|
"calendar_freebusy",
|
|
"calendar_create_event",
|
|
"calendar_update_event",
|
|
"calendar_delete_event",
|
|
"calendar_respond_invitation",
|
|
"calendar_create_meet_link",
|
|
],
|
|
web_search: ["web_search"],
|
|
};
|
|
const ALL_TOOL_NAMES = [...new Set(Object.values(TOOL_GROUPS).flat())];
|
|
function parseEnabledTools(raw) {
|
|
const groups = String(raw ?? "")
|
|
.split(",")
|
|
.map((part) => part.trim().toLowerCase())
|
|
.filter(Boolean);
|
|
if (groups.length === 0)
|
|
return null;
|
|
const enabled = new Set();
|
|
for (const group of groups) {
|
|
for (const tool of TOOL_GROUPS[group] ?? []) {
|
|
enabled.add(tool);
|
|
}
|
|
}
|
|
return enabled.size > 0 ? enabled : null;
|
|
}
|
|
function encodePath(path) {
|
|
return path
|
|
.replace(/^\/+/, "")
|
|
.split("/")
|
|
.filter(Boolean)
|
|
.map((seg) => encodeURIComponent(seg))
|
|
.join("/");
|
|
}
|
|
function toolText(data) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: typeof data === "string" ? data : JSON.stringify(data, null, 2),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
/** Ultid list APIs use page + page_size, not limit + offset. */
|
|
function listPaginationParams(limit, offset) {
|
|
const qs = new URLSearchParams();
|
|
const pageSize = limit != null && Number.isFinite(limit) && limit > 0
|
|
? Math.min(Math.trunc(limit), 500)
|
|
: undefined;
|
|
if (pageSize != null) {
|
|
qs.set("page_size", String(pageSize));
|
|
const off = offset != null && Number.isFinite(offset) && offset > 0 ? Math.trunc(offset) : 0;
|
|
qs.set("page", String(Math.floor(off / pageSize) + 1));
|
|
}
|
|
else if (offset != null && Number.isFinite(offset) && offset > 0) {
|
|
qs.set("page_size", "50");
|
|
qs.set("page", String(Math.floor(Math.trunc(offset) / 50) + 1));
|
|
}
|
|
return qs;
|
|
}
|
|
function withListPagination(base, limit, offset) {
|
|
const qs = new URLSearchParams(base);
|
|
for (const [key, value] of listPaginationParams(limit, offset)) {
|
|
qs.set(key, value);
|
|
}
|
|
return qs;
|
|
}
|
|
function applyHiddenMailboxParams(qs, include_spam, include_trash) {
|
|
if (include_spam)
|
|
qs.set("include_spam", "true");
|
|
if (include_trash)
|
|
qs.set("include_trash", "true");
|
|
}
|
|
async function ultiFetch(token, path, init) {
|
|
const headers = new Headers(init?.headers);
|
|
headers.set("Accept", "application/json");
|
|
if (token)
|
|
headers.set("Authorization", `Bearer ${token}`);
|
|
const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
|
const text = await res.text();
|
|
if (!res.ok) {
|
|
throw new Error(`ulti ${path} failed (${res.status}): ${text.slice(0, 500)}`);
|
|
}
|
|
if (!text)
|
|
return { ok: true, status: res.status };
|
|
try {
|
|
return JSON.parse(text);
|
|
}
|
|
catch {
|
|
return text;
|
|
}
|
|
}
|
|
function resolveToken(req) {
|
|
return (String(req.headers["x-ulti-token"] ?? req.headers.authorization ?? "").replace(/^Bearer\s+/i, "") || "");
|
|
}
|
|
function createServer(getToken, enabledTools) {
|
|
const server = new McpServer({
|
|
name: "ultimail-mcp",
|
|
version: "0.1.0",
|
|
});
|
|
const allow = (toolName) => !enabledTools || enabledTools.has(toolName);
|
|
if (allow("mail_search")) {
|
|
server.tool("mail_search", "Search mail messages (spam and trash excluded by default)", {
|
|
query: z.string(),
|
|
account_id: z.string().optional(),
|
|
limit: z.number().optional(),
|
|
offset: z.number().optional(),
|
|
include_spam: z.boolean().optional(),
|
|
include_trash: z.boolean().optional(),
|
|
}, async ({ query, account_id, limit, offset, include_spam, include_trash }) => {
|
|
const qs = withListPagination(new URLSearchParams({ q: query }), limit, offset);
|
|
if (account_id)
|
|
qs.set("account_id", account_id);
|
|
applyHiddenMailboxParams(qs, include_spam, include_trash);
|
|
const data = await ultiFetch(getToken(), `/mail/search?${qs}`);
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("mail_read")) {
|
|
server.tool("mail_read", "Read a mail message by id", { message_id: z.string(), account_id: z.string().optional() }, async ({ message_id, account_id }) => {
|
|
const qs = account_id ? `?account_id=${encodeURIComponent(account_id)}` : "";
|
|
const data = await ultiFetch(getToken(), `/mail/messages/${message_id}${qs}`);
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("mail_list")) {
|
|
server.tool("mail_list", "List mail messages in a folder (spam and trash excluded by default unless folder=spam/trash)", {
|
|
folder: z.string().optional(),
|
|
account_id: z.string().optional(),
|
|
limit: z.number().optional(),
|
|
offset: z.number().optional(),
|
|
include_spam: z.boolean().optional(),
|
|
include_trash: z.boolean().optional(),
|
|
}, async ({ folder, account_id, limit, offset, include_spam, include_trash }) => {
|
|
let qs = new URLSearchParams();
|
|
if (folder)
|
|
qs.set("folder", folder);
|
|
if (account_id)
|
|
qs.set("account_id", account_id);
|
|
qs = withListPagination(qs, limit, offset);
|
|
applyHiddenMailboxParams(qs, include_spam, include_trash);
|
|
const suffix = qs.size > 0 ? `?${qs}` : "";
|
|
const data = await ultiFetch(getToken(), `/mail/messages${suffix}`);
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("mail_send")) {
|
|
server.tool("mail_send", "Send a mail message", {
|
|
account_id: z.string(),
|
|
to: z.array(z.string()),
|
|
cc: z.array(z.string()).optional(),
|
|
bcc: z.array(z.string()).optional(),
|
|
subject: z.string(),
|
|
body_text: z.string().optional(),
|
|
body_html: z.string().optional(),
|
|
in_reply_to: z.string().optional(),
|
|
reply_to_message_id: z.string().optional(),
|
|
schedule_at: z.string().optional(),
|
|
}, async (body) => {
|
|
const data = await ultiFetch(getToken(), "/mail/send", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Idempotency-Key": randomUUID(),
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("mail_update_labels")) {
|
|
server.tool("mail_update_labels", "Update labels on a mail message", { message_id: z.string(), labels: z.array(z.string()) }, async ({ message_id, labels }) => {
|
|
const data = await ultiFetch(getToken(), `/mail/messages/${message_id}/labels`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ labels }),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("mail_update_flags")) {
|
|
server.tool("mail_update_flags", "Update flags on a mail message", { message_id: z.string(), flags: z.array(z.string()) }, async ({ message_id, flags }) => {
|
|
const data = await ultiFetch(getToken(), `/mail/messages/${message_id}/flags`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ flags }),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("mail_delete")) {
|
|
server.tool("mail_delete", "Delete a mail message", { message_id: z.string() }, async ({ message_id }) => {
|
|
const data = await ultiFetch(getToken(), `/mail/messages/${message_id}`, {
|
|
method: "DELETE",
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("drive_list")) {
|
|
server.tool("drive_list", "List drive files in a folder", {
|
|
path: z.string().optional(),
|
|
limit: z.number().optional(),
|
|
offset: z.number().optional(),
|
|
}, async ({ path, limit, offset }) => {
|
|
const encoded = encodePath(path ?? "");
|
|
const qs = listPaginationParams(limit, offset);
|
|
const suffix = qs.size > 0 ? `?${qs}` : "";
|
|
const data = await ultiFetch(getToken(), `/drive/files/${encoded}${suffix}`);
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("drive_file_info")) {
|
|
server.tool("drive_file_info", "Get drive file or folder metadata", { path: z.string() }, async ({ path }) => {
|
|
const encoded = encodePath(path);
|
|
const data = await ultiFetch(getToken(), `/drive/files/info/${encoded}`);
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("drive_create_file")) {
|
|
server.tool("drive_create_file", "Create a new office file (document, spreadsheet, presentation, drawing)", {
|
|
parent_path: z.string().optional(),
|
|
name: z.string(),
|
|
kind: z.enum(["document", "spreadsheet", "presentation", "drawing"]),
|
|
}, async ({ parent_path, name, kind }) => {
|
|
const data = await ultiFetch(getToken(), "/drive/files/new", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ parent_path, name, kind }),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("drive_create_folder")) {
|
|
server.tool("drive_create_folder", "Create a drive folder", { path: z.string() }, async ({ path }) => {
|
|
const encoded = encodePath(path);
|
|
const data = await ultiFetch(getToken(), `/drive/folders/${encoded}`, {
|
|
method: "POST",
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("drive_delete")) {
|
|
server.tool("drive_delete", "Delete a drive file or folder", { path: z.string() }, async ({ path }) => {
|
|
const encoded = encodePath(path);
|
|
const data = await ultiFetch(getToken(), `/drive/files/${encoded}`, {
|
|
method: "DELETE",
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("drive_move")) {
|
|
server.tool("drive_move", "Move a drive file or folder", {
|
|
source: z.string(),
|
|
destination: z.string(),
|
|
source_root: z.string().optional(),
|
|
source_root_id: z.string().optional(),
|
|
destination_root: z.string().optional(),
|
|
destination_root_id: z.string().optional(),
|
|
}, async (body) => {
|
|
const data = await ultiFetch(getToken(), "/drive/move", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("drive_rename")) {
|
|
server.tool("drive_rename", "Rename a drive file or folder", {
|
|
path: z.string(),
|
|
new_name: z.string(),
|
|
root: z.string().optional(),
|
|
root_id: z.string().optional(),
|
|
}, async (body) => {
|
|
const data = await ultiFetch(getToken(), "/drive/rename", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("drive_share_create")) {
|
|
server.tool("drive_share_create", "Create a drive share link or internal share", {
|
|
path: z.string(),
|
|
share_type: z.number().optional(),
|
|
permissions: z.number().optional(),
|
|
role: z.enum(["owner", "editor", "viewer"]).optional(),
|
|
mode: z.enum(["public", "internal", "contact"]).optional(),
|
|
share_with: z.string().optional(),
|
|
note: z.string().optional(),
|
|
root: z.string().optional(),
|
|
root_id: z.string().optional(),
|
|
}, async (body) => {
|
|
const data = await ultiFetch(getToken(), "/drive/shares", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("drive_share_update")) {
|
|
server.tool("drive_share_update", "Update an existing drive share", {
|
|
share_id: z.string(),
|
|
permissions: z.number().optional(),
|
|
role: z.enum(["owner", "editor", "viewer"]).optional(),
|
|
expire_date: z.string().optional(),
|
|
password: z.string().optional(),
|
|
}, async ({ share_id, ...body }) => {
|
|
const data = await ultiFetch(getToken(), `/drive/shares/${share_id}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("drive_share_delete")) {
|
|
server.tool("drive_share_delete", "Delete a drive share", { share_id: z.string() }, async ({ share_id }) => {
|
|
const data = await ultiFetch(getToken(), `/drive/shares/${share_id}`, {
|
|
method: "DELETE",
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("contacts_search")) {
|
|
server.tool("contacts_search", "Search contacts", {
|
|
query: z.string(),
|
|
book_id: z.string().optional(),
|
|
limit: z.number().optional(),
|
|
offset: z.number().optional(),
|
|
}, async ({ query, book_id, limit, offset }) => {
|
|
const qs = withListPagination(new URLSearchParams({ q: query }), limit, offset);
|
|
if (book_id)
|
|
qs.set("book_id", book_id);
|
|
const data = await ultiFetch(getToken(), `/contacts/search?${qs}`);
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("contacts_list_books")) {
|
|
server.tool("contacts_list_books", "List address books", {}, async () => {
|
|
const data = await ultiFetch(getToken(), "/contacts/books");
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("contacts_list")) {
|
|
server.tool("contacts_list", "List contacts in an address book", {
|
|
book_id: z.string(),
|
|
limit: z.number().optional(),
|
|
offset: z.number().optional(),
|
|
}, async ({ book_id, limit, offset }) => {
|
|
const qs = listPaginationParams(limit, offset);
|
|
const suffix = qs.size > 0 ? `?${qs}` : "";
|
|
const data = await ultiFetch(getToken(), `/contacts/books/${book_id}${suffix}`);
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("contacts_get")) {
|
|
server.tool("contacts_get", "Get a contact by path (book_id/contact_uid)", { path: z.string() }, async ({ path }) => {
|
|
const encoded = encodePath(path);
|
|
const data = await ultiFetch(getToken(), `/contacts/${encoded}`);
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("contacts_create")) {
|
|
server.tool("contacts_create", "Create a contact in an address book", {
|
|
book_id: z.string(),
|
|
contact: z.record(z.string(), z.unknown()),
|
|
}, async ({ book_id, contact }) => {
|
|
const data = await ultiFetch(getToken(), `/contacts/books/${book_id}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(contact),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("contacts_update")) {
|
|
server.tool("contacts_update", "Update a contact (requires etag from get)", {
|
|
path: z.string(),
|
|
if_match: z.string(),
|
|
contact: z.record(z.string(), z.unknown()),
|
|
}, async ({ path, if_match, contact }) => {
|
|
const encoded = encodePath(path);
|
|
const data = await ultiFetch(getToken(), `/contacts/${encoded}`, {
|
|
method: "PUT",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"If-Match": if_match,
|
|
},
|
|
body: JSON.stringify(contact),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("contacts_delete")) {
|
|
server.tool("contacts_delete", "Delete a contact by path", { path: z.string() }, async ({ path }) => {
|
|
const encoded = encodePath(path);
|
|
const data = await ultiFetch(getToken(), `/contacts/${encoded}`, {
|
|
method: "DELETE",
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("suite_search")) {
|
|
server.tool("suite_search", "Unified search across mail, drive, contacts (mail spam/trash excluded by default)", {
|
|
query: z.string(),
|
|
types: z.string().optional(),
|
|
limit: z.number().optional(),
|
|
offset: z.number().optional(),
|
|
include_spam: z.boolean().optional(),
|
|
include_trash: z.boolean().optional(),
|
|
}, async ({ query, types, limit, offset, include_spam, include_trash }) => {
|
|
const qs = withListPagination(new URLSearchParams({ q: query }), limit, offset);
|
|
if (types)
|
|
qs.set("types", types);
|
|
applyHiddenMailboxParams(qs, include_spam, include_trash);
|
|
const data = await ultiFetch(getToken(), `/search?${qs}`);
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("web_search")) {
|
|
server.tool("web_search", "Search the public web using the configured search provider (Brave, Bing, SearXNG, DuckDuckGo or custom JSON API).", {
|
|
query: z.string(),
|
|
count: z.number().optional(),
|
|
}, async ({ query, count }) => {
|
|
const qs = new URLSearchParams({ q: query });
|
|
if (count != null && Number.isFinite(count) && count > 0) {
|
|
qs.set("count", String(Math.min(Math.trunc(count), 20)));
|
|
}
|
|
const data = await ultiFetch(getToken(), `/search/web?${qs}`);
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("docs_read")) {
|
|
server.tool("docs_read", "Read UltiDocs document JSON (.ultidoc sidecar path)", { path: z.string() }, async ({ path }) => {
|
|
const encoded = encodePath(path);
|
|
const data = await ultiFetch(getToken(), `/drive/download/${encoded}`);
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("docs_save")) {
|
|
server.tool("docs_save", "Save UltiDocs TipTap content to sidecar path", {
|
|
path: z.string(),
|
|
document: z.record(z.string(), z.unknown()),
|
|
}, async ({ path, document }) => {
|
|
const data = await ultiFetch(getToken(), "/richtext/save", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ path, document }),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("calendar_list")) {
|
|
server.tool("calendar_list", "List calendars", {}, async () => {
|
|
const data = await ultiFetch(getToken(), "/calendar/");
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("calendar_create")) {
|
|
server.tool("calendar_create", "Create a calendar", {
|
|
id: z.string().optional(),
|
|
display_name: z.string(),
|
|
color: z.string().optional(),
|
|
}, async (body) => {
|
|
const data = await ultiFetch(getToken(), "/calendar/", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("calendar_update")) {
|
|
server.tool("calendar_update", "Update calendar display name or color", {
|
|
cal_id: z.string(),
|
|
display_name: z.string().optional(),
|
|
color: z.string().optional(),
|
|
}, async ({ cal_id, ...body }) => {
|
|
const data = await ultiFetch(getToken(), `/calendar/${cal_id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("calendar_delete")) {
|
|
server.tool("calendar_delete", "Delete a calendar", { cal_id: z.string() }, async ({ cal_id }) => {
|
|
const data = await ultiFetch(getToken(), `/calendar/${cal_id}`, {
|
|
method: "DELETE",
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("calendar_list_events")) {
|
|
server.tool("calendar_list_events", "List events in a calendar", {
|
|
cal_id: z.string(),
|
|
limit: z.number().optional(),
|
|
offset: z.number().optional(),
|
|
}, async ({ cal_id, limit, offset }) => {
|
|
const qs = listPaginationParams(limit, offset);
|
|
const suffix = qs.size > 0 ? `?${qs}` : "";
|
|
const data = await ultiFetch(getToken(), `/calendar/${cal_id}/events${suffix}`);
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("calendar_freebusy")) {
|
|
server.tool("calendar_freebusy", "Query free/busy for attendees in a time range", {
|
|
start: z.string(),
|
|
end: z.string(),
|
|
attendees: z.array(z.string()),
|
|
}, async (body) => {
|
|
const data = await ultiFetch(getToken(), "/calendar/freebusy", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("calendar_create_event")) {
|
|
server.tool("calendar_create_event", "Create a calendar event", {
|
|
cal_id: z.string(),
|
|
event: z.record(z.string(), z.unknown()),
|
|
}, async ({ cal_id, event }) => {
|
|
const data = await ultiFetch(getToken(), `/calendar/${cal_id}/events`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(event),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("calendar_update_event")) {
|
|
server.tool("calendar_update_event", "Update a calendar event (requires If-Match etag)", {
|
|
event_path: z.string(),
|
|
if_match: z.string(),
|
|
event: z.record(z.string(), z.unknown()),
|
|
}, async ({ event_path, if_match, event }) => {
|
|
const encoded = encodePath(event_path);
|
|
const data = await ultiFetch(getToken(), `/calendar/events/${encoded}`, {
|
|
method: "PUT",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"If-Match": if_match,
|
|
},
|
|
body: JSON.stringify(event),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("calendar_delete_event")) {
|
|
server.tool("calendar_delete_event", "Delete a calendar event", { event_path: z.string() }, async ({ event_path }) => {
|
|
const encoded = encodePath(event_path);
|
|
const data = await ultiFetch(getToken(), `/calendar/events/${encoded}`, {
|
|
method: "DELETE",
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("calendar_respond_invitation")) {
|
|
server.tool("calendar_respond_invitation", "Respond to a calendar invitation", {
|
|
event_path: z.string(),
|
|
email: z.string().optional(),
|
|
response: z.enum(["accepted", "declined", "tentative", "needs-action"]),
|
|
if_match: z.string().optional(),
|
|
}, async ({ event_path, ...body }) => {
|
|
const encoded = encodePath(event_path);
|
|
const data = await ultiFetch(getToken(), `/calendar/events/response/${encoded}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (allow("calendar_create_meet_link")) {
|
|
server.tool("calendar_create_meet_link", "Create a video meeting link for an event", {
|
|
event_path: z.string(),
|
|
if_match: z.string().optional(),
|
|
}, async ({ event_path, if_match }) => {
|
|
const encoded = encodePath(event_path);
|
|
const data = await ultiFetch(getToken(), `/calendar/events/meet-link/${encoded}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ if_match }),
|
|
});
|
|
return toolText(data);
|
|
});
|
|
}
|
|
if (enabledTools && enabledTools.size === 0) {
|
|
console.warn("ultimail-mcp: no tools enabled for groups", [...enabledTools]);
|
|
}
|
|
else if (enabledTools) {
|
|
const disabled = ALL_TOOL_NAMES.filter((name) => !enabledTools.has(name));
|
|
if (disabled.length > 0) {
|
|
console.log("ultimail-mcp disabled tools:", disabled.join(", "));
|
|
}
|
|
}
|
|
return server;
|
|
}
|
|
const app = express();
|
|
app.use(express.json({ limit: "4mb" }));
|
|
const streamableTransports = new Map();
|
|
const sseTransports = new Map();
|
|
app.get("/health", (_req, res) => {
|
|
res.json({ ok: true, tools: ALL_TOOL_NAMES });
|
|
});
|
|
app.all("/mcp", async (req, res) => {
|
|
const token = resolveToken(req);
|
|
const enabledTools = parseEnabledTools(String(req.headers["x-ulti-enabled-tools"] ?? ""));
|
|
try {
|
|
const sessionId = String(req.headers["mcp-session-id"] ?? "");
|
|
let transport;
|
|
if (sessionId && streamableTransports.has(sessionId)) {
|
|
transport = streamableTransports.get(sessionId);
|
|
}
|
|
else if (!sessionId && req.method === "POST" && isInitializeRequest(req.body)) {
|
|
transport = new StreamableHTTPServerTransport({
|
|
sessionIdGenerator: () => randomUUID(),
|
|
onsessioninitialized: (sid) => {
|
|
if (transport)
|
|
streamableTransports.set(sid, transport);
|
|
},
|
|
});
|
|
transport.onclose = () => {
|
|
const sid = transport?.sessionId;
|
|
if (sid)
|
|
streamableTransports.delete(sid);
|
|
};
|
|
const server = createServer(() => token, enabledTools);
|
|
await server.connect(transport);
|
|
}
|
|
else {
|
|
res.status(400).json({
|
|
jsonrpc: "2.0",
|
|
error: { code: -32000, message: "Bad Request: No valid session ID provided" },
|
|
id: null,
|
|
});
|
|
return;
|
|
}
|
|
await transport.handleRequest(req, res, req.body);
|
|
}
|
|
catch (error) {
|
|
console.error("streamable mcp error:", error);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({
|
|
jsonrpc: "2.0",
|
|
error: { code: -32603, message: "Internal server error" },
|
|
id: null,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
app.get("/sse", async (req, res) => {
|
|
const token = resolveToken(req);
|
|
const enabledTools = parseEnabledTools(String(req.headers["x-ulti-enabled-tools"] ?? ""));
|
|
const transport = new SSEServerTransport("/messages", res);
|
|
sseTransports.set(transport.sessionId, transport);
|
|
res.on("close", () => sseTransports.delete(transport.sessionId));
|
|
const server = createServer(() => token, enabledTools);
|
|
await server.connect(transport);
|
|
});
|
|
app.post("/messages", async (req, res) => {
|
|
const sessionId = String(req.query.sessionId ?? "");
|
|
const transport = sseTransports.get(sessionId);
|
|
if (!transport || !(transport instanceof SSEServerTransport)) {
|
|
res.status(404).json({ error: "session not found" });
|
|
return;
|
|
}
|
|
await transport.handlePostMessage(req, res, req.body);
|
|
});
|
|
app.listen(PORT, () => {
|
|
console.log(`ultimail-mcp listening on :${PORT} (streamable /mcp, legacy /sse)`);
|
|
});
|