@composer-app/mcp 0.0.1-beta.0 → 0.0.1-beta.1

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.
@@ -1,6 +1,7 @@
1
1
  // src/mcp.ts
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
5
  import {
5
6
  CallToolRequestSchema,
6
7
  ListToolsRequestSchema
@@ -8,6 +9,8 @@ import {
8
9
  import { nanoid as nanoid2 } from "nanoid";
9
10
  import path3 from "path";
10
11
  import os2 from "os";
12
+ import http from "http";
13
+ import { randomUUID } from "crypto";
11
14
 
12
15
  // src/roomState.ts
13
16
  import * as Y3 from "yjs";
@@ -15,6 +18,7 @@ import YProvider from "y-partyserver/provider";
15
18
  import WebSocket from "ws";
16
19
 
17
20
  // src/docReaders.ts
21
+ import { markdownFromYFragment } from "@composer-app/shared";
18
22
  import * as Y from "yjs";
19
23
  function isXmlElement(node) {
20
24
  return node instanceof Y.XmlElement;
@@ -104,9 +108,7 @@ function renderBlockAsMarkdown(block) {
104
108
  return text;
105
109
  }
106
110
  function serializeDocAsMarkdown(doc) {
107
- const blocks = topLevelBlocks(doc);
108
- if (blocks.length === 0) return "";
109
- return blocks.map(renderBlockAsMarkdown).join("\n\n");
111
+ return markdownFromYFragment(doc.getXmlFragment("default"));
110
112
  }
111
113
 
112
114
  // src/anchors.ts
@@ -289,6 +291,64 @@ function resolveServerAnchor(doc, spec) {
289
291
  };
290
292
  }
291
293
 
294
+ // src/logger.ts
295
+ import * as fs from "fs";
296
+ import * as path from "path";
297
+ import * as os from "os";
298
+ var COMPOSER_DIR = process.env.COMPOSER_CONFIG_DIR ?? path.join(os.homedir(), ".composer");
299
+ var LOG_FILE = process.env.COMPOSER_LOG_FILE ?? path.join(COMPOSER_DIR, "mcp.log");
300
+ var ensured = false;
301
+ function ensureDir() {
302
+ if (ensured) return;
303
+ try {
304
+ fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
305
+ ensured = true;
306
+ } catch {
307
+ }
308
+ }
309
+ function write(line) {
310
+ ensureDir();
311
+ try {
312
+ fs.appendFileSync(LOG_FILE, line);
313
+ } catch {
314
+ }
315
+ try {
316
+ process.stderr.write(line);
317
+ } catch {
318
+ }
319
+ }
320
+ function log(message, meta) {
321
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
322
+ const metaStr = meta ? ` ${JSON.stringify(meta)}` : "";
323
+ write(`${ts} [composer-mcp] ${message}${metaStr}
324
+ `);
325
+ }
326
+ function logError(message, err) {
327
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
328
+ const detail = err instanceof Error ? `${err.name}: ${err.message}
329
+ ${err.stack ?? ""}` : typeof err === "string" ? err : JSON.stringify(err);
330
+ write(`${ts} [composer-mcp] ERROR ${message}
331
+ ${detail}
332
+ `);
333
+ }
334
+ var crashHandlersInstalled = false;
335
+ function installCrashHandlers() {
336
+ if (crashHandlersInstalled) return;
337
+ crashHandlersInstalled = true;
338
+ process.on("uncaughtException", (err) => {
339
+ logError("uncaughtException", err);
340
+ process.exit(1);
341
+ });
342
+ process.on("unhandledRejection", (reason) => {
343
+ logError("unhandledRejection", reason);
344
+ process.exit(1);
345
+ });
346
+ process.on("exit", (code) => {
347
+ log("process exit", { code });
348
+ });
349
+ }
350
+ var LOG_FILE_PATH = LOG_FILE;
351
+
292
352
  // src/roomState.ts
293
353
  var RoomState = class {
294
354
  doc = new Y3.Doc();
@@ -326,8 +386,74 @@ var RoomState = class {
326
386
  this.provider.awareness.setLocalStateField("user", {
327
387
  name: opts.actingAs,
328
388
  color: opts.identity.color,
329
- userId: opts.identity.userId
389
+ userId: opts.identity.userId,
390
+ isAgent: true
330
391
  });
392
+ this.installAwarenessHeartbeat();
393
+ this.installAwarenessDiagnostics();
394
+ }
395
+ /**
396
+ * Re-broadcast the MCP's awareness every 15s.
397
+ *
398
+ * y-partyserver's provider disables the y-protocols awareness
399
+ * `_checkInterval` (see `clearInterval(awareness._checkInterval)` in
400
+ * `y-partyserver/dist/provider/index.js`), so the MCP sends its awareness
401
+ * exactly once — on connect — and never heartbeats after that. Combined
402
+ * with Cloudflare Durable Object hibernation (which evicts the server's
403
+ * in-memory `document.awareness` Map on wake), this means a browser that
404
+ * connects more than ~60s after the MCP sees an empty awareness dump in
405
+ * `onConnect` and never learns the agent is there. The user's own
406
+ * awareness flows the other direction fine (they send on connect, server
407
+ * broadcasts to the MCP), which is why the failure is asymmetric.
408
+ *
409
+ * Calling `setLocalState(getLocalState())` forces an awareness `update`
410
+ * event, which the provider's handler broadcasts as a normal awareness
411
+ * frame. 15s is well under any realistic hibernation gap.
412
+ */
413
+ installAwarenessHeartbeat() {
414
+ const heartbeat = setInterval(() => {
415
+ const local = this.provider.awareness.getLocalState();
416
+ if (local !== null) {
417
+ this.provider.awareness.setLocalState(local);
418
+ }
419
+ }, 15e3);
420
+ heartbeat.unref?.();
421
+ }
422
+ /**
423
+ * Diagnostic: prove whether our own awareness state is set locally and
424
+ * whether the server echoes peer states back to us. Logs four snapshots
425
+ * over the first 30s of a room's life, then stops. Remove once the
426
+ * "agent avatar not visible" bug is root-caused.
427
+ */
428
+ installAwarenessDiagnostics() {
429
+ const snapshot = (label) => {
430
+ const ws = this.provider.ws;
431
+ const entries = Array.from(this.provider.awareness.getStates().entries());
432
+ log("awareness-diag", {
433
+ roomId: this.roomId,
434
+ label,
435
+ wsReadyState: ws?.readyState ?? null,
436
+ synced: this.provider.synced,
437
+ localClientId: this.doc.clientID,
438
+ localState: this.provider.awareness.getLocalState(),
439
+ peerCount: entries.length,
440
+ peers: entries.map(([clientId, state]) => ({
441
+ clientId,
442
+ user: state?.user ?? null
443
+ }))
444
+ });
445
+ };
446
+ snapshot("t+0 (post-setLocalState)");
447
+ setTimeout(() => snapshot("t+2s"), 2e3);
448
+ setTimeout(() => snapshot("t+10s"), 1e4);
449
+ setTimeout(() => snapshot("t+30s"), 3e4);
450
+ let tick = 1;
451
+ const interval = setInterval(() => {
452
+ snapshot(`t+${tick}min`);
453
+ tick += 1;
454
+ if (tick > 10) clearInterval(interval);
455
+ }, 6e4);
456
+ interval.unref?.();
331
457
  }
332
458
  /**
333
459
  * Resolves when the provider has completed its first sync handshake.
@@ -482,8 +608,8 @@ function hashState(doc) {
482
608
  }
483
609
 
484
610
  // src/identity.ts
485
- import * as fs from "fs/promises";
486
- import * as path from "path";
611
+ import * as fs2 from "fs/promises";
612
+ import * as path2 from "path";
487
613
  import { nanoid } from "nanoid";
488
614
  var PALETTE = [
489
615
  "#a855f7",
@@ -510,9 +636,9 @@ function isValidIdentity(value) {
510
636
  return typeof v.userId === "string" && typeof v.color === "string";
511
637
  }
512
638
  async function loadOrCreateIdentity(dir) {
513
- const filePath = path.join(dir, FILE_NAME);
639
+ const filePath = path2.join(dir, FILE_NAME);
514
640
  try {
515
- const raw = await fs.readFile(filePath, "utf8");
641
+ const raw = await fs2.readFile(filePath, "utf8");
516
642
  const parsed = JSON.parse(raw);
517
643
  if (isValidIdentity(parsed)) {
518
644
  return { userId: parsed.userId, color: parsed.color };
@@ -526,118 +652,19 @@ async function loadOrCreateIdentity(dir) {
526
652
  userId: nanoid(),
527
653
  color: pickColor()
528
654
  };
529
- await fs.mkdir(dir, { recursive: true });
530
- await fs.writeFile(filePath, JSON.stringify(identity, null, 2), {
655
+ await fs2.mkdir(dir, { recursive: true });
656
+ await fs2.writeFile(filePath, JSON.stringify(identity, null, 2), {
531
657
  mode: FILE_MODE
532
658
  });
533
659
  return identity;
534
660
  }
535
661
 
536
662
  // src/mdToFragment.ts
537
- import { marked } from "marked";
538
- import * as Y4 from "yjs";
663
+ import { writeMarkdownToYFragment } from "@composer-app/shared";
539
664
  function writeMarkdownToFragment(fragment, markdown) {
540
- const doc = fragment.doc;
541
- if (!doc) throw new Error("fragment must be attached to a Y.Doc");
542
- const tokens = marked.lexer(markdown);
543
- doc.transact(() => {
544
- for (const token of tokens) {
545
- const element = buildElementForToken(token);
546
- if (element) fragment.push([element]);
547
- }
548
- });
549
- }
550
- function buildElementForToken(token) {
551
- if (token.type === "heading") {
552
- const heading = token;
553
- return makeBlock("heading", heading.text ?? "", {
554
- level: String(heading.depth ?? 1)
555
- });
556
- }
557
- if (token.type === "paragraph") {
558
- const paragraph = token;
559
- return makeBlock("paragraph", paragraph.text ?? "");
560
- }
561
- if (token.type === "space") {
562
- return null;
563
- }
564
- const raw = token.raw?.trim();
565
- if (!raw) return null;
566
- return makeBlock("paragraph", raw);
567
- }
568
- function makeBlock(nodeName, text, attrs) {
569
- const element = new Y4.XmlElement(nodeName);
570
- if (attrs) {
571
- for (const [key, value] of Object.entries(attrs)) {
572
- element.setAttribute(key, value);
573
- }
574
- }
575
- if (text.length > 0) {
576
- const xmlText = new Y4.XmlText();
577
- xmlText.insert(0, text);
578
- element.insert(0, [xmlText]);
579
- }
580
- return element;
665
+ writeMarkdownToYFragment(fragment, markdown);
581
666
  }
582
667
 
583
- // src/logger.ts
584
- import * as fs2 from "fs";
585
- import * as path2 from "path";
586
- import * as os from "os";
587
- var COMPOSER_DIR = process.env.COMPOSER_CONFIG_DIR ?? path2.join(os.homedir(), ".composer");
588
- var LOG_FILE = process.env.COMPOSER_LOG_FILE ?? path2.join(COMPOSER_DIR, "mcp.log");
589
- var ensured = false;
590
- function ensureDir() {
591
- if (ensured) return;
592
- try {
593
- fs2.mkdirSync(path2.dirname(LOG_FILE), { recursive: true });
594
- ensured = true;
595
- } catch {
596
- }
597
- }
598
- function write(line) {
599
- ensureDir();
600
- try {
601
- fs2.appendFileSync(LOG_FILE, line);
602
- } catch {
603
- }
604
- try {
605
- process.stderr.write(line);
606
- } catch {
607
- }
608
- }
609
- function log(message, meta) {
610
- const ts = (/* @__PURE__ */ new Date()).toISOString();
611
- const metaStr = meta ? ` ${JSON.stringify(meta)}` : "";
612
- write(`${ts} [composer-mcp] ${message}${metaStr}
613
- `);
614
- }
615
- function logError(message, err) {
616
- const ts = (/* @__PURE__ */ new Date()).toISOString();
617
- const detail = err instanceof Error ? `${err.name}: ${err.message}
618
- ${err.stack ?? ""}` : typeof err === "string" ? err : JSON.stringify(err);
619
- write(`${ts} [composer-mcp] ERROR ${message}
620
- ${detail}
621
- `);
622
- }
623
- var crashHandlersInstalled = false;
624
- function installCrashHandlers() {
625
- if (crashHandlersInstalled) return;
626
- crashHandlersInstalled = true;
627
- process.on("uncaughtException", (err) => {
628
- logError("uncaughtException", err);
629
- process.exit(1);
630
- });
631
- process.on("unhandledRejection", (reason) => {
632
- logError("unhandledRejection", reason);
633
- process.exit(1);
634
- });
635
- process.on("exit", (code) => {
636
- log("process exit", { code });
637
- });
638
- }
639
- var LOG_FILE_PATH = LOG_FILE;
640
-
641
668
  // src/mcp.ts
642
669
  var COMPOSER_DIR2 = process.env.COMPOSER_CONFIG_DIR ?? path3.join(os2.homedir(), ".composer");
643
670
  var SERVER_HOST = process.env.COMPOSER_SERVER_HOST ?? "usecomposer.app";
@@ -1124,9 +1151,7 @@ async function dispatchTool(name, args) {
1124
1151
  return errorResult(`unknown tool: ${name}`);
1125
1152
  }
1126
1153
  }
