social-autoposter 1.6.56 → 1.6.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -1223,6 +1223,25 @@ function installRuntime() {
1223
1223
  process.exit(res.status == null ? 1 : res.status);
1224
1224
  }
1225
1225
 
1226
+ // Wipe a social-autoposter install back to factory-fresh (test machines /
1227
+ // uninstall). Shells out to the bundled scripts/reset-test-machine.sh, which is
1228
+ // the single source of truth: it removes the owned state dir (~/.social-
1229
+ // autoposter-mcp), packaged Chrome profiles + imported cookies, the browser-
1230
+ // harness clone/CLI/server.py, the global npm package, and the MCP registration.
1231
+ // DEFAULT IS A DRY RUN (prints what WOULD be removed); pass --yes to apply, and
1232
+ // --deep to also remove the shared uv toolchain + Chromium cache. Forwarding to
1233
+ // the shell script keeps npm and .mcpb installs behaving identically.
1234
+ function reset() {
1235
+ const script = path.join(PKG_ROOT, 'scripts', 'reset-test-machine.sh');
1236
+ if (!fs.existsSync(script)) {
1237
+ console.error(`Cannot find ${script}. Re-run \`npx social-autoposter update\` to repair the install.`);
1238
+ process.exit(1);
1239
+ }
1240
+ // Forward everything after `reset` (e.g. --yes, --deep) straight to the script.
1241
+ const res = spawnSync('bash', [script, ...process.argv.slice(3)], { stdio: 'inherit' });
1242
+ process.exit(res.status == null ? 1 : res.status);
1243
+ }
1244
+
1226
1245
  const cmd = process.argv[2];
1227
1246
  if (cmd === 'init') {
1228
1247
  init();
@@ -1234,6 +1253,8 @@ if (cmd === 'init') {
1234
1253
  bootstrapVm();
1235
1254
  } else if (cmd === 'install-runtime') {
1236
1255
  installRuntime();
1256
+ } else if (cmd === 'reset' || cmd === 'uninstall') {
1257
+ reset();
1237
1258
  } else if (cmd === 'export-cookies') {
1238
1259
  // Forward to cookie-helper with 'export' + remaining args
1239
1260
  process.argv = [process.argv[0], process.argv[1], 'export', ...process.argv.slice(3)];
@@ -1266,4 +1287,5 @@ if (cmd === 'init') {
1266
1287
  console.log(' npx social-autoposter install-runtime provision owned Python + Chromium (panel-free)');
1267
1288
  console.log(' npx social-autoposter export-cookies [dir] export browser cookies');
1268
1289
  console.log(' npx social-autoposter import-cookies [dir] import browser cookies');
1290
+ console.log(' npx social-autoposter reset [--yes] [--deep] uninstall/wipe the install (dry-run unless --yes)');
1269
1291
  }
package/mcp/dist/index.js CHANGED
@@ -13,16 +13,18 @@
13
13
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
14
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
15
  import { z } from "zod";
16
+ import { screencast, bringBrowserToFront } from "./screencast.js";
16
17
  import os from "node:os";
17
18
  import path from "node:path";
18
19
  import fs from "node:fs";
19
20
  import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
20
21
  import { applySetup, resolveProject, hasReadyProject, listManagedProjectStatus, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, } from "./setup.js";
21
- import { xStatus, xConnect, summarizeXAuth } from "./twitterAuth.js";
22
+ import { xStatus, xConnect, xDetectSources, summarizeXAuth } from "./twitterAuth.js";
22
23
  import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, } from "./runtime.js";
23
24
  import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
24
- import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server";
25
+ import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, getUiCapability, } from "@modelcontextprotocol/ext-apps/server";
25
26
  import { fileURLToPath } from "node:url";
27
+ import http from "node:http";
26
28
  // MCP Apps control panel. The self-contained HTML is built by vite
27
29
  // (vite-plugin-singlefile) into dist/panel.html alongside this compiled file.
