#!/usr/bin/env node
#!/usr/bin/env node
// src/index.ts
import mic from "node-mic";
import { writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { exec } from "child_process";
var PLATFORM = process.env.LOTUS_PLATFORM || "cli";
var BROWSER_URL_FILE = join(tmpdir(), "lotus-browser-url.txt");
var RECORDING_STATE_FILE = join(tmpdir(), "lotus-recording-state.json");
var LOTUS_API_KEY = process.env.LOTUS_API_KEY;
var LOTUS_URL = process.env.LOTUS_URL;
var skills = [];
var activeRecordingSession = null;
var activeRefineSession = null;
function send(message) {
process.stdout.write(JSON.stringify(message) + "\n");
}
function sendError(id, code, message) {
send({ jsonrpc: "2.0", id, error: { code, message } });
}
function log(message) {
console.error(`[lotus-mcp:${PLATFORM}] ${message}`);
}
log(`Config: LOTUS_URL=${LOTUS_URL}, API_KEY=${LOTUS_API_KEY ? `${LOTUS_API_KEY.slice(0, 4)}...` : "NOT SET"}`);
async function fetchJson(url, options) {
const res = await fetch(url, options);
const contentType = res.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
const text = await res.text();
log(`API error: Expected JSON, got ${contentType}. URL: ${url}`);
log(`Response body (first 500 chars): ${text.slice(0, 500)}`);
throw new Error(`API returned ${res.status} with ${contentType || "no content-type"}. Check LOTUS_URL and LOTUS_API_KEY.`);
}
if (!res.ok) {
const error = await res.json();
throw new Error(`API error ${res.status}: ${error.message || JSON.stringify(error)}`);
}
return res.json();
}
function onBrowserReady(url, sessionId) {
if (PLATFORM === "cursor") {
writeFileSync(BROWSER_URL_FILE, url, "utf8");
writeFileSync(RECORDING_STATE_FILE, JSON.stringify({
status: "recording",
url,
sessionId,
timestamp: Date.now()
}), "utf8");
log(`Browser URL written to ${BROWSER_URL_FILE}`);
} else {
openInSystemBrowser(url);
}
}
function updateRecordingState(status) {
if (PLATFORM === "cursor") {
writeFileSync(RECORDING_STATE_FILE, JSON.stringify({
status,
timestamp: Date.now()
}), "utf8");
}
}
function openInSystemBrowser(url) {
const platform = process.platform;
let command;
if (platform === "darwin") {
command = `open "${url}"`;
} else if (platform === "win32") {
command = `start "" "${url}"`;
} else {
command = `xdg-open "${url}"`;
}
exec(command, (error) => {
if (error) {
log(`Failed to open browser: ${error.message}`);
log(`Please open manually: ${url}`);
} else {
log(`Opened browser: ${url}`);
}
});
}
function getBrowserInstructions(url) {
if (PLATFORM === "cursor") {
return `The browser should open automatically in your Cursor panel.
If it doesn't appear, run: Cmd+Shift+P \u2192 "Lotus: Open Browser"`;
} else {
return `I've opened the browser in a new window.
If it didn't open, here's the URL: ${url}`;
}
}
async function loadSkills() {
try {
const data = await fetchJson(`${LOTUS_URL}/api/v1/skills`, {
headers: { "X-API-Key": LOTUS_API_KEY }
});
skills = data.skills || [];
return true;
} catch (e) {
log(`Failed to load skills: ${e.message}`);
return false;
}
}
async function executeSkill(skillId, inputs) {
try {
return await fetchJson(`${LOTUS_URL}/api/v1/skills/${skillId}/execute`, {
method: "POST",
headers: { "X-API-Key": LOTUS_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ inputs })
});
} catch (e) {
return { success: false, error: e.message };
}
}
function toJsonSchema(type) {
switch (type?.toLowerCase()) {
case "number":
return { type: "number" };
case "boolean":
return { type: "boolean" };
default:
return { type: "string" };
}
}
function toToolName(name) {
return `lotus_${name.toLowerCase().replace(/[^a-z0-9]/g, "_")}`;
}
function startMicRecording() {
if (!activeRecordingSession) return;
try {
const Mic = mic;
const micInstance = new Mic({
rate: 16e3,
channels: 1,
fileType: "wav"
});
const audioStream = micInstance.getAudioStream();
const chunks = [];
audioStream.on("data", (data) => {
chunks.push(data);
});
audioStream.on("error", (err) => {
log(`Mic error: ${err.message}`);
});
micInstance.start();
activeRecordingSession.micInstance = micInstance;
activeRecordingSession.audioChunks = chunks;
log("Microphone recording started");
} catch (err) {
log(`Failed to start mic: ${err.message}`);
}
}
function stopMicRecording() {
if (!activeRecordingSession?.micInstance) return void 0;
try {
activeRecordingSession.micInstance.stop();
const chunks = activeRecordingSession.audioChunks || [];
log("Microphone recording stopped");
if (chunks.length > 0) {
return Buffer.concat(chunks);
}
} catch (err) {
log(`Failed to stop mic: ${err.message}`);
}
return void 0;
}
async function apiStartRecording(url) {
try {
return await fetchJson(`${LOTUS_URL}/api/v1/record/start`, {
method: "POST",
headers: { "X-API-Key": LOTUS_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ url })
});
} catch (e) {
return { error: e.message };
}
}
async function apiStopRecording(sessionId, audioBase64) {
try {
return await fetchJson(`${LOTUS_URL}/api/v1/record/stop`, {
method: "POST",
headers: { "X-API-Key": LOTUS_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, audio_base64: audioBase64 })
});
} catch (e) {
return { error: e.message };
}
}
async function apiStartRefine(sessionId, name, description) {
try {
return await fetchJson(`${LOTUS_URL}/api/v1/refine/start`, {
method: "POST",
headers: { "X-API-Key": LOTUS_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, name, description })
});
} catch (e) {
return { error: e.message };
}
}
async function apiRefineStatus(sessionId) {
try {
return await fetchJson(`${LOTUS_URL}/api/v1/refine/status?session_id=${encodeURIComponent(sessionId)}`, {
headers: { "X-API-Key": LOTUS_API_KEY }
});
} catch (e) {
return { status: "error", error: e.message };
}
}
async function apiRefineAnswer(sessionId, answer) {
try {
return await fetchJson(`${LOTUS_URL}/api/v1/refine/answer`, {
method: "POST",
headers: { "X-API-Key": LOTUS_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, answer })
});
} catch (e) {
return { error: e.message };
}
}
async function apiCancelSession(sessionId) {
try {
return await fetchJson(`${LOTUS_URL}/api/v1/session/cancel`, {
method: "POST",
headers: { "X-API-Key": LOTUS_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId })
});
} catch (e) {
return { error: e.message };
}
}
async function handleRequest(request) {
const { id, method, params } = request;
switch (method) {
case "initialize": {
if (!LOTUS_API_KEY || !LOTUS_URL) {
sendError(id, -32600, "LOTUS_API_KEY and LOTUS_URL environment variables required");
return;
}
if (!await loadSkills()) {
sendError(id, -32600, "Failed to connect to Lotus");
return;
}
log(`Initialized (platform: ${PLATFORM})`);
send({
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2025-11-25",
capabilities: { tools: {} },
serverInfo: { name: "lotus-mcp", version: "0.3.0" }
}
});
break;
}
case "notifications/initialized":
break;
case "tools/list": {
await loadSkills();
const skillTools = skills.map((skill) => ({
name: toToolName(skill.name),
description: skill.description || skill.name,
inputSchema: {
type: "object",
properties: Object.fromEntries(
(skill.inputs || []).map((i) => [i.name, { ...toJsonSchema(i.type), description: i.description }])
),
required: (skill.inputs || []).map((i) => i.name)
}
}));
const creationTools = [
{
name: "lotus_start_recording",
description: "Start recording a new skill. Opens a cloud browser for the user to perform actions. The user should narrate what they're doing - audio is captured automatically via microphone.",
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "The URL to start recording from" }
},
required: ["url"]
}
},
{
name: "lotus_stop_recording",
description: "Stop the current recording session. This will stop audio capture, analyze the workflow, and prepare it for refinement. Returns workflow analysis including suggested name and detected inputs.",
inputSchema: {
type: "object",
properties: {},
required: []
}
},
{
name: "lotus_refine_skill",
description: "Refine the recorded workflow into a skill. Opens a live view in the browser where you can watch the AI test and refine the skill. This tool blocks until refinement is complete or the AI has a question - no polling needed.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Optional name for the skill (overrides suggested name)" },
description: { type: "string", description: "Optional description for the skill" }
},
required: []
}
},
{
name: "lotus_refine_status",
description: "Check the status of the refinement process (for debugging only - lotus_refine_skill now blocks until complete). Returns 'refining', 'question', 'complete', or 'error'.",
inputSchema: {
type: "object",
properties: {},
required: []
}
},
{
name: "lotus_refine_answer",
description: "Answer a clarifying question from the AI during refinement. After sending the answer, this tool blocks until refinement is complete or another question arises - no polling needed.",
inputSchema: {
type: "object",
properties: {
answer: { type: "string", description: "Your answer to the AI's question" }
},
required: ["answer"]
}
},
{
name: "lotus_cancel_session",
description: "Cancel the current recording or refinement session and clean up resources.",
inputSchema: {
type: "object",
properties: {},
required: []
}
}
];
send({ jsonrpc: "2.0", id, result: { tools: [...creationTools, ...skillTools] } });
break;
}
case "tools/call": {
const toolName = params?.name;
const toolArgs = params?.arguments || {};
if (toolName === "lotus_start_recording") {
const url = toolArgs.url;
if (!url) {
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: "Error: URL is required" }],
isError: true
}
});
return;
}
if (activeRecordingSession) {
await apiCancelSession(activeRecordingSession.sessionId);
activeRecordingSession = null;
}
if (activeRefineSession) {
await apiCancelSession(activeRefineSession.sessionId);
activeRefineSession = null;
}
const result2 = await apiStartRecording(url);
if ("error" in result2) {
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: `Error starting recording: ${result2.error}` }],
isError: true
}
});
return;
}
activeRecordingSession = {
sessionId: result2.session_id,
liveViewUrl: result2.live_view_url,
micInstance: null,
audioChunks: []
};
onBrowserReady(result2.live_view_url, result2.session_id);
updateRecordingState("recording");
startMicRecording();
const instructions = getBrowserInstructions(result2.live_view_url);
send({
jsonrpc: "2.0",
id,
result: {
content: [{
type: "text",
text: `\u{1F3AC} Recording started!
${instructions}
**What to do:**
1. Navigate through the website
2. Perform the actions you want to automate
3. Narrate what you're doing out loud (your voice is being recorded)
4. Tell me "I'm done" when finished
I'm watching everything!`
}]
}
});
return;
}
if (toolName === "lotus_stop_recording") {
if (!activeRecordingSession) {
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: "Error: No active recording session. Call lotus_start_recording first." }],
isError: true
}
});
return;
}
const audioBuffer = stopMicRecording();
const audioBase64 = audioBuffer ? audioBuffer.toString("base64") : void 0;
updateRecordingState("stopped");
const result2 = await apiStopRecording(activeRecordingSession.sessionId, audioBase64);
if (result2.error) {
activeRecordingSession = null;
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: `Error stopping recording: ${result2.error}` }],
isError: true
}
});
return;
}
const sessionId = activeRecordingSession.sessionId;
activeRecordingSession = null;
activeRefineSession = { sessionId, liveViewUrl: "" };
let responseText = `\u23F9\uFE0F Recording stopped!
**Summary:** ${result2.workflow_summary}
**Suggested Name:** ${result2.suggested_name}
**Detected Auth:** ${result2.detected_auth ? "Yes" : "No"}`;
if (result2.transcript) {
responseText += `
**Your Narration:** "${result2.transcript}"`;
}
if (result2.inferred_inputs && result2.inferred_inputs.length > 0) {
responseText += `
**Inferred Inputs:**
${result2.inferred_inputs.map((i) => ` - ${i.name} (${i.type}): ${i.description}`).join("\n")}`;
}
responseText += `
Ready to refine this into a skill. Call lotus_refine_skill to start the refinement process.`;
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: responseText }]
}
});
return;
}
if (toolName === "lotus_refine_skill") {
if (!activeRefineSession) {
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: "Error: No recorded workflow ready. Call lotus_start_recording and lotus_stop_recording first." }],
isError: true
}
});
return;
}
const name = toolArgs.name;
const description = toolArgs.description;
const result2 = await apiStartRefine(activeRefineSession.sessionId, name, description);
if (result2.error) {
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: `Error starting refinement: ${result2.error}` }],
isError: true
}
});
return;
}
activeRefineSession.liveViewUrl = result2.live_view_url || "";
if (result2.live_view_url) {
onBrowserReady(result2.live_view_url, activeRefineSession.sessionId);
}
updateRecordingState("refining");
const pollInterval = 3e3;
const maxPolls = 200;
for (let i = 0; i < maxPolls; i++) {
await new Promise((r) => setTimeout(r, pollInterval));
const status = await apiRefineStatus(activeRefineSession.sessionId);
if (status.status === "complete") {
updateRecordingState("idle");
activeRefineSession = null;
await loadSkills();
const skill2 = status.skill;
if (!skill2) {
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: "Skill created but details not available. Check your skills list." }]
}
});
return;
}
send({
jsonrpc: "2.0",
id,
result: {
content: [{
type: "text",
text: `\u2705 Skill created successfully!
**Name:** ${skill2.name}
**Description:** ${skill2.description}
**Inputs:** ${skill2.inputs?.length > 0 ? skill2.inputs.map((i2) => `${i2.name} (${i2.type})`).join(", ") : "None"}
**ID:** ${skill2.id}
You can now use this skill by calling ${toToolName(skill2.name)}`
}]
}
});
return;
}
if (status.status === "question") {
send({
jsonrpc: "2.0",
id,
result: {
content: [{
type: "text",
text: `\u{1F914} The AI has a question:
**${status.question}**
Use lotus_refine_answer to respond.`
}]
}
});
return;
}
if (status.status === "error") {
updateRecordingState("idle");
activeRefineSession = null;
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: `Refinement failed: ${status.error}` }],
isError: true
}
});
return;
}
log(`Refinement in progress... (poll ${i + 1}/${maxPolls})`);
}
updateRecordingState("idle");
activeRefineSession = null;
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: "Refinement timed out after 10 minutes. Please try again." }],
isError: true
}
});
return;
}
if (toolName === "lotus_refine_status") {
if (!activeRefineSession) {
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: "Error: No active refinement session." }],
isError: true
}
});
return;
}
const result2 = await apiRefineStatus(activeRefineSession.sessionId);
if (result2.error) {
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: `Error checking status: ${result2.error}` }],
isError: true
}
});
return;
}
if (result2.status === "complete") {
activeRefineSession = null;
updateRecordingState("idle");
await loadSkills();
const skill2 = result2.skill;
if (!skill2) {
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: "Skill created but details not available. Check your skills list." }]
}
});
return;
}
send({
jsonrpc: "2.0",
id,
result: {
content: [{
type: "text",
text: `\u2705 Skill created successfully!
**Name:** ${skill2.name}
**Description:** ${skill2.description}
**Inputs:** ${skill2.inputs?.length > 0 ? skill2.inputs.map((i) => `${i.name} (${i.type})`).join(", ") : "None"}
**ID:** ${skill2.id}
You can now use this skill by calling ${toToolName(skill2.name)}`
}]
}
});
return;
}
if (result2.status === "question") {
send({
jsonrpc: "2.0",
id,
result: {
content: [{
type: "text",
text: `\u{1F914} The AI has a question:
**${result2.question}**
Use lotus_refine_answer to respond.`
}]
}
});
return;
}
if (result2.status === "error") {
activeRefineSession = null;
updateRecordingState("idle");
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: `Refinement failed: ${result2.error}` }],
isError: true
}
});
return;
}
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: "\u23F3 Refinement in progress... Call lotus_refine_status again to check." }]
}
});
return;
}
if (toolName === "lotus_refine_answer") {
if (!activeRefineSession) {
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: "Error: No active refinement session." }],
isError: true
}
});
return;
}
const answer = toolArgs.answer;
if (!answer) {
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: "Error: answer is required" }],
isError: true
}
});
return;
}
const answerResult = await apiRefineAnswer(activeRefineSession.sessionId, answer);
if (answerResult.error) {
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: `Error sending answer: ${answerResult.error}` }],
isError: true
}
});
return;
}
const pollInterval = 3e3;
const maxPolls = 200;
for (let i = 0; i < maxPolls; i++) {
await new Promise((r) => setTimeout(r, pollInterval));
const status = await apiRefineStatus(activeRefineSession.sessionId);
if (status.status === "complete") {
updateRecordingState("idle");
activeRefineSession = null;
await loadSkills();
const skill2 = status.skill;
if (!skill2) {
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: "Skill created but details not available. Check your skills list." }]
}
});
return;
}
send({
jsonrpc: "2.0",
id,
result: {
content: [{
type: "text",
text: `\u2705 Skill created successfully!
**Name:** ${skill2.name}
**Description:** ${skill2.description}
**Inputs:** ${skill2.inputs?.length > 0 ? skill2.inputs.map((i2) => `${i2.name} (${i2.type})`).join(", ") : "None"}
**ID:** ${skill2.id}
You can now use this skill by calling ${toToolName(skill2.name)}`
}]
}
});
return;
}
if (status.status === "question") {
send({
jsonrpc: "2.0",
id,
result: {
content: [{
type: "text",
text: `\u{1F914} The AI has another question:
**${status.question}**
Use lotus_refine_answer to respond.`
}]
}
});
return;
}
if (status.status === "error") {
updateRecordingState("idle");
activeRefineSession = null;
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: `Refinement failed: ${status.error}` }],
isError: true
}
});
return;
}
log(`Refinement in progress after answer... (poll ${i + 1}/${maxPolls})`);
}
updateRecordingState("idle");
activeRefineSession = null;
send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: "Refinement timed out after 10 minutes. Please try again." }],
isError: true
}
});
return;
}
if (toolName === "lotus_cancel_session") {
let cancelled = false;
if (activeRecordingSession) {
stopMicRecording();
await apiCancelSession(activeRecordingSession.sessionId);
activeRecordingSession = null;
cancelled = true;
}
if (activeRefineSession) {
await apiCancelSession(activeRefineSession.sessionId);
activeRefineSession = null;
cancelled = true;
}
updateRecordingState("idle");
send({
jsonrpc: "2.0",
id,
result: {
content: [{
type: "text",
text: cancelled ? "Session cancelled and resources cleaned up." : "No active session to cancel."
}]
}
});
return;
}
const skill = skills.find((s) => toToolName(s.name) === toolName);
if (!skill) {
sendError(id, -32602, `Tool not found: ${toolName}`);
return;
}
const result = await executeSkill(skill.id, toolArgs);
send({
jsonrpc: "2.0",
id,
result: {
content: [{
type: "text",
text: result.success ? typeof result.result === "string" ? result.result : JSON.stringify(result.result, null, 2) : `Error: ${result.error}`
}],
...result.success ? {} : { isError: true }
}
});
break;
}
case "resources/list":
case "resources/read":
case "prompts/list":
case "prompts/get":
send({ jsonrpc: "2.0", id, result: {} });
break;
default:
sendError(id, -32601, `Method not found: ${method}`);
}
}
async function main() {
log(`Starting (platform: ${PLATFORM})`);
let buffer = "";
let processing = false;
const queue = [];
async function processQueue() {
if (processing) return;
processing = true;
while (queue.length > 0) {
const line = queue.shift();
try {
await handleRequest(JSON.parse(line));
} catch {
sendError(null, -32700, "Parse error");
}
}
processing = false;
}
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.trim()) queue.push(line);
}
processQueue();
});
process.stdin.on("end", () => {
if (activeRecordingSession) {
stopMicRecording();
apiCancelSession(activeRecordingSession.sessionId).catch(() => {
});
}
if (activeRefineSession) {
apiCancelSession(activeRefineSession.sessionId).catch(() => {
});
}
if (buffer.trim()) {
queue.push(buffer);
processQueue().then(() => process.exit(0));
} else {
process.exit(0);
}
});
process.on("SIGINT", () => {
if (activeRecordingSession) {
stopMicRecording();
apiCancelSession(activeRecordingSession.sessionId).catch(() => {
});
}
if (activeRefineSession) {
apiCancelSession(activeRefineSession.sessionId).catch(() => {
});
}
process.exit(0);
});
process.on("SIGTERM", () => {
if (activeRecordingSession) {
stopMicRecording();
apiCancelSession(activeRecordingSession.sessionId).catch(() => {
});
}
if (activeRefineSession) {
apiCancelSession(activeRefineSession.sessionId).catch(() => {
});
}
process.exit(0);
});
}
main();