Skip to main content
Glama
index.js26.8 kB
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("&", "&amp;") .replaceAll("<", "&lt;") .replaceAll(">", "&gt;") .replaceAll('"', "&quot;") .replaceAll("'", "&#39;"); } 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); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Sarthak-ignite/steady-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server