28
30
  const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -127,7 +129,25 @@ const server = new McpServer({
127
129
  "TYPICAL FLOW: `setup` (configure the project once) -> `draft_cycle` (scan + review a batch; the " +
128
130
  "user approves / edits / skips every draft in a single form) -> `autopilot` (enable to also turn " +
129
131
  "on hands-free background posting AND daily auto-updates) -> `get_stats` (see performance). Run " +
130
- "`setup` first; the other tools refuse until a project is fully configured.",
132
+ "`setup` first; the other tools refuse until a project is fully configured.\n\n" +
133
+ "RENDER THE DASHBOARD AFTER ACTIONS. After any state-changing or results-producing tool call " +
134
+ "(`draft_cycle`, `post_drafts`, `autopilot` enable/disable, `get_stats`), end your turn by " +
135
+ "calling the `dashboard` tool so the user sees the updated state visually. Do NOT call " +
136
+ "`dashboard` after pure Q&A, config explanations, or status-only checks that changed nothing.",
137
+ });
138
+ const TOOL_HANDLERS = {};
139
+ const baseRegisterTool = server.registerTool.bind(server);
140
+ // `tool` is TYPED as server.registerTool so every call site keeps the exact
141
+ // same input-schema -> callback-arg inference it had before; the body is `any`
142
+ // and just additionally stashes the callback by name. `appTool` drops the
143
+ // leading `server` arg of registerAppTool (its callback takes no typed args).
144
+ const tool = ((name, config, cb) => {
145
+ TOOL_HANDLERS[name] = cb;
146
+ return baseRegisterTool(name, config, cb);
147
+ });
148
+ const appTool = ((name, config, cb) => {
149
+ TOOL_HANDLERS[name] = cb;
150
+ return registerAppTool(server, name, config, cb);
131
151
  });
132
152
  function jsonContent(obj) {
133
153
  return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] };