1127
- async function startMcpServer() {
1128
- installCrashHandlers();
1129
- log("mcp server starting", { pid: process.pid, node: process.version });
1154
+ function buildServer() {
1130
1155
  const server = new Server(
1131
1156
  { name: "composer-mcp", version: "0.0.1" },
1132
1157
  { capabilities: { tools: {} } }
@@ -1160,18 +1185,90 @@ async function startMcpServer() {
1160
1185
  return errorResult(message);
1161
1186
  }
1162
1187
  });
1188
+ return server;
1189
+ }
1190
+ async function startMcpServer() {
1191
+ installCrashHandlers();
1192
+ log("mcp server starting", {
1193
+ pid: process.pid,
1194
+ node: process.version,
1195
+ build: "awareness-heartbeat-v1"
1196
+ });
1197
+ const server = buildServer();
1163
1198
  const transport = new StdioServerTransport();
1164
1199
  await server.connect(transport);
1165
1200
  log("mcp server connected", {
1201
+ transport: "stdio",
1166
1202
  serverHost: SERVER_HOST,
1167
1203
  appBase: APP_BASE,
1168
1204
  logFile: LOG_FILE_PATH,
1169
1205
  pid: process.pid
1170
1206
  });
1171
1207
  }
1208
+ async function startMcpHttpServer(opts) {
1209
+ installCrashHandlers();
1210
+ log("mcp http server starting", {
1211
+ port: opts.port,
1212
+ pid: process.pid,
1213
+ node: process.version,
1214
+ build: "awareness-heartbeat-v1"
1215
+ });
1216
+ const server = buildServer();
1217
+ const transport = new StreamableHTTPServerTransport({
1218
+ sessionIdGenerator: () => randomUUID()
1219
+ });
1220
+ await server.connect(transport);
1221
+ const httpServer = http.createServer(async (req, res) => {
1222
+ try {
1223
+ const url = req.url ?? "";
1224
+ if (url === "/mcp" || url.startsWith("/mcp?") || url.startsWith("/mcp/")) {
1225
+ await transport.handleRequest(req, res);
1226
+ return;
1227
+ }
1228
+ if (url === "/health") {
1229
+ res.writeHead(200, { "Content-Type": "application/json" });
1230
+ res.end(JSON.stringify({ ok: true, serverHost: SERVER_HOST }));
1231
+ return;
1232
+ }
1233
+ res.writeHead(404, { "Content-Type": "text/plain" });
1234
+ res.end("composer-mcp: POST /mcp");
1235
+ } catch (err) {
1236
+ logError("http handler error", err);
1237
+ if (!res.headersSent) {
1238
+ res.writeHead(500, { "Content-Type": "text/plain" });
1239
+ res.end("internal error");
1240
+ } else {
1241
+ res.end();
1242
+ }
1243
+ }
1244
+ });
1245
+ httpServer.listen(opts.port, "127.0.0.1", () => {
1246
+ const url = `http://127.0.0.1:${opts.port}/mcp`;
1247
+ log("mcp http server listening", {
1248
+ url,
1249
+ serverHost: SERVER_HOST,
1250
+ appBase: APP_BASE,
1251
+ logFile: LOG_FILE_PATH,
1252
+ pid: process.pid
1253
+ });
1254
+ console.error(
1255
+ `composer-mcp http listening on ${url}
1256
+ serverHost=${SERVER_HOST}
1257
+ appBase=${APP_BASE}`
1258
+ );
1259
+ });
1260
+ const shutdown = () => {
1261
+ log("mcp http server shutting down");
1262
+ httpServer.close(() => process.exit(0));
1263
+ setTimeout(() => process.exit(0), 500).unref();
1264
+ };
1265
+ process.on("SIGTERM", shutdown);
1266
+ process.on("SIGINT", shutdown);
1267
+ }
1172
1268
 
