plotcoder-board 0.1.0
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/README.md +99 -0
- package/package.json +57 -0
- package/scripts/plotcoder-call.mjs +141 -0
- package/scripts/plotcoder-http.mjs +114 -0
- package/scripts/plotcoder-mcp-server.mjs +2663 -0
- package/scripts/plotcoder-mcp.mjs +93 -0
- package/src/board/agents.d.ts +14 -0
- package/src/board/agents.js +78 -0
- package/src/board/fdx.d.ts +26 -0
- package/src/board/fdx.js +206 -0
- package/src/board/fountain.d.ts +48 -0
- package/src/board/fountain.js +246 -0
- package/src/board/numbering.d.ts +13 -0
- package/src/board/numbering.js +89 -0
- package/src/board/organize.d.ts +24 -0
- package/src/board/organize.js +151 -0
- package/src/board/paginate.d.ts +51 -0
- package/src/board/paginate.js +375 -0
- package/src/board/project.d.ts +72 -0
- package/src/board/project.js +236 -0
- package/src/board/projectFile.d.ts +33 -0
- package/src/board/projectFile.js +97 -0
- package/src/board/readWall.d.ts +70 -0
- package/src/board/readWall.js +406 -0
- package/src/board/reducer.d.ts +191 -0
- package/src/board/reducer.js +921 -0
- package/src/board/reminders.d.ts +6 -0
- package/src/board/reminders.js +53 -0
- package/src/board/sync.d.ts +65 -0
- package/src/board/sync.js +198 -0
- package/src/board/templates.d.ts +21 -0
- package/src/board/templates.js +110 -0
- package/src/board/words.d.ts +10 -0
- package/src/board/words.js +201 -0
- package/src/board/workflows.d.ts +24 -0
- package/src/board/workflows.js +116 -0
- package/src/board/zip.d.ts +5 -0
- package/src/board/zip.js +134 -0
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# PlotCoder
|
|
2
|
+
|
|
3
|
+
A storylining app for screenwriters, live at [plotcoder.com](http://plotcoder.com).
|
|
4
|
+
|
|
5
|
+
PlotCoder is a set of tools for building a storyline, covering the activities a writer does today in Final Draft. It is designed so that an **agent driven by a person** has every one of those tools: the person directs, the agent operates, and the board on screen is the person's window onto the same records. The first tool is the wall, a digital corkboard for breaking and rearranging plot before writing the script, because that is the part of the job Final Draft does worst. Pages come last. Once the tools exist, workflows get launched on top of them. The horizon, a long way off: a person makes a storyline they believe in, then the app helps them drive agents that build segments of the movie with video generation tools.
|
|
6
|
+
|
|
7
|
+
The full statement of purpose, every decision, and every requirement lives in [REQUIREMENTS.md](REQUIREMENTS.md). Read it before changing anything. It is the source of truth; this file is the front door.
|
|
8
|
+
|
|
9
|
+
## The method the tools serve
|
|
10
|
+
|
|
11
|
+
1. State the **logline**: the central question, what the story is arguing.
|
|
12
|
+
2. Mark the **beats**: the 8 to 15 major turns.
|
|
13
|
+
3. Fill the space between them with **scene cards**. One card is one scene.
|
|
14
|
+
4. **Read the wall**: find the act that sags, the setup with no payoff, the character who disappears.
|
|
15
|
+
5. Only then **write pages**.
|
|
16
|
+
|
|
17
|
+
Steps 1 to 4 are built. Step 4 reads the runs between beats and the setups and their payoffs, and asks ten kinds of question, including a sagging act, a setup with no payoff, a character who disappears, and two scenes doing the same job, in the Reminders modal and as a `read_wall` tool. The Story Map, a strip under the wall, draws the same reading along a page axis and jumps the wall to any card you click. A board has a cast: type "with Maya, Tom" on a card, and see the wall by person from the Cast lens. Step 5 is not started. See "What is built and what is left" in the requirements.
|
|
18
|
+
|
|
19
|
+
## Run it
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm ci
|
|
23
|
+
npm run dev
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Opens on `http://localhost:5173`. The board lives in the browser's localStorage and, while the dev server runs, is mirrored to `.plotcoder/board.json` so an agent can read and write it.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm test # kernel, geometry, layout, project file, MCP server
|
|
30
|
+
npm run test:e2e # the doors into the kernel, in a real browser (Playwright)
|
|
31
|
+
npm run build # typecheck and bundle to dist/
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The end-to-end suite starts its own dev server on port 5174 with its own board file, so it never touches your wall. It needs Chromium once: `npx playwright install chromium`.
|
|
35
|
+
|
|
36
|
+
Pushes to `main` run both suites and deploy `dist/` to GitHub Pages. A failing test blocks the deploy.
|
|
37
|
+
|
|
38
|
+
## Drive it from an agent
|
|
39
|
+
|
|
40
|
+
Every board verb goes through one command kernel, `src/board/reducer.js`, and there are three doors into it:
|
|
41
|
+
|
|
42
|
+
- **The wall.** Tap the words to type, drag the paper to move. Lasso to select, then Group. Drag a card's handle onto another card for an arrow. ⌘Z takes back any change, whichever door made it.
|
|
43
|
+
- **`window.plotcoder`** on the page, for a console or a CDP session.
|
|
44
|
+
- **The MCP server**, published to npm as `plotcoder-board`: `npx -y plotcoder-board@latest` is the server, `npx -y plotcoder-board@latest call <tool> '{json}'` one call from a shell, `npx -y plotcoder-board@latest serve` the hosted door on a port (a `Dockerfile` is here too). Inside the repo it is `scripts/plotcoder-mcp.mjs`, wired for Cursor in `.cursor/mcp.json` and for Claude Code in `.mcp.json` (run `npm ci` once first). A version tag (`v0.2.0`) publishes it, with `NPM_TOKEN` in the repo's secrets. Seventy-one tools: reading (`list_board`, `read_wall`, `read_pages`, `read_character`, `page_count`, `list_words`, `list_workflows`, `segment_brief`); the card, cast, place, group and arrow verbs, `set_logline`, `set_target`, `set_rank`, `set_plant`, `write_scene`, `organize`, `apply_template` with `list_structures`, `save_structure`, `remove_structure`; `undo` and `redo`; the project's `list_boards`, `open_board`, `new_board`, `rename_board`, `delete_board`, `set_premise`, `rename_project` and the reminders; Fountain and Final Draft both ways; the production half (`lock_numbers`, `unlock_numbers`, `start_revision`, `end_revision`); the project as a file both ways (`export_project`, `import_project`); and, through the account door, `list_projects`, `open_project`, `new_project`, `delete_project`, `empty_account`, `delete_account`, `add_picture`, `add_take`, `list_takes`, `list_files`, `remove_file`, `build_segment`. If the dev app is open, a tool call lands on the wall within a second; if not, it edits the board file and the wall catches up on the next load.
|
|
45
|
+
- **The account door.** With `PLOTCODER_EMAIL` and `PLOTCODER_PASSWORD` in the agent's environment — the writer's own — and no dev app answering, the same server works the writer's project on the account directly, and every change lands live on every open wall. `PLOTCODER_PROJECT` picks a project by name or id. No account yet? `claim_account` makes one with the writer's email and a password they chose. The on-ramp — the doors, what to call first, the rules — is in the app behind *Are you an agent? Start here* and served at [plotcoder.com/llms.txt](https://plotcoder.com/llms.txt), both from `src/board/agents.js`.
|
|
46
|
+
|
|
47
|
+
An agent should call the tools, never fake mouse drags. The skill in `.cursor/skills/plotcoder-board/SKILL.md` says how; `.claude/skills/plotcoder-board` is a symlink to the same file.
|
|
48
|
+
|
|
49
|
+
## Blind runs, and the test account
|
|
50
|
+
|
|
51
|
+
A **blind run** is a fresh agent given the on-ramp and a treatment and nothing
|
|
52
|
+
else, asked to build a wall and to keep a log of everything that made the job
|
|
53
|
+
harder than it should have been. The friction log is the product; the wall is
|
|
54
|
+
just what produces it. Three rounds have been run and all 73 of their
|
|
55
|
+
findings are fixed; the fourth ran through the account door and its twenty-three
|
|
56
|
+
are fixed too. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
|
|
57
|
+
the table of rounds, and the next round's prompt with the test account filled in.
|
|
58
|
+
|
|
59
|
+
A round works a **test account** — a throwaway marked on its writer row, and the
|
|
60
|
+
only kind of account the wipe script will touch (R44). Mark it once, then empty
|
|
61
|
+
it between rounds:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
node scripts/wipe-test-account.mjs test@test.com --mark # once, to make it wipeable
|
|
65
|
+
node scripts/wipe-test-account.mjs test@test.com # the plan; changes nothing
|
|
66
|
+
node scripts/wipe-test-account.mjs test@test.com --empty --yes # projects go, account stays
|
|
67
|
+
node scripts/wipe-test-account.mjs test@test.com --delete --yes # the account goes too
|
|
68
|
+
node scripts/wipe-test-account.mjs test@test.com --unmark # back to a writer's account
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Needs `SUPABASE_SERVICE_ROLE_KEY` in the shell; it is not in the repo and must
|
|
72
|
+
not be. Nothing changes without `--yes`, and the script refuses any address that
|
|
73
|
+
is not marked, so a mistyped address cannot take a writer's work. A project
|
|
74
|
+
merely *shared with* the test account belongs to whoever made it and survives.
|
|
75
|
+
|
|
76
|
+
**Prefer `--empty` between rounds.** `--delete` means claiming the address
|
|
77
|
+
again, which changes the credentials in the round's prompt and quietly turns the
|
|
78
|
+
next round into a test of `claim_account` instead of the door it meant to test.
|
|
79
|
+
|
|
80
|
+
## Layout of the repo
|
|
81
|
+
|
|
82
|
+
| Path | What it is |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `REQUIREMENTS.md` | Purpose, decisions, requirements, open questions, what is left. The source of truth. |
|
|
85
|
+
| `src/board/reducer.js` | The kernel. Pure, DOM-free, runs in the browser and in Node. |
|
|
86
|
+
| `src/board/store.ts` | Browser store: localStorage, the dev bridge, `window.plotcoder`. |
|
|
87
|
+
| `src/App.tsx`, `src/NoteBoard.tsx`, `src/NoteCard.tsx` | The wall. |
|
|
88
|
+
| `src/GeneralBar.tsx`, `src/Logline.tsx` | Screen chrome: the bottom-right bar and the logline strip. |
|
|
89
|
+
| `src/viewport.ts`, `src/arrowGeometry.ts`, `src/organizeLayout.ts` | Pure helpers, tested. |
|
|
90
|
+
| `scripts/plotcoder-mcp.mjs` | The MCP server's front door: starts `plotcoder-mcp-server.mjs`, or answers in words when `npm ci` has not been run. |
|
|
91
|
+
| `scripts/plotcoder-mcp-server.mjs` | The MCP server. |
|
|
92
|
+
| `vite.config.ts` | The dev bridge that mirrors the board to a file on localhost. Never ships. |
|
|
93
|
+
| `.github/workflows/deploy.yml` | Test, build, deploy to Pages. |
|
|
94
|
+
| `blind-runs/` | The blind-run practice: the rules, the rounds, and each round's prompt and treatment. |
|
|
95
|
+
| `scripts/wipe-test-account.mjs` | Empty or remove a marked test account (R44). `wipe-plan.mjs` is the pure rail it decides by. |
|
|
96
|
+
|
|
97
|
+
## Status
|
|
98
|
+
|
|
99
|
+
Version 0.1.0. A project of boards; sign in with your email and a password from the PlotCoder mark and your projects follow you to every device, share one with another writer by email and write it together live, or stay signed out and work on this device as before. Pages sit beside the wall: a scene's text lives on its card, measures it, paginates to the industry's rules, prints, and goes out and comes in as Fountain or Final Draft. It installs as a progressive web app and opens offline; plotcoder.com serves over HTTPS. The wall, beats, card length, groups, arrows, pan and zoom, save and open, and the agent surface are in use.
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "plotcoder-board",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"prebuild": "node scripts/write-agents-text.mjs",
|
|
8
|
+
"build": "tsc -b && vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"test:watch": "vitest",
|
|
12
|
+
"test:e2e": "playwright test",
|
|
13
|
+
"serve": "node scripts/plotcoder-http.mjs"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
17
|
+
"@supabase/supabase-js": "^2.116.0",
|
|
18
|
+
"react": "^19.1.1",
|
|
19
|
+
"react-dom": "^19.1.1",
|
|
20
|
+
"ws": "^8.21.3",
|
|
21
|
+
"zod": "^4.6.2"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@playwright/test": "^1.63.0",
|
|
25
|
+
"@types/node": "^22.20.2",
|
|
26
|
+
"@types/react": "^19.1.12",
|
|
27
|
+
"@types/react-dom": "^19.1.9",
|
|
28
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
29
|
+
"tsx": "^4.23.13",
|
|
30
|
+
"typescript": "^5.9.2",
|
|
31
|
+
"vite": "^5.4.19",
|
|
32
|
+
"vite-plugin-pwa": "^1.3.0",
|
|
33
|
+
"vitest": "^3.2.7"
|
|
34
|
+
},
|
|
35
|
+
"description": "PlotCoder's MCP server: a storyline wall for screenwriters, driven by an agent. `npx plotcoder-board` is the server; `npx plotcoder-board call <tool>` makes one call from a shell.",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/plot-coder/plotcoder.github.io.git"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://plotcoder.com/llms.txt",
|
|
42
|
+
"bin": {
|
|
43
|
+
"plotcoder-board": "scripts/plotcoder-mcp.mjs"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"scripts/plotcoder-mcp.mjs",
|
|
47
|
+
"scripts/plotcoder-mcp-server.mjs",
|
|
48
|
+
"scripts/plotcoder-call.mjs",
|
|
49
|
+
"scripts/plotcoder-http.mjs",
|
|
50
|
+
"src/board/*.js",
|
|
51
|
+
"src/board/*.d.ts",
|
|
52
|
+
"README.md"
|
|
53
|
+
],
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=20"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One tool call to the PlotCoder MCP server, from a shell — for an agent
|
|
3
|
+
// without MCP support, or a person checking a tool by hand.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/plotcoder-call.mjs list_words
|
|
6
|
+
// node scripts/plotcoder-call.mjs create_note '{"headline":"Maya finds the letter","change":"She decides not to tell Tom."}'
|
|
7
|
+
// node scripts/plotcoder-call.mjs tools # every tool, with its description
|
|
8
|
+
//
|
|
9
|
+
// Speaks JSON-RPC over stdio to scripts/plotcoder-mcp.mjs and prints the
|
|
10
|
+
// reply's text. The same environment applies: PLOTCODER_ROOT for where the
|
|
11
|
+
// files live, PLOTCODER_EMAIL and PLOTCODER_PASSWORD for the account door.
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const SERVER = fileURLToPath(new URL("./plotcoder-mcp.mjs", import.meta.url));
|
|
17
|
+
const [tool, rawArgs] = process.argv.slice(2);
|
|
18
|
+
|
|
19
|
+
const USAGE = `usage: node scripts/plotcoder-call.mjs <tool> ['{json arguments}']
|
|
20
|
+
node scripts/plotcoder-call.mjs --batch < calls.jsonl many calls on one server
|
|
21
|
+
node scripts/plotcoder-call.mjs tools every tool, with its description
|
|
22
|
+
node scripts/plotcoder-call.mjs --help
|
|
23
|
+
|
|
24
|
+
One call, one server: each run starts scripts/plotcoder-mcp.mjs, makes the call,
|
|
25
|
+
and stops it. So undo, and the project new_project or open_project chose, do not
|
|
26
|
+
carry from one call to the next — set PLOTCODER_PROJECT for the calls that need
|
|
27
|
+
it, or run them as a batch: one call per line on stdin, as
|
|
28
|
+
{"tool": "create_note", "arguments": {"headline": "…", "change": "…"}},
|
|
29
|
+
all on one server, so undo and the opened project hold within the batch. Each
|
|
30
|
+
reply is printed under a line naming its tool. An MCP session keeps one server
|
|
31
|
+
for the whole conversation.
|
|
32
|
+
|
|
33
|
+
The environment is the server's:
|
|
34
|
+
PLOTCODER_ROOT the folder whose wall you mean (default: this one)
|
|
35
|
+
PLOTCODER_EMAIL the writer's sign-in: the account door, no app needed
|
|
36
|
+
PLOTCODER_PASSWORD a wrong password is refused by every tool, never worked around
|
|
37
|
+
PLOTCODER_PROJECT which of the writer's projects, by name or id
|
|
38
|
+
PLOTCODER_SESSION=0 sign in on every call instead of keeping the sign-in in .plotcoder/
|
|
39
|
+
PLOTCODER_JSON=1 keep the JSON tail on replies (off on this door)
|
|
40
|
+
PLOTCODER_VERBOSE=1 show the server's own log lines`;
|
|
41
|
+
|
|
42
|
+
const batch = tool === "--batch";
|
|
43
|
+
if (!tool || tool === "--help" || tool === "-h" || tool === "help") {
|
|
44
|
+
console.error(USAGE);
|
|
45
|
+
process.exit(tool ? 0 : 2);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let args = {};
|
|
49
|
+
if (rawArgs && !batch) {
|
|
50
|
+
try {
|
|
51
|
+
args = JSON.parse(rawArgs);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error(`arguments must be JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The server's own log lines ("ready", "account door") stay off the reply
|
|
59
|
+
// unless PLOTCODER_VERBOSE=1 asks for them.
|
|
60
|
+
// PLOTCODER_ONE_CALL tells the server it will not live past this call, so its
|
|
61
|
+
// replies can say what does not carry to the next one.
|
|
62
|
+
// The JSON tail is off on this door unless asked for: the text carries the ids.
|
|
63
|
+
const child = spawn("node", [SERVER], {
|
|
64
|
+
stdio: ["pipe", "pipe", process.env.PLOTCODER_VERBOSE ? "inherit" : "ignore"],
|
|
65
|
+
env: { ...process.env, PLOTCODER_ONE_CALL: "1", PLOTCODER_JSON: process.env.PLOTCODER_JSON === "1" ? "1" : "0" },
|
|
66
|
+
});
|
|
67
|
+
let buffer = "";
|
|
68
|
+
const pending = new Map();
|
|
69
|
+
let nextId = 1;
|
|
70
|
+
child.stdout.setEncoding("utf8");
|
|
71
|
+
child.stdout.on("data", (chunk) => {
|
|
72
|
+
buffer += chunk;
|
|
73
|
+
let cut;
|
|
74
|
+
while ((cut = buffer.indexOf("\n")) >= 0) {
|
|
75
|
+
const line = buffer.slice(0, cut).trim();
|
|
76
|
+
buffer = buffer.slice(cut + 1);
|
|
77
|
+
if (!line) continue;
|
|
78
|
+
let message;
|
|
79
|
+
try {
|
|
80
|
+
message = JSON.parse(line);
|
|
81
|
+
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const waiting = pending.get(message.id);
|
|
85
|
+
if (waiting) {
|
|
86
|
+
pending.delete(message.id);
|
|
87
|
+
waiting(message);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const send = (message) => child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
93
|
+
const request = (method, params) =>
|
|
94
|
+
new Promise((resolve, reject) => {
|
|
95
|
+
const id = nextId++;
|
|
96
|
+
const timer = setTimeout(() => reject(new Error(`timed out waiting for ${method}`)), 30_000);
|
|
97
|
+
pending.set(id, (message) => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
if (message.error) reject(new Error(message.error.message));
|
|
100
|
+
else resolve(message.result);
|
|
101
|
+
});
|
|
102
|
+
send({ jsonrpc: "2.0", id, method, params });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
await request("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "plotcoder-call", version: "1.0.0" } });
|
|
107
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
108
|
+
if (tool === "tools") {
|
|
109
|
+
const { tools } = await request("tools/list", {});
|
|
110
|
+
for (const item of tools) console.log(`${item.name} — ${item.description}\n`);
|
|
111
|
+
} else if (batch) {
|
|
112
|
+
// One server for every line: undo and the opened project hold within the batch.
|
|
113
|
+
let input = "";
|
|
114
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
115
|
+
const lines = input.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
116
|
+
for (const line of lines) {
|
|
117
|
+
let call;
|
|
118
|
+
try {
|
|
119
|
+
call = JSON.parse(line);
|
|
120
|
+
} catch {
|
|
121
|
+
console.log(`--- (skipped: not JSON) ${line.slice(0, 80)}`);
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const name = call.tool ?? call.name;
|
|
126
|
+
console.log(`--- ${name}`);
|
|
127
|
+
const result = await request("tools/call", { name, arguments: call.arguments ?? call.args ?? {} });
|
|
128
|
+
for (const part of result.content ?? []) if (part.type === "text") console.log(part.text);
|
|
129
|
+
if (result.isError) process.exitCode = 1;
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
const result = await request("tools/call", { name: tool, arguments: args });
|
|
133
|
+
for (const part of result.content ?? []) if (part.type === "text") console.log(part.text);
|
|
134
|
+
if (result.isError) process.exitCode = 1;
|
|
135
|
+
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
138
|
+
process.exitCode = 1;
|
|
139
|
+
} finally {
|
|
140
|
+
child.kill();
|
|
141
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The hosted door (R48): PlotCoder's MCP server over HTTP, for a writer's
|
|
3
|
+
// agent with nothing installed. One server per request, made from the
|
|
4
|
+
// writer's own sign-in on the request — Authorization: Basic base64(email:
|
|
5
|
+
// password), or X-PlotCoder-Email and X-PlotCoder-Password — so two writers
|
|
6
|
+
// never share a door, and the account wins as it does everywhere else. No
|
|
7
|
+
// disk: the file door is off, and export_project answers with the file as
|
|
8
|
+
// JSON. Stateless streamable HTTP: any MCP client that speaks it connects
|
|
9
|
+
// with `--transport http` and the two headers.
|
|
10
|
+
//
|
|
11
|
+
// PORT=8787 node scripts/plotcoder-http.mjs (or: npx plotcoder-board serve)
|
|
12
|
+
//
|
|
13
|
+
// Deploy it anywhere Node runs (a Dockerfile is in the repo); put it behind
|
|
14
|
+
// HTTPS, since the sign-in travels in the header.
|
|
15
|
+
|
|
16
|
+
import http from "node:http";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
20
|
+
import { createPlotcoderServer } from "./plotcoder-mcp-server.mjs";
|
|
21
|
+
|
|
22
|
+
const PORT = Number(process.env.PORT ?? 8787);
|
|
23
|
+
const HOST = process.env.HOST ?? "0.0.0.0";
|
|
24
|
+
const SCRATCH = path.join(os.tmpdir(), "plotcoder-hosted");
|
|
25
|
+
|
|
26
|
+
const ABOUT = `PlotCoder's hosted MCP door.
|
|
27
|
+
|
|
28
|
+
POST /mcp with the writer's sign-in on the request:
|
|
29
|
+
Authorization: Basic base64(email:password)
|
|
30
|
+
(or X-PlotCoder-Email and X-PlotCoder-Password)
|
|
31
|
+
X-PlotCoder-Project: a project name or id (optional)
|
|
32
|
+
|
|
33
|
+
For Claude Code, once:
|
|
34
|
+
claude mcp add plotcoder --transport http https://<this host>/mcp --header "Authorization: Basic <base64 of email:password>"
|
|
35
|
+
|
|
36
|
+
The on-ramp: https://plotcoder.com/llms.txt
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
/** The writer's sign-in from the request, or null. */
|
|
40
|
+
export function credentialsFrom(headers) {
|
|
41
|
+
const auth = String(headers.authorization ?? "");
|
|
42
|
+
if (/^basic\s+/i.test(auth)) {
|
|
43
|
+
const decoded = Buffer.from(auth.replace(/^basic\s+/i, ""), "base64").toString("utf8");
|
|
44
|
+
const cut = decoded.indexOf(":");
|
|
45
|
+
if (cut > 0) return { email: decoded.slice(0, cut), password: decoded.slice(cut + 1) };
|
|
46
|
+
}
|
|
47
|
+
const email = headers["x-plotcoder-email"];
|
|
48
|
+
const password = headers["x-plotcoder-password"];
|
|
49
|
+
if (typeof email === "string" && typeof password === "string" && email && password) return { email, password };
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The environment one request's server runs with: the writer's sign-in, no bridge, no disk. */
|
|
54
|
+
export function envFor(headers, base = process.env) {
|
|
55
|
+
const creds = credentialsFrom(headers);
|
|
56
|
+
if (!creds) return null;
|
|
57
|
+
return {
|
|
58
|
+
PLOTCODER_HOSTED: "1",
|
|
59
|
+
PLOTCODER_NO_BRIDGE: "1",
|
|
60
|
+
PLOTCODER_ROOT: SCRATCH,
|
|
61
|
+
PLOTCODER_JSON: base.PLOTCODER_JSON ?? "1",
|
|
62
|
+
PLOTCODER_EMAIL: creds.email,
|
|
63
|
+
PLOTCODER_PASSWORD: creds.password,
|
|
64
|
+
PLOTCODER_PROJECT: typeof headers["x-plotcoder-project"] === "string" ? headers["x-plotcoder-project"] : "",
|
|
65
|
+
...(base.VITE_SUPABASE_URL ? { VITE_SUPABASE_URL: base.VITE_SUPABASE_URL } : {}),
|
|
66
|
+
...(base.VITE_SUPABASE_KEY ? { VITE_SUPABASE_KEY: base.VITE_SUPABASE_KEY } : {}),
|
|
67
|
+
...(base.PLOTCODER_VIDEO_PROVIDER ? { PLOTCODER_VIDEO_PROVIDER: base.PLOTCODER_VIDEO_PROVIDER } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createHostedDoor(base = process.env) {
|
|
72
|
+
return http.createServer(async (req, res) => {
|
|
73
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
74
|
+
if (url.pathname === "/" || url.pathname === "/health") {
|
|
75
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
76
|
+
res.end(ABOUT);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (url.pathname !== "/mcp") {
|
|
80
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
81
|
+
res.end("Not here. The door is POST /mcp.\n");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const env = envFor(req.headers, base);
|
|
85
|
+
if (!env) {
|
|
86
|
+
res.writeHead(401, { "content-type": "text/plain; charset=utf-8", "www-authenticate": 'Basic realm="PlotCoder"' });
|
|
87
|
+
res.end("The hosted door needs the writer's sign-in on the request: Authorization: Basic base64(email:password), or X-PlotCoder-Email and X-PlotCoder-Password.\n");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// One server per request, stateless: the sign-in is the session.
|
|
91
|
+
const { server } = createPlotcoderServer(env);
|
|
92
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
93
|
+
res.on("close", () => {
|
|
94
|
+
void transport.close();
|
|
95
|
+
void server.close();
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
await server.connect(transport);
|
|
99
|
+
await transport.handleRequest(req, res);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (!res.headersSent) {
|
|
102
|
+
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
103
|
+
res.end(`The door failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
|
|
110
|
+
if (isMain || process.env.PLOTCODER_SERVE === "1") {
|
|
111
|
+
createHostedDoor().listen(PORT, HOST, () => {
|
|
112
|
+
process.stderr.write(`[plotcoder-http] the hosted door is open on http://${HOST}:${PORT}/mcp\n`);
|
|
113
|
+
});
|
|
114
|
+
}
|