@@ -413,7 +433,7 @@ server.registerPrompt("getting-started", {
413
433
  // across several calls — readiness is derived from config.json, never a stored
414
434
  // flag. Call with status:true (or just no name) to list every project this
415
435
  // install manages and what each still needs.
416
- server.registerTool("setup", {
436
+ tool("setup", {
417
437
  title: "Set up a project",
418
438
  description: "Run this FIRST, before any drafting or autopilot. Two jobs:\n" +
419
439
  "1) Configure a project this install posts for: its website, what it does (description), who " +
@@ -431,10 +451,16 @@ server.registerTool("setup", {
431
451
  inputSchema: {
432
452
  status: z.boolean().optional(),
433
453
  action: z
434
- .enum(["connect_x"])
454
+ .enum(["connect_x", "detect_x_sources", "reseed_queries"])
435
455
  .optional()
436
456
  .describe("connect_x = import/validate your X session in the autoposter's managed browser. " +
437
- "Without confirm:true it only EXPLAINS what it will do (so you can tell the user first)."),
457
+ "Without confirm:true it only EXPLAINS what it will do (so you can tell the user first). " +
458
+ "detect_x_sources = list the browsers/profiles the X session can be imported from " +
459
+ "(read-only, no keychain prompt) so the user can pick the right one; returns " +
460
+ "{sources:[{spec,label,x_session}], recommended}. " +
461
+ "reseed_queries = (re-)expand an EXISTING project's search topics into ~30 real X search " +
462
+ "queries and return them. Use for projects configured before query-expansion existed, or " +
463
+ "to refresh the query bank. Requires name. Returns the resulting query list."),
438
464
  confirm: z
439
465
  .boolean()
440
466
  .optional()
@@ -473,6 +499,20 @@ server.registerTool("setup", {
473
499
  .describe("Anything the posts must avoid saying / claiming"),
474
500
  },
475
501
  }, async (args) => {
502
+ // ---- List import sources (for the panel dropdown) ---------------------
503
+ // Read-only browser/profile detection. Never reads the keychain or decrypts
504
+ // a cookie, so it shows no macOS Safe Storage prompt. Lets the user pick the
505
+ // exact browser+profile that holds their X session.
506
+ if (args.action === "detect_x_sources") {
507
+ const r = await xDetectSources();
508
+ return jsonContent({
509
+ action: "detect_x_sources",
510
+ ok: r.ok,
511
+ sources: r.sources,
512
+ recommended: r.recommended,
513
+ error: r.error,
514
+ });
515
+ }
476
516
  // ---- Connect X/Twitter: import the user's session into our browser ----
477
517
  // Explain-then-confirm: the first call (no confirm) describes exactly what
478
518
  // will happen so the agent can get the user's OK; confirm:true runs it.
@@ -519,6 +559,69 @@ server.registerTool("setup", {
519
559
  : "X is not connected yet. " + summarizeXAuth(r),
520
560
  });
521
561
  }
562
+ // ---- Reseed search queries: (re-)expand an existing project's topics ----
563
+ // For projects configured before query-expansion shipped (their topics live
564
+ // in the DB but the seed-query bank is empty, so the cycle cold-starts on one
565
+ // crude query), or to refresh the bank on demand. Idempotent: the seeder
566
+ // dedups against what's already there. Returns the resulting queries so the
567
+ // agent can show the user exactly what the cycle will fan out over.
568
+ if (args.action === "reseed_queries") {
569
+ if (!args.name) {
570
+ return jsonContent({
571
+ action: "reseed_queries",
572
+ error: "name is required — tell me which configured project to reseed.",
573
+ });
574
+ }
575
+ try {
576
+ const qseed = await runPython("scripts/seed_search_queries.py", ["--project", args.name, "--supply-test", "auto", "--emit-json"], { timeoutMs: 600_000 });
577
+ const qm = /seeded=(\d+)\s+inserted=(\d+)\s+updated=(\d+)/.exec(qseed.stdout);
578
+ let queries = [];
579
+ const jm = qseed.stdout.split("===QUERIES_JSON===")[1];
580
+ if (jm) {
581
+ try {
582
+ queries = (JSON.parse(jm.trim()).queries ?? []);
583
+ }
584
+ catch {
585
+ /* leave queries empty; count note below still informs the user */
586
+ }
587
+ }
588
+ if (qseed.code !== 0 && queries.length === 0) {
589
+ const qtail = (qseed.stderr || qseed.stdout).trim().split("\n").slice(-1)[0] ||
590
+ "unknown error";
591
+ return jsonContent({
592
+ action: "reseed_queries",
593
+ project: args.name,
594
+ ok: false,
595
+ error: qtail,
596
+ note: `Couldn't expand search queries for '${args.name}' — ${qtail}. ` +
597
+ `Common causes: the project has no search topics seeded yet (run setup with name='${args.name}' ` +
598
+ `and its search_topics first), or X/Claude wasn't reachable. The cycle still runs off the seeded topics.`,
599
+ });
600
+ }
601
+ return jsonContent({
602
+ action: "reseed_queries",
603
+ project: args.name,
604
+ ok: true,
605
+ seeded: qm ? Number(qm[1]) : queries.length,
606
+ inserted: qm ? Number(qm[2]) : undefined,
607
+ updated: qm ? Number(qm[3]) : undefined,
608
+ query_count: queries.length,
609
+ queries,
610
+ note: `Expanded '${args.name}' into ${queries.length} active search quer` +
611
+ `${queries.length === 1 ? "y" : "ies"}. The cycle now fans out over these instead of ` +
612
+ `running a single query. Show the user the list so they can confirm they look on-target; ` +
613
+ `re-run this anytime to refresh, or run draft_cycle to use them.`,
614
+ });
615
+ }
616
+ catch (e) {
617
+ return jsonContent({
618
+ action: "reseed_queries",
619
+ project: args.name,
620
+ ok: false,
621
+ error: e.message,
622
+ });
623
+ }
624
+ }
522
625
  // Status / discovery mode: no project name supplied, or explicitly asked.
523
626
  if (args.status === true || !args.name) {
524
627
  const projects = listManagedProjectStatus();
@@ -568,6 +671,7 @@ server.registerTool("setup", {
568
671
  // the user if topics are missing. Only runs once the project is ready
569
672
  // (i.e. it actually has search_topics to seed). (2026-06-02)
570
673
  let seedNote = "";
674
+ let searchQueries = [];
571
675
  if (result.ready) {
572
676
  const seed = await runPython("scripts/seed_search_topics.py", ["--project", result.project], { timeoutMs: 60_000 });
573
677
  if (seed.code === 0) {
@@ -590,10 +694,20 @@ server.registerTool("setup", {
590
694
  // stays fast when X isn't connected yet. (2026-06-04)
591
695
  if (seed.code === 0) {
592
696
  try {
593
- const qseed = await runPython("scripts/seed_search_queries.py", ["--project", result.project, "--supply-test", "auto"], { timeoutMs: 600_000 });
697
+ const qseed = await runPython("scripts/seed_search_queries.py", ["--project", result.project, "--supply-test", "auto", "--emit-json"], { timeoutMs: 600_000 });
594
698
  const qm = /seeded=(\d+)\s+inserted=(\d+)\s+updated=(\d+)/.exec(qseed.stdout);
699
+ const qjson = qseed.stdout.split("===QUERIES_JSON===")[1];
700
+ if (qjson) {
701
+ try {
702
+ searchQueries = (JSON.parse(qjson.trim()).queries ?? []);
703
+ }
704
+ catch {
705
+ /* leave empty; count note still informs the user */
706
+ }
707
+ }
595
708
  if (qseed.code === 0 && qm) {
596
- seedNote += ` Expanded them into ${qm[1]} search quer${qm[1] === "1" ? "y" : "ies"} so the cycle can fan out instead of running a single query.`;
709
+ const n = searchQueries.length || Number(qm[1]);
710
+ seedNote += ` Expanded them into ${n} search quer${n === 1 ? "y" : "ies"} so the cycle can fan out instead of running a single query.`;
597
711
  }
598
712
  else if (qseed.code !== 0) {
599
713
  const qtail = (qseed.stderr || qseed.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
@@ -612,6 +726,7 @@ server.registerTool("setup", {
612
726
  ready: result.ready,
613
727
  missing_required: result.missing_required,
614
728
  topics_seeded: result.ready,
729
+ search_queries: searchQueries,
615
730
  config_path: configPath(),
616
731
  note: result.ready
617
732
  ? `Project '${result.project}' is fully set up.${seedNote} Next: connect X so the autoposter can post — ` +
@@ -629,14 +744,15 @@ server.registerTool("setup", {
629
744
  // Posting is a SEPARATE step (post_drafts) so the user picks by number in chat.
630
745
  // This host doesn't support elicitation, so there is no in-tool form: the model
631
746
  // relays the table and asks which to post / edit, then calls post_drafts.
632
- server.registerTool("draft_cycle", {
747
+ tool("draft_cycle", {
633
748
  title: "Draft an X reply cycle",
634
749
  description: "Scan X and draft replies on this machine, then return ALL drafts as a numbered table " +
635
750
  "for review. This tool POSTS NOTHING. Show the table to the user and ask which numbers " +
636
751
  "to post and which to rewrite, then call `post_drafts` with their decision and the " +
637
752
  "returned batch_id. The table MUST show, per draft: the thread being replied to " +
638
753
  "(thread_text), the draft reply, and the link target (link_url) when present; never " +
639
- "drop those columns. Flow: discover -> draft -> review in chat -> post_drafts.",
754
+ "drop those columns. Flow: discover -> draft -> review in chat -> post_drafts. " +
755
+ "After returning the table, call the `dashboard` tool so the user sees the updated state.",
640
756
  inputSchema: {
641
757
  project: z
642
758
  .string()
@@ -735,14 +851,15 @@ server.registerTool("draft_cycle", {
735
851
  // Second half of the manual loop. The user reviewed the table from draft_cycle
736
852
  // and said which numbers to post / edit; this posts exactly those. Editing a
737
853
  // draft implies posting it. Indices are 1-based, matching the table.
738
- server.registerTool("post_drafts", {
854
+ tool("post_drafts", {
739
855
  title: "Post chosen drafts",
740
856
  description: "Post the drafts the user approved from a draft_cycle batch. Pass the batch_id from " +
741
857
  "draft_cycle and the user's decision by NUMBER (1-based, matching the table): `post` is " +
742
858
  "the list of draft numbers to post as drafted; `edits` rewrites a draft's text before " +
743
859
  "posting it (editing implies posting); `post_all` posts every draft. Only the chosen " +
744
860
  "drafts post; anything not listed is left unposted. Call this ONLY after the user has " +
745
- "told you which drafts they want.",
861
+ "told you which drafts they want. After posting, call the `dashboard` tool so the user " +
862
+ "sees the updated state.",
746
863
  inputSchema: {
747
864
  batch_id: z.string().describe("The batch_id returned by draft_cycle."),
748
865
  post: z
@@ -816,11 +933,12 @@ server.registerTool("post_drafts", {
816
933
  });
817
934
  });
818
935
  // ---- autopilot: one tool, three actions -----------------------------------
819
- server.registerTool("autopilot", {
936
+ tool("autopilot", {
820
937
  title: "X autopilot",
821
938
  description: "Control background X/Twitter posting. action=enable loads the launchd job so the " +
822
939
  "cycle fires automatically; action=disable unloads it (manual draft_cycle still works); " +
823
- "action=status reports whether it is loaded.",
940
+ "action=status reports whether it is loaded. After enable/disable, call the `dashboard` " +
941
+ "tool so the user sees the updated autopilot state.",
824
942
  inputSchema: {
825
943
  action: z.enum(["enable", "disable", "status"]),
826
944
  },
@@ -898,10 +1016,11 @@ server.registerTool("autopilot", {
898
1016
  });
899
1017
  });
900
1018
  // ---- get_stats: read-only -------------------------------------------------
901
- server.registerTool("get_stats", {
1019
+ tool("get_stats", {
902
1020
  title: "Get X/Twitter stats",
903
1021
  description: "Read-only post + engagement stats for the X/Twitter rail over the last N days. " +
904
- "Wraps project_stats_json.py. Use to show the user how their posts are performing.",
1022
+ "Wraps project_stats_json.py. Use to show the user how their posts are performing. " +
1023
+ "After returning the numbers, call the `dashboard` tool so the user sees them rendered.",
905
1024
  inputSchema: {
906
1025
  days: z.number().int().min(1).max(90).default(7),
907
1026
  project: z
@@ -929,7 +1048,7 @@ server.registerTool("get_stats", {
929
1048
  }
930
1049
  });
931
1050
  // ---- version: report installed version + deliver updates on demand ---------
932
- server.registerTool("version", {
1051
+ tool("version", {
933
1052
  title: "Version & updates",
934
1053
  description: "Report the installed social-autoposter version and check npm for a newer release. " +
935
1054
  "action:'status' (default) shows installed vs latest published and whether an update is " +
@@ -996,7 +1115,7 @@ function runtimeSnapshot() {
996
1115
  progress: progress ?? null,
997
1116
  };
998
1117
  }
999
- server.registerTool("install_runtime", {
1118
+ tool("install_runtime", {
1000
1119
  title: "Install the Python runtime",
1001
1120
  description: "One-time setup that provisions the self-contained runtime the autoposter needs: a private " +
1002
1121
  "Python (via uv, not your system Python), its dependencies, and the Chromium browser. Runs in " +
@@ -1016,7 +1135,7 @@ server.registerTool("install_runtime", {
1016
1135
  progress,
1017
1136
  });
1018
1137
  });
1019
- server.registerTool("install_status", {
1138
+ tool("install_status", {
1020
1139
  title: "Runtime install status",
1021
1140
  description: "Report whether the self-contained Python/Chromium runtime is installed and, if an install is " +
1022
1141
  "in progress, the per-step progress (uv, Python, venv, dependencies, Chromium). Poll this after " +
@@ -1027,7 +1146,7 @@ server.registerTool("install_status", {
1027
1146
  // The panel renders the full config and lets the user edit it. Writing is
1028
1147
  // guarded: the new content must parse as JSON, and we always drop a timestamped
1029
1148
  // backup next to config.json before overwriting, so a bad paste is recoverable.
1030
- server.registerTool("config", {
1149
+ tool("config", {
1031
1150
  title: "View or edit config.json",
1032
1151
  description: "Read or update the autoposter's config.json (the source of truth for every project, the X/" +
1033
1152
  "Reddit/LinkedIn account handles, topics, and exclusions). action:'get' (default) returns the " +
@@ -1137,14 +1256,140 @@ async function buildSnapshot() {
1137
1256
  runtime_provisioning: isProvisioning(),
1138
1257
  };
1139
1258
  }
1140
- registerAppTool(server, "panel", {
1141
- title: "Social Autoposter panel",
1142
- description: "Open the Social Autoposter control panel: a visual dashboard showing project setup, X " +
1259
+ // ---- dashboard localhost fallback -----------------------------------------
1260
+ // When the connected host doesn't support MCP Apps UI (Claude Code / Cowork
1261
+ // today), serve the SAME dist/panel.html from a loopback HTTP server. The page
1262
+ // detects it's running over HTTP (window.__SAPS_BRIDGE__) and routes every
1263
+ // app.callServerTool through POST /tool/<name>, which replays the exact captured
1264
+ // handler in TOOL_HANDLERS. No pipeline or front-end logic is duplicated.
1265
+ // True if the host advertised it can render our ui:// HTML resource inline.
1266
+ function hostRendersAppUi() {
1267
+ try {
1268
+ const caps = (server.server.getClientCapabilities?.() ?? null);
1269
+ const uiCap = getUiCapability(caps);
1270
+ return !!uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE);
1271
+ }
1272
+ catch {
1273
+ return false;
1274
+ }
1275
+ }
1276
+ let localPanel = null;
1277
+ // Read the built panel.html and flip it into HTTP-bridge mode by injecting a
1278
+ // flag the front-end reads at boot. Same bytes as the inline ui:// resource,
1279
+ // minus the postMessage host (there's none over loopback).
1280
+ function panelHtmlForHttp() {
1281
+ const html = fs.readFileSync(path.join(DIST_DIR, "panel.html"), "utf-8");
1282
+ const inject = `<script>window.__SAPS_BRIDGE__=${JSON.stringify("http")};</script>`;
1283
+ if (html.includes("</head>"))
1284
+ return html.replace("</head>", inject + "</head>");
1285
+ return inject + html;
1286
+ }
1287
+ function readBody(req) {
1288
+ return new Promise((resolve, reject) => {
1289
+ const chunks = [];
1290
+ req.on("data", (c) => chunks.push(Buffer.from(c)));
1291
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
1292
+ req.on("error", reject);
1293
+ });
1294
+ }
1295
+ // Start (or reuse) the loopback HTTP server that serves the dashboard plus a
1296
+ // /tool/<name> dispatch endpoint backed by TOOL_HANDLERS. Bound to 127.0.0.1 on
1297
+ // an OS-assigned ephemeral port so nothing is exposed off-box.
1298
+ function startLocalPanel() {
1299
+ if (localPanel)
1300
+ return Promise.resolve(localPanel.url);
1301
+ return new Promise((resolve, reject) => {
1302
+ const srv = http.createServer(async (req, res) => {
1303
+ try {
1304
+ const url = new URL(req.url || "/", "http://127.0.0.1");
1305
+ if (req.method === "GET" &&
1306
+ (url.pathname === "/" || url.pathname === "/panel" || url.pathname === "/index.html")) {
1307
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1308
+ res.end(panelHtmlForHttp());
1309
+ return;
1310
+ }
1311
+ if (req.method === "GET" && url.pathname === "/health") {
1312
+ res.writeHead(200, { "Content-Type": "application/json" });
1313
+ res.end(JSON.stringify({ ok: true }));
1314
+ return;
1315
+ }
1316
+ if (req.method === "POST" && url.pathname.startsWith("/tool/")) {
1317
+ const name = decodeURIComponent(url.pathname.slice("/tool/".length));
1318
+ const handler = TOOL_HANDLERS[name];
1319
+ if (!handler) {
1320
+ res.writeHead(404, { "Content-Type": "application/json" });
1321
+ res.end(JSON.stringify({ isError: true, content: [{ type: "text", text: `Unknown tool: ${name}` }] }));
1322
+ return;
1323
+ }
1324
+ const raw = await readBody(req);
1325
+ let args = {};
1326
+ if (raw.trim()) {
1327
+ try {
1328
+ args = JSON.parse(raw);
1329
+ }
1330
+ catch {
1331
+ args = {};
1332
+ }
1333
+ }
1334
+ let result;
1335
+ try {
1336
+ result = await handler(args ?? {}, {});
1337
+ }
1338
+ catch (e) {
1339
+ result = { isError: true, content: [{ type: "text", text: String(e?.message || e) }] };
1340
+ }
1341
+ res.writeHead(200, { "Content-Type": "application/json" });
1342
+ res.end(JSON.stringify(result ?? {}));
1343
+ return;
1344
+ }
1345
+ res.writeHead(404, { "Content-Type": "text/plain" });
1346
+ res.end("not found");
1347
+ }
1348
+ catch (e) {
1349
+ try {
1350
+ res.writeHead(500, { "Content-Type": "text/plain" });
1351
+ res.end(String(e?.message || e));
1352
+ }
1353
+ catch { /* response already sent */ }
1354
+ }
1355
+ });
1356
+ srv.on("error", reject);
1357
+ srv.listen(0, "127.0.0.1", () => {
1358
+ const addr = srv.address();
1359
+ const port = typeof addr === "object" && addr ? addr.port : 0;
1360
+ localPanel = { url: `http://127.0.0.1:${port}/`, server: srv };
1361
+ resolve(localPanel.url);
1362
+ });
1363
+ });
1364
+ }
1365
+ // Open a URL in the user's default browser, cross-platform. Honors
1366
+ // SAPS_PANEL_NO_OPEN (set on headless autopilot boxes or in tests) to skip the
1367
+ // actual open while still returning the URL to the caller.
1368
+ async function openInBrowser(url) {
1369
+ if (process.env.SAPS_PANEL_NO_OPEN)
1370
+ return;
1371
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
1372
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
1373
+ try {
1374
+ await run(cmd, args, { timeoutMs: 10_000 });
1375
+ }
1376
+ catch (e) {
1377
+ console.error("[social-autoposter-mcp] openInBrowser failed:", e?.message || e);
1378
+ }
1379
+ }
1380
+ appTool("dashboard", {
1381
+ title: "Social Autoposter dashboard",
1382
+ description: "Render the Social Autoposter dashboard in chat: a visual surface showing project setup, X " +
1143
1383
  "connection, autopilot state, and 7-day stats, with buttons to run a draft cycle, toggle " +
1144
- "autopilot, connect X, and refresh. Use when the user asks to see the panel, dashboard, " +
1145
- "status, or controls. Hosts without UI support get the same data as text.",
1384
+ "autopilot, connect X, and refresh. Use when the user asks to see the dashboard, panel, " +
1385
+ "status, or controls. ALSO call this at the end of any state-changing or results-producing " +
1386
+ "action (draft_cycle, post_drafts, autopilot enable/disable, get_stats) so the user sees the " +
1387
+ "updated dashboard. Hosts without UI support get the same data as text.",
1146
1388
  inputSchema: {},
1147
- outputSchema: { snapshot: z.string() },
1389
+ // fallback_url is set only when the host can't render the ui:// resource and
1390
+ // we open the dashboard via the loopback HTTP server instead. Declared
1391
+ // optional so the SDK's strict output-schema check accepts both shapes.
1392
+ outputSchema: { snapshot: z.string(), fallback_url: z.string().optional() },
1148
1393
  _meta: { ui: { resourceUri: PANEL_URI } },
1149
1394
  }, async () => {
1150
1395
  const snap = await buildSnapshot();
@@ -1153,10 +1398,101 @@ registerAppTool(server, "panel", {
1153
1398
  ` — projects ${snap.projects_ready}/${snap.projects_total} ready, ` +
1154
1399
  `X ${snap.x_connected ? "connected" : "not connected"}, ` +
1155
1400
  `autopilot ${snap.autopilot_on ? "on" : "off"}.`;
1156
- return {
1401
+ const base = {
1157
1402
  content: [{ type: "text", text: human }],
1158
1403
  structuredContent: { snapshot: JSON.stringify(snap) },
1159
1404
  };
1405
+ // If the host can render MCP Apps UI inline, the _meta.ui.resourceUri above
1406
+ // makes it paint the panel; just return the snapshot. If it CAN'T (Claude
1407
+ // Code / Cowork today), fall back to serving the identical panel.html from a
1408
+ // loopback HTTP server and opening it in the browser, so the user still gets
1409
+ // the visual surface instead of a wall of text.
1410
+ if (hostRendersAppUi())
1411
+ return base;
1412
+ try {
1413
+ const url = await startLocalPanel();
1414
+ await openInBrowser(url);
1415
+ return {
1416
+ content: [{
1417
+ type: "text",
1418
+ text: human +
1419
+ `\n\nThis host can't render the dashboard inline, so I opened it in your browser: ${url}`,
1420
+ }],
1421
+ structuredContent: { snapshot: JSON.stringify(snap), fallback_url: url },
1422
+ };
1423
+ }
1424
+ catch (e) {
1425
+ // Loopback server failed to start; degrade to the text-only snapshot.
1426
+ console.error("[social-autoposter-mcp] local panel fallback failed:", e?.message || e);
1427
+ return base;
1428
+ }
1429
+ });
1430
+ // ---- show browser to user: live CDP screencast ----------------------------
1431
+ // Streams a live view of the autoposter's managed Chrome into the panel. Frames
1432
+ // travel back through the normal tool-result channel as a data: URL (which the
1433
+ // default panel CSP already permits), so this needs no CSP widening and no
1434
+ // direct network access from the iframe. The panel polls action:"frame".
1435
+ //
1436
+ // This is a PLAIN tool (not appTool): it renders nothing of its own, it only
1437
+ // feeds frames into the existing `dashboard` panel via callServerTool. Registering
1438
+ // it as an app-tool requires a `_meta.ui.resourceUri`; without one,
1439
+ // registerAppTool throws "Cannot read properties of undefined (reading 'ui')" at
1440
+ // startup and the whole server fails to connect. So keep it a regular tool.
1441
+ tool("show_browser_to_user", {
1442
+ title: "Show browser to user",
1443
+ description: "Show the user a LIVE view of the autoposter's managed Chrome (what the bot " +
1444
+ "is doing in the browser right now). Attaches a CDP screencast to the active " +
1445
+ "browser session and returns the newest frame as a data: image. Actions: " +
1446
+ "'start' begins the screencast, 'frame' returns the latest frame (poll this on " +
1447
+ "a short interval to animate), 'stop' ends it, 'front' raises the real browser " +
1448
+ "window above everything else so the user can interact with it directly. Use when " +
1449
+ "the user asks to see / watch the browser, or to bring the browser to the front.",
1450
+ inputSchema: {
1451
+ action: z.enum(["start", "frame", "stop", "front"]).optional(),
1452
+ port: z.number().int().optional().describe("CDP debugging port to attach to; auto-detected if omitted."),
1453
+ },
1454
+ }, async (args) => {
1455
+ const action = args?.action || "frame";
1456
+ if (action === "stop") {
1457
+ screencast.stop();
1458
+ return jsonContent({ ok: true, running: false });
1459
+ }
1460
+ if (action === "front") {
1461
+ const res = await bringBrowserToFront(typeof args?.port === "number" ? args.port : undefined);
1462
+ if (!res.ok) {
1463
+ const message = res.error === "no_browser"
1464
+ ? "No managed Chrome is running right now, so there's nothing to bring to the front. Start a draft cycle or autopilot first."
1465
+ : "Couldn't bring the browser to the front: " + String(res.error);
1466
+ return jsonContent({ ok: false, brought_to_front: false, message });
1467
+ }
1468
+ return jsonContent({ ok: true, brought_to_front: true, port: res.port });
1469
+ }
1470
+ const ensured = await screencast.ensure(typeof args?.port === "number" ? args.port : undefined);
1471
+ if (!ensured.ok) {
1472
+ const message = ensured.error === "no_browser"
1473
+ ? "No managed Chrome is running right now. Start a draft cycle or autopilot so there's a live browser session to show."
1474
+ : ensured.error === "no_websocket"
1475
+ ? "This Node runtime has no WebSocket support (needs Node 21+), so a screencast can't be opened."
1476
+ : "Couldn't attach to the browser: " + String(ensured.error);
1477
+ return jsonContent({ ok: false, running: false, frame: null, message });
1478
+ }
1479
+ // On a fresh start the first frame takes a beat to arrive; wait briefly so the
1480
+ // caller's first poll already has something to paint.
1481
+ let frame = screencast.frame();
1482
+ for (let i = 0; i < 12 && !frame; i++) {
1483
+ await new Promise((r) => setTimeout(r, 120));
1484
+ frame = screencast.frame();
1485
+ }
1486
+ const st = screencast.status();
1487
+ return jsonContent({
1488
+ ok: true,
1489
+ running: st.running,
1490
+ port: st.port,
1491
+ title: st.title,
1492
+ url: st.url,
1493
+ age_ms: st.age_ms,
1494
+ frame: frame ? `data:image/jpeg;base64,${frame}` : null,
1495
+ });
1160
1496
  });
1161
1497
  registerAppResource(server, "Social Autoposter panel", PANEL_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => ({
1162
1498
  contents: [