1173
1269
  export {
1174
- loadOrCreateIdentity,
1175
1270
  logError,
1176
- startMcpServer
1271
+ loadOrCreateIdentity,
1272
+ startMcpServer,
1273
+ startMcpHttpServer
1177
1274
  };
package/dist/cli.js CHANGED
@@ -2,8 +2,9 @@
2
2
  import {
3
3
  loadOrCreateIdentity,
4
4
  logError,
5
+ startMcpHttpServer,
5
6
  startMcpServer
6
- } from "./chunk-UPWB2QWR.js";
7
+ } from "./chunk-PQWVQMLP.js";
7
8
 
8
9
  // src/cli.ts
9
10
  import { execFile } from "child_process";
@@ -13,13 +14,36 @@ import path from "path";
13
14
  import os from "os";
14
15
  import { fileURLToPath } from "url";
15
16
  var exec = promisify(execFile);
17
+ var DEFAULT_HTTP_PORT = 3456;
18
+ function resolveHttpPort() {
19
+ const argv = process.argv.slice(3);
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const arg = argv[i];
22
+ if (arg.startsWith("--port=")) {
23
+ const n = Number(arg.slice("--port=".length));
24
+ if (Number.isFinite(n) && n > 0) return n;
25
+ } else if (arg === "--port" && argv[i + 1]) {
26
+ const n = Number(argv[i + 1]);
27
+ if (Number.isFinite(n) && n > 0) return n;
28
+ }
29
+ }
30
+ const envPort = process.env.COMPOSER_MCP_PORT;
31
+ if (envPort) {
32
+ const n = Number(envPort);
33
+ if (Number.isFinite(n) && n > 0) return n;
34
+ }
35
+ return DEFAULT_HTTP_PORT;
36
+ }
16
37
  async function main() {
17
38
  const cmd = process.argv[2];
18
39
  if (cmd === "mcp") return startMcpServer();
40
+ if (cmd === "http") return startMcpHttpServer({ port: resolveHttpPort() });
19
41
  if (cmd === "setup") return setup();
20
42
  console.log(`composer-mcp
21
43
  setup Register the MCP server with your agent
22
- mcp Run as an MCP server (invoked by the host CLI)`);
44
+ mcp Run as an MCP server over stdio (invoked by the host CLI)
45
+ http Run as an MCP server over HTTP (for local dev + HMR)
46
+ flags: --port N (or COMPOSER_MCP_PORT env; default ${DEFAULT_HTTP_PORT})`);
23
47
  }
24
48
  async function setup() {
25
49
  const dir = path.join(os.homedir(), ".composer-mcp");
package/dist/mcp.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import {
2
+ startMcpHttpServer,
2
3
  startMcpServer
3
- } from "./chunk-UPWB2QWR.js";
4
+ } from "./chunk-PQWVQMLP.js";
4
5
  export {
6
+ startMcpHttpServer,
5
7
  startMcpServer
6
8
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@composer-app/mcp",
3
- "version": "0.0.1-beta.0",
3
+ "version": "0.0.1-beta.1",
4
4
  "description": "Composer MCP",
5
5
  "license": "MIT",
6
6
  "author": "Josh Philpott",
@@ -29,11 +29,14 @@
29
29
  "scripts": {
30
30
  "build": "tsup src/cli.ts src/mcp.ts --format esm --target node20 --out-dir dist --clean",
31
31
  "dev": "tsup src/cli.ts src/mcp.ts --format esm --target node20 --out-dir dist --watch",
32
+ "start:local": "tsx --env-file=.env.local src/cli.ts mcp",
33
+ "start:prod": "tsx --env-file=.env.production src/cli.ts mcp",
34
+ "typecheck": "tsc --noEmit -p .",
32
35
  "test": "vitest run"
33
36
  },
34
37
  "dependencies": {
38
+ "@composer-app/shared": "*",
35
39
  "@modelcontextprotocol/sdk": "^1.0.0",
36
- "marked": "^14.1.0",
37
40
  "nanoid": "^5.1.7",
38
41
  "partysocket": "^1.1.16",
39
42
  "ws": "^8.20.0",
@@ -44,6 +47,7 @@
44
47
  "@types/node": "^24.12.0",
45
48
  "@types/ws": "^8.18.1",
46
49
  "tsup": "^8.3.0",
50
+ "tsx": "^4.21.0",
47
51
  "typescript": "~5.9.3",
48
52
  "vitest": "^4.1.3"
49
53
  }