import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const execFileAsync = promisify(execFile);
const STEADY_BASE_URL = process.env.STEADY_BASE_URL ?? "https://app.steady.space";
const DEBUG = process.env.STEADY_MCP_DEBUG === "1";
function defaultAppDataDir() {
// OS-appropriate per-user app data dir (no extra deps).
// - macOS: ~/Library/Application Support
// - Windows: %APPDATA% (Roaming)
// - Linux: $XDG_CONFIG_HOME or ~/.config
if (process.platform === "darwin") {
return path.join(os.homedir(), "Library", "Application Support");
}
if (process.platform === "win32") {
return (
process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
);
}
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
}
function defaultCookiesPath() {
return path.join(defaultAppDataDir(), "steady-mcp", "cookies.txt");
}
function defaultCookieJarPath() {
return path.join(defaultAppDataDir(), "steady-mcp", "cookiejar.txt");
}
const COOKIES_PATH = process.env.STEADY_COOKIES_PATH ?? defaultCookiesPath();
const COOKIE_JAR_PATH = process.env.STEADY_COOKIE_JAR_PATH ?? defaultCookieJarPath();
const STEADY_EMAIL = process.env.STEADY_EMAIL ?? "";
const STEADY_PASSWORD = process.env.STEADY_PASSWORD ?? "";
const STEADY_PASSWORD_COMMAND = process.env.STEADY_PASSWORD_COMMAND ?? "";
const STEADY_KEYCHAIN_SERVICE = process.env.STEADY_KEYCHAIN_SERVICE ?? "steady-mcp";
async function getPassword() {
if (STEADY_PASSWORD) return STEADY_PASSWORD;
if (STEADY_PASSWORD_COMMAND) {
const [bin, ...rest] = STEADY_PASSWORD_COMMAND.split(" ").filter(Boolean);
if (!bin) throw new Error("STEADY_PASSWORD_COMMAND is empty");
const { stdout } = await execFileAsync(bin, rest, { maxBuffer: 1024 * 1024 });
const pw = String(stdout ?? "").trim();
if (!pw) throw new Error("STEADY_PASSWORD_COMMAND returned empty output");
return pw;
}
// macOS Keychain fallback
if (process.platform === "darwin") {
const account = process.env.STEADY_KEYCHAIN_ACCOUNT ?? STEADY_EMAIL;
if (!account) return "";
try {
const { stdout } = await execFileAsync(
"security",
["find-generic-password", "-w", "-s", STEADY_KEYCHAIN_SERVICE, "-a", account],
{ maxBuffer: 1024 * 1024 },
);
return String(stdout ?? "").trim();
} catch {
return "";
}
}
return "";
}
async function ensureParentDir(filePath) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
}
async function readCookies() {
try {
const raw = await fs.readFile(COOKIES_PATH, "utf8");
const cookies = raw.trim();
if (!cookies) return null;
return cookies;
} catch (err) {
if (err?.code === "ENOENT") return null;
throw err;
}
}
async function writeCookies(cookies) {
await ensureParentDir(COOKIES_PATH);
// Best-effort permission hardening on macOS/Linux
await fs.writeFile(COOKIES_PATH, cookies.trim() + "\n", { mode: 0o600 });
}
async function writeCookieJar(jarContents) {
await ensureParentDir(COOKIE_JAR_PATH);
await fs.writeFile(COOKIE_JAR_PATH, jarContents, { mode: 0o600 });
}
async function fileExistsNonEmpty(filePath) {
try {
const stat = await fs.stat(filePath);
return stat.isFile() && stat.size > 0;
} catch (err) {
if (err?.code === "ENOENT") return false;
throw err;
}
}
function cookieHeaderToNetscapeJar(cookieHeader) {
// Minimal Netscape cookie jar so we can let curl handle Set-Cookie rotation.
// domain \t includeSubdomains \t path \t secure \t expiration \t name \t value
const header = String(cookieHeader ?? "").trim();
if (!header) return "";
const lines = [
"# Netscape HTTP Cookie File",
"# https://curl.se/docs/http-cookies.html",
"# This file was generated by steady-mcp",
"",
];
const parts = header.split(";").map((p) => p.trim()).filter(Boolean);
for (const part of parts) {
const eqIdx = part.indexOf("=");
if (eqIdx <= 0) continue;
const name = part.slice(0, eqIdx).trim();
const value = part.slice(eqIdx + 1).trim();
if (!name) continue;
// Use app.steady.space as the domain for auth cookies.
lines.push(["app.steady.space", "TRUE", "/", "TRUE", "0", name, value].join("\t"));
}
return lines.join("\n") + "\n";
}
async function getOrCreateCookieJarPath() {
// Prefer a real cookie jar (it handles session rotation).
if (await fileExistsNonEmpty(COOKIE_JAR_PATH)) return COOKIE_JAR_PATH;
// Fall back to cookies.txt and synthesize a jar.
const cookieHeader = await readCookies();
if (!cookieHeader) return null;
const jar = cookieHeaderToNetscapeJar(cookieHeader);
if (!jar) return null;
await writeCookieJar(jar);
return COOKIE_JAR_PATH;
}
function getLocalISODate(date = new Date()) {
// YYYY-MM-DD in local timezone
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function textToRichTextHtml(text) {
// Steady expects rich-text HTML for answer_set fields.
// We keep it safe by escaping user input and only adding minimal tags.
const raw = String(text ?? "").trim();
if (!raw) return "";
const paragraphs = raw
.split(/\n{2,}/g)
.map((p) => p.trim())
.filter(Boolean)
.map((p) => `<p>${escapeHtml(p).replace(/\r\n|\r|\n/g, "<br>")}</p>`);
return paragraphs.join("");
}
async function curl({ url, cookies, method = "GET", headers = {}, dataUrlencode = [], wantHeaders = false }) {
const args = ["-sS"];
if (wantHeaders) args.push("-D", "-");
args.push("-X", method);
if (cookies) args.push("-H", `Cookie: ${cookies}`);
for (const [k, v] of Object.entries(headers)) args.push("-H", `${k}: ${v}`);
for (const [k, v] of dataUrlencode) {
args.push("--data-urlencode", `${k}=${v}`);
}
args.push(url);
if (DEBUG) console.error("DEBUG curl args:", JSON.stringify(args));
// Use system curl. On macOS it's available by default.
const { stdout } = await execFileAsync("curl", args, { maxBuffer: 10 * 1024 * 1024 });
return stdout;
}
async function curlWithJar({
url,
jarPath,
method = "GET",
headers = {},
dataUrlencode = [],
}) {
const args = ["-sS", "-X", method, "-c", jarPath, "-b", jarPath];
for (const [k, v] of Object.entries(headers)) args.push("-H", `${k}: ${v}`);
for (const [k, v] of dataUrlencode) args.push("--data-urlencode", `${k}=${v}`);
args.push(url);
if (DEBUG) console.error("DEBUG curl(jar) args:", JSON.stringify(args));
const { stdout } = await execFileAsync("curl", args, { maxBuffer: 10 * 1024 * 1024 });
return stdout;
}
async function curlWithJarToFiles({
url,
jarPath,
method = "GET",
headers = {},
dataUrlencode = [],
}) {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "steady-mcp-"));
const headerFile = path.join(tmpDir, "headers.txt");
const bodyFile = path.join(tmpDir, "body.html");
try {
const args = [
"-sS",
"-X",
method,
"-c",
jarPath,
"-b",
jarPath,
"-D",
headerFile,
"-o",
bodyFile,
];
for (const [k, v] of Object.entries(headers)) args.push("-H", `${k}: ${v}`);
for (const [k, v] of dataUrlencode) args.push("--data-urlencode", `${k}=${v}`);
args.push(url);
if (DEBUG) console.error("DEBUG curl(jar,tofiles) args:", JSON.stringify(args));
await execFileAsync("curl", args, { maxBuffer: 10 * 1024 * 1024 });
const [headersText, body] = await Promise.all([
fs.readFile(headerFile, "utf8"),
fs.readFile(bodyFile, "utf8"),
]);
return { headersText, body };
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
async function fetchEditPage({ cookies, date }) {
const isoDate = date ?? getLocalISODate();
const url = `${STEADY_BASE_URL}/check-ins/${isoDate}/edit`;
// NOTE: Using cookie header here is brittle because Steady rotates _sthr_session on GET.
// Prefer the cookie-jar variants for authenticated flows.
const html = await curl({ url, cookies, method: "GET" });
return { html, isoDate, url };
}
function extractCsrfTokenFromHtml(html) {
// Prefer <meta name="csrf-token" content="...">
const metaMatch = html.match(/name="csrf-token"\s+content="([^"]+)"/);
if (metaMatch?.[1]) return metaMatch[1];
// Fallback to hidden input authenticity_token in the main form
const inputMatch = html.match(/name="authenticity_token"\s+value="([^"]+)"/);
if (inputMatch?.[1]) return inputMatch[1];
return null;
}
function extractCheckinFormTokenFromHtml(html) {
const formMatch = html.match(/<form[^>]*id="new_answer_set"[\s\S]*?<\/form>/);
if (!formMatch?.[0]) return null;
return formMatch[0].match(/name="authenticity_token"\s+value="([^"]+)"/)?.[1] ?? null;
}
function extractFirstLocation(headersText) {
// In case of redirects, curl writes multiple header blocks. Grab the last Location.
const lines = headersText.split("\n");
const locations = lines
.filter((l) => l.toLowerCase().startsWith("location:"))
.map((l) => l.split(":").slice(1).join(":").trim());
return locations.length ? locations[locations.length - 1] : null;
}
function absolutizeUrl(maybeRelative) {
if (!maybeRelative) return null;
if (maybeRelative.startsWith("http://") || maybeRelative.startsWith("https://")) return maybeRelative;
if (!maybeRelative.startsWith("/")) return `${STEADY_BASE_URL}/${maybeRelative}`;
return `${STEADY_BASE_URL}${maybeRelative}`;
}
function extractCheckinActionFromHtml(html) {
// Grab the action on the main answer_set form.
const formMatch = html.match(/<form[^>]*id="new_answer_set"[^>]*action="([^"]+)"/);
return formMatch?.[1] ?? null;
}
function extractFormContainingPassword(html) {
const forms = html.match(/<form\b[\s\S]*?<\/form>/g) ?? [];
return (
forms.find((f) => /type="password"/i.test(f)) ??
forms.find((f) => /password/i.test(f)) ??
null
);
}
function extractFormAction(formHtml) {
return formHtml?.match(/\baction="([^"]+)"/)?.[1] ?? null;
}
function extractHiddenInputs(formHtml) {
const inputs = [];
const re = /<input\b[^>]*type="hidden"[^>]*>/g;
const nameRe = /\bname="([^"]+)"/;
const valueRe = /\bvalue="([^"]*)"/;
const matches = formHtml.match(re) ?? [];
for (const inputTag of matches) {
const name = inputTag.match(nameRe)?.[1];
if (!name) continue;
const value = inputTag.match(valueRe)?.[1] ?? "";
inputs.push([name, value]);
}
return inputs;
}
function extractPasswordFieldName(formHtml) {
// Find the first password input and return its name.
const m = formHtml.match(/<input\b[^>]*type="password"[^>]*\bname="([^"]+)"/i);
return m?.[1] ?? null;
}
function extractEmailFieldName(formHtml) {
// Prefer an explicit email input on the password page.
const m = formHtml.match(/<input\b[^>]*type="email"[^>]*\bname="([^"]+)"/i);
if (m?.[1]) return m[1];
// Common Rails name if present anywhere in the form.
if (/\bname="user\[email\]"/.test(formHtml)) return "user[email]";
return null;
}
async function steadyLogin({ email, password }) {
if (!email) throw new Error("STEADY_EMAIL is not set (or email argument missing).");
if (!password) throw new Error("No password provided/found (set STEADY_PASSWORD, STEADY_PASSWORD_COMMAND, or macOS Keychain).");
await ensureParentDir(COOKIE_JAR_PATH);
// Start with a fresh cookie jar each login to avoid sticky states
await fs.rm(COOKIE_JAR_PATH, { force: true }).catch(() => {});
// 1) GET /signin to establish session + get authenticity_token
const signinUrl = `${STEADY_BASE_URL}/signin`;
const signinHtml = await curlWithJar({ url: signinUrl, jarPath: COOKIE_JAR_PATH });
const emailToken = signinHtml.match(/name="authenticity_token"\s+value="([^"]+)"/)?.[1];
if (!emailToken) throw new Error("Could not extract authenticity_token from /signin.");
// 2) POST email
const emailPost = await curlWithJarToFiles({
url: signinUrl,
jarPath: COOKIE_JAR_PATH,
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
dataUrlencode: [
["authenticity_token", emailToken],
["user[email]", email],
],
});
const nextUrl = absolutizeUrl(extractFirstLocation(emailPost.headersText));
if (!nextUrl) {
// If no redirect, maybe the password form is on the response body
const form = extractFormContainingPassword(emailPost.body);
if (!form) throw new Error("After submitting email, no redirect and no password form was found.");
}
// If redirect goes to magic_link, this account likely uses passwordless
if (nextUrl && nextUrl.includes("/magic_link")) {
throw new Error(
"This account appears to use magic-link sign-in (redirected to /magic_link). Use cookie-based auth or verify your login flow in the browser.",
);
}
// 3) GET password page (either redirected page or the email POST body)
const passwordPageHtml = nextUrl
? await curlWithJar({ url: nextUrl, jarPath: COOKIE_JAR_PATH })
: emailPost.body;
const passwordForm = extractFormContainingPassword(passwordPageHtml);
if (!passwordForm) {
throw new Error("Could not find a password form after submitting email.");
}
const passwordAction = extractFormAction(passwordForm);
if (!passwordAction) throw new Error("Password form has no action.");
const passwordUrl = absolutizeUrl(passwordAction);
const hidden = extractHiddenInputs(passwordForm);
const passwordFieldName = extractPasswordFieldName(passwordForm);
if (!passwordFieldName) throw new Error("Could not determine password field name from password form.");
const emailFieldName = extractEmailFieldName(passwordForm);
const formFields = new Map(hidden);
if (emailFieldName) formFields.set(emailFieldName, email);
formFields.set(passwordFieldName, password);
// Best effort remember-me (only if server recognizes it)
formFields.set("user[remember_me]", "1");
const passwordPost = await curlWithJarToFiles({
url: passwordUrl,
jarPath: COOKIE_JAR_PATH,
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
dataUrlencode: Array.from(formFields.entries()),
});
// 4) Verify we are signed in by fetching /check-ins
const checkinsHtml = await curlWithJar({
url: `${STEADY_BASE_URL}/check-ins`,
jarPath: COOKIE_JAR_PATH,
method: "GET",
});
const isSignedIn =
checkinsHtml.includes('meta name="current-user"') ||
checkinsHtml.includes('name="current-user"');
if (!isSignedIn) {
const loc = extractFirstLocation(passwordPost.headersText);
throw new Error(
`Login did not complete successfully (cannot access /check-ins). Redirect was: ${loc ?? "n/a"}`,
);
}
// 5) Persist cookie jar + also write a Cookie header string for the other tools.
const jarContents = await fs.readFile(COOKIE_JAR_PATH, "utf8").catch(() => "");
await writeCookieJar(jarContents);
const cookieHeader = netscapeJarToCookieHeader(jarContents);
if (!cookieHeader) {
// If jar parsing failed, try a simpler approach: extract Set-Cookie from the password POST response
const setCookieLines = passwordPost.headersText
.split("\n")
.filter((l) => l.toLowerCase().startsWith("set-cookie:"));
const simpleCookies = setCookieLines
.map((l) => l.split(":").slice(1).join(":").trim().split(";")[0])
.filter((c) => c.includes("_sthr_session") || c.includes("remember_user_token"))
.join("; ");
if (simpleCookies) {
await writeCookies(simpleCookies);
return { ok: true, cookies_path: COOKIES_PATH, cookie_jar_path: COOKIE_JAR_PATH };
}
throw new Error("Login succeeded, but could not extract cookies from cookie jar or Set-Cookie headers.");
}
await writeCookies(cookieHeader);
return { ok: true, cookies_path: COOKIES_PATH, cookie_jar_path: COOKIE_JAR_PATH };
}
function netscapeJarToCookieHeader(jarContents) {
// Netscape format: domain \t flag \t path \t secure \t expiration \t name \t value
const lines = String(jarContents ?? "").split("\n");
const pairs = new Map(); // name -> value
for (const line of lines) {
if (!line || line.startsWith("#")) continue;
const parts = line.split("\t");
if (parts.length < 7) continue;
const domain = parts[0];
const name = parts[5];
const value = parts[6];
// Only include Steady cookies
if (!domain.includes("steady.space")) continue;
if (!name) continue;
if (!pairs.has(name)) pairs.set(name, value);
}
const cookie = Array.from(pairs.entries())
.map(([k, v]) => `${k}=${v}`)
.join("; ");
return cookie;
}
function parseTeamsFromHtml(html) {
// Returns [{id, name}] from the multi-select list.
const teams = [];
const re =
/<li role="option"[^>]*data-label="([^"]+)"[\s\S]*?<input[^>]*name="team_ids\[\]"[^>]*value="([^"]+)"/g;
let m;
while ((m = re.exec(html)) !== null) {
const name = m[1];
const id = m[2];
teams.push({ id, name });
}
// De-dupe by id (keep first name)
const seen = new Set();
return teams.filter((t) => {
if (seen.has(t.id)) return false;
seen.add(t.id);
return true;
});
}
function resolveTeamId({ team, teams }) {
// Accept UUID directly
if (typeof team === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(team)) {
return team;
}
const normalized = String(team).trim().toLowerCase();
const exact = teams.find((t) => t.name.trim().toLowerCase() === normalized);
if (exact) return exact.id;
const partial = teams.find((t) => t.name.trim().toLowerCase().includes(normalized));
if (partial) return partial.id;
return null;
}
async function steadyPing({ cookies }) {
const url = `${STEADY_BASE_URL}/check-ins`;
const html = await curl({ url, cookies, method: "GET" });
const isSignedIn = html.includes('name="current-user"') || html.includes('meta name="current-user"');
return { ok: isSignedIn };
}
async function steadyListTeams({ jarPath, date }) {
const isoDate = date ?? getLocalISODate();
const url = `${STEADY_BASE_URL}/check-ins/${isoDate}/edit`;
const html = await curlWithJar({ url, jarPath, method: "GET" });
const teams = parseTeamsFromHtml(html);
return { date: isoDate, teams };
}
async function steadySubmitCheckin({
jarPath,
team,
text,
previous = "",
blockers = "",
mood = "calm",
date,
}) {
const isoDate = date ?? getLocalISODate();
const editUrl = `${STEADY_BASE_URL}/check-ins/${isoDate}/edit`;
const html = await curlWithJar({ url: editUrl, jarPath, method: "GET" });
// IMPORTANT: Steady appears to use per-form CSRF tokens; prefer the token in the check-in form.
const csrf = extractCheckinFormTokenFromHtml(html) ?? extractCsrfTokenFromHtml(html);
if (!csrf) {
throw new Error("Could not extract CSRF token. Your cookies may be expired.");
}
const action = extractCheckinActionFromHtml(html);
if (!action) {
throw new Error("Could not locate check-in form action on the edit page.");
}
const teams = parseTeamsFromHtml(html);
const teamId = resolveTeamId({ team, teams });
if (!teamId) {
throw new Error(
`Unknown team '${team}'. Use steady_list_teams to see available names/ids.`,
);
}
const updateUrl = action.startsWith("http") ? action : `${STEADY_BASE_URL}${action}`;
// Steady expects rich text HTML in answer_set fields
const previousHtml = textToRichTextHtml(previous);
const nextHtml = textToRichTextHtml(text);
const blockersHtml = textToRichTextHtml(blockers);
const { headersText } = await curlWithJarToFiles({
url: updateUrl,
jarPath,
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
dataUrlencode: [
["_method", "put"],
["authenticity_token", csrf],
["answer_set[previous]", previousHtml],
["answer_set[next]", nextHtml],
["answer_set[blockers]", blockersHtml],
["answer_set[mood]", mood],
["team_ids[]", teamId],
["button", ""],
["return_path", "/digest"],
],
});
// We can infer success if we got a 302 (common after Rails form submits).
const statusLines = headersText.split("\n").filter((l) => l.startsWith("HTTP/"));
const statusLine = statusLines.length ? statusLines[statusLines.length - 1] : null;
const statusCode = statusLine ? Number(statusLine.split(" ")[1]) : null;
const location = extractFirstLocation(headersText);
return {
date: isoDate,
team_id: teamId,
team_name: teams.find((t) => t.id === teamId)?.name ?? null,
status_code: statusCode,
location,
};
}
const server = new Server(
{ name: "steady-mcp", version: "0.1.0" },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "steady_login",
description:
"Log into Steady using the email+password flow and store fresh cookies locally for other tools to use. Reads credentials from env vars (STEADY_EMAIL + STEADY_PASSWORD or STEADY_PASSWORD_COMMAND or macOS Keychain).",
inputSchema: {
type: "object",
properties: {
email: { type: "string", description: "Optional override for STEADY_EMAIL." },
},
required: [],
},
},
{
name: "steady_set_cookies",
description:
"Store Steady cookies (Cookie header value) locally so other tools can authenticate. Provide at least remember_user_token and/or _sthr_session.",
inputSchema: {
type: "object",
properties: {
cookies: { type: "string", description: "Cookie header value, e.g. `_sthr_session=...; remember_user_token=...`" },
},
required: ["cookies"],
},
},
{
name: "steady_ping",
description: "Verify the stored cookies can access Steady (checks /check-ins).",
inputSchema: { type: "object", properties: {}, required: [] },
},
{
name: "steady_list_teams",
description: "List available teams (name + id) from the check-in edit page for a date (default: today).",
inputSchema: {
type: "object",
properties: {
date: { type: "string", description: "YYYY-MM-DD (optional; default: today)" },
},
required: [],
},
},
{
name: "steady_submit_checkin",
description:
"Submit today's Steady check-in for ONE team (no official API; uses Steady's web form). Supports: previous work, next work, blockers, and mood.",
inputSchema: {
type: "object",
properties: {
team: { type: "string", description: "Team name or team UUID." },
text: { type: "string", description: "What will you do next? (Steady field: next)" },
previous: { type: "string", description: "What did you do previously? (optional; Steady field: previous)" },
blockers: { type: "string", description: "Are you blocked by anything? (optional; Steady field: blockers)" },
mood: { type: "string", description: "Mood (optional). Default: calm." },
date: { type: "string", description: "YYYY-MM-DD (optional; default: today)" },
},
required: ["team", "text"],
},
},
],
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const name = request.params.name;
const args = request.params.arguments ?? {};
if (name === "steady_login") {
const email = args.email ? String(args.email) : STEADY_EMAIL;
const password = await getPassword();
const res = await steadyLogin({ email, password });
return { content: [{ type: "text", text: JSON.stringify(res, null, 2) }] };
}
if (name === "steady_set_cookies") {
const cookies = String(args.cookies ?? "").trim();
if (!cookies) throw new Error("cookies is required");
await writeCookies(cookies);
// Also generate a cookie jar so curl can track Set-Cookie rotation.
const jar = cookieHeaderToNetscapeJar(cookies);
if (jar) await writeCookieJar(jar);
return {
content: [
{ type: "text", text: `Saved cookies to ${COOKIES_PATH} and cookie jar to ${COOKIE_JAR_PATH}` },
],
};
}
const jarPath = await getOrCreateCookieJarPath();
if (!jarPath) {
return {
content: [
{
type: "text",
text:
"No cookies configured. Run steady_set_cookies with your Steady cookies first (copy _sthr_session and remember_user_token from the browser).",
},
],
isError: true,
};
}
if (name === "steady_ping") {
const html = await curlWithJar({ url: `${STEADY_BASE_URL}/check-ins`, jarPath, method: "GET" });
const isSignedIn =
html.includes('meta name="current-user"') ||
html.includes('name="current-user"');
const res = { ok: isSignedIn };
return { content: [{ type: "text", text: JSON.stringify(res, null, 2) }] };
}
if (name === "steady_list_teams") {
const date = args.date ? String(args.date) : undefined;
const res = await steadyListTeams({ jarPath, date });
return { content: [{ type: "text", text: JSON.stringify(res, null, 2) }] };
}
if (name === "steady_submit_checkin") {
const team = String(args.team);
const text = String(args.text);
const previous = args.previous ? String(args.previous) : "";
const blockers = args.blockers ? String(args.blockers) : "";
const mood = args.mood ? String(args.mood) : "calm";
const date = args.date ? String(args.date) : undefined;
const res = await steadySubmitCheckin({
jarPath,
team,
text,
previous,
blockers,
mood,
date,
});
return { content: [{ type: "text", text: JSON.stringify(res, null, 2) }] };
}
throw new Error(`Unknown tool: ${name}`);
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});