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 +22 -0
- package/mcp/dist/index.js +363 -27
- package/mcp/dist/panel.html +30 -14
- package/mcp/dist/screencast.js +316 -0
- package/mcp/dist/twitterAuth.js +19 -0
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +3 -3
- package/package.json +1 -1
- package/scripts/capture_thread_media.py +101 -22
- package/scripts/harness_overlay.py +761 -0
- package/scripts/reset-test-machine.sh +152 -0
- package/scripts/score_twitter_candidates.py +7 -0
- package/scripts/seed_search_queries.py +72 -5
- package/scripts/setup_twitter_auth.py +128 -14
- package/scripts/twitter_browser.py +59 -4
- package/scripts/twitter_scan.py +25 -3
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
|
1145
|
-
"status, or controls.
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|