@eyejack-creator/mcp 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.
Files changed (3) hide show
  1. package/README.md +42 -0
  2. package/dist/index.js +962 -0
  3. package/package.json +35 -0
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @eyejack-creator/mcp
2
+
3
+ EyeJack Creator MCP server — connect **Claude Code / Codex / Cursor** to a live EyeJack editing session. Phase 3 of the agentic roadmap: **read-only** (scene inspection); writes, screenshots, and live sync arrive in later phases.
4
+
5
+ ```
6
+ Claude Code ──stdio──► @eyejack/mcp ──► @eyejack/creator-api ──► AppSync (agent session token)
7
+ ```
8
+
9
+ ## Connect
10
+
11
+ 1. Open your artwork in the EyeJack Creator and press **Connect AI** — it mints a short-lived session token scoped to *that artwork only* and shows the exact command:
12
+
13
+ ```bash
14
+ claude mcp add eyejack \
15
+ --env EYEJACK_SESSION_TOKEN=<token> \
16
+ --env EYEJACK_STAGE=dev \
17
+ -- npx -y @eyejack-creator/mcp
18
+ ```
19
+
20
+ Other MCP clients consume the same `{command, args, env}` triple — Cursor (`.cursor/mcp.json` or Settings → MCP), Claude Desktop (`claude_desktop_config.json`), Codex (`~/.codex/config.toml` `mcp_servers`).
21
+
22
+ 2. Ask away: *"What's in my scene?" · "Which assets aren't placed yet?" · "How is the layout arranged?"*
23
+
24
+ The token lives as long as your editor tab (the tab heartbeats it; closing the tab lets it lapse within minutes). Pressing Connect AI anywhere supersedes the previous session — one active AI session per account.
25
+
26
+ ## Tools (v0)
27
+
28
+ | Tool | Returns |
29
+ |---|---|
30
+ | `get_scene` | Full structured snapshot: metadata, parsed scene document (unity-space, degrees), `sceneRevision`, referenced files |
31
+ | `list_assets` | The artwork's asset files with `usedInScene` resolution |
32
+ | `get_session_status` | Session scope, active state, expiry |
33
+
34
+ Tool descriptions embed the scene-format contract so models handle coordinates/conventions correctly without external docs.
35
+
36
+ ## Develop
37
+
38
+ ```bash
39
+ pnpm --filter @eyejack-creator/mcp build
40
+ # End-to-end smoke (spawns the server over stdio like a real client):
41
+ EYEJACK_SESSION_TOKEN=<token> node examples/smoke-client.mjs
42
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,962 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+
7
+ // ../creator-api/dist/transport.js
8
+ var GraphQLRequestError = class extends Error {
9
+ constructor(message, errors, data) {
10
+ super(message);
11
+ this.errors = errors;
12
+ this.data = data;
13
+ this.name = "GraphQLRequestError";
14
+ }
15
+ /** First AppSync errorType, e.g. 'UnauthorizedException' or 'SceneRevisionConflict'. */
16
+ get errorType() {
17
+ return this.errors[0]?.errorType;
18
+ }
19
+ /** Payload attached via $util.error's data/errorInfo args (may be absent). */
20
+ get errorData() {
21
+ const first = this.errors[0];
22
+ return first?.data ?? first?.errorInfo ?? void 0;
23
+ }
24
+ };
25
+ var TransportError = class extends Error {
26
+ constructor(message, status, bodyText) {
27
+ super(message);
28
+ this.status = status;
29
+ this.bodyText = bodyText;
30
+ this.name = "TransportError";
31
+ }
32
+ };
33
+ var GraphQLClient = class {
34
+ constructor(options) {
35
+ this.endpoint = options.endpoint;
36
+ this.auth = options.auth;
37
+ const impl = options.fetchImpl ?? globalThis.fetch;
38
+ if (!impl) {
39
+ throw new Error("GraphQLClient: no fetch available \u2014 pass fetchImpl (Node < 18?)");
40
+ }
41
+ this.fetchImpl = impl.bind(globalThis);
42
+ }
43
+ /** Execute a query/mutation and return `data` (throws GraphQLRequestError / TransportError). */
44
+ async execute(query, variables) {
45
+ const body = JSON.stringify(variables === void 0 ? { query } : { query, variables });
46
+ const baseHeaders = { "Content-Type": "application/json" };
47
+ const authHeaders = await this.auth.authorize({
48
+ url: this.endpoint,
49
+ method: "POST",
50
+ headers: baseHeaders,
51
+ body
52
+ });
53
+ let response;
54
+ try {
55
+ response = await this.fetchImpl(this.endpoint, {
56
+ method: "POST",
57
+ headers: { ...baseHeaders, ...authHeaders },
58
+ body
59
+ });
60
+ } catch (err) {
61
+ throw new TransportError(`Network error calling ${this.endpoint}: ${err.message}`);
62
+ }
63
+ if (!response.ok) {
64
+ const text = await response.text().catch(() => "");
65
+ throw new TransportError(`HTTP ${response.status} from GraphQL endpoint`, response.status, text);
66
+ }
67
+ const json = await response.json();
68
+ if (json.errors && json.errors.length > 0) {
69
+ const first = json.errors[0];
70
+ throw new GraphQLRequestError(first?.message ?? "GraphQL request failed", json.errors, json.data);
71
+ }
72
+ if (json.data === void 0 || json.data === null) {
73
+ throw new GraphQLRequestError("GraphQL response contained no data", []);
74
+ }
75
+ return json.data;
76
+ }
77
+ };
78
+
79
+ // ../creator-api/dist/realtime.js
80
+ var KA_GRACE_MS = 5 * 6e4;
81
+ var RECONNECT_BASE_MS = 1e3;
82
+ var RECONNECT_MAX_MS = 3e4;
83
+ var AppSyncRealtimeClient = class {
84
+ constructor(options) {
85
+ this.ws = null;
86
+ this.connecting = null;
87
+ this.subs = /* @__PURE__ */ new Map();
88
+ this.nextId = 1;
89
+ this.kaTimeoutMs = KA_GRACE_MS;
90
+ this.kaTimer = null;
91
+ this.reconnectAttempt = 0;
92
+ this.closedByUser = false;
93
+ const url = new URL(options.graphqlEndpoint);
94
+ this.httpHost = url.host;
95
+ this.realtimeUrl = `wss://${url.host.replace("appsync-api", "appsync-realtime-api")}${url.pathname}`;
96
+ this.auth = options.auth;
97
+ const impl = options.WebSocketImpl ?? globalThis.WebSocket;
98
+ if (!impl)
99
+ throw new Error("AppSyncRealtimeClient: no WebSocket available \u2014 pass WebSocketImpl");
100
+ this.WebSocketImpl = impl;
101
+ this.onTransportEvent = options.onTransportEvent ?? (() => {
102
+ });
103
+ }
104
+ subscribe(query, variables, handlers) {
105
+ const id = String(this.nextId++);
106
+ const sub = { id, query, variables, handlers, started: false };
107
+ this.subs.set(id, sub);
108
+ this.ensureConnected().then(() => this.startSub(sub)).catch((err) => handlers.error?.(err));
109
+ return {
110
+ unsubscribe: () => {
111
+ this.subs.delete(id);
112
+ if (this.ws && this.ws.readyState === 1) {
113
+ this.ws.send(JSON.stringify({ id, type: "stop" }));
114
+ }
115
+ if (this.subs.size === 0)
116
+ this.teardown("idle");
117
+ }
118
+ };
119
+ }
120
+ /** Close the socket and stop reconnecting (subscriptions are NOT resumed). */
121
+ close() {
122
+ this.closedByUser = true;
123
+ this.teardown("closed-by-user");
124
+ }
125
+ async authHeader() {
126
+ const headers = await this.auth.authorize({
127
+ url: `https://${this.httpHost}/graphql`,
128
+ method: "WS",
129
+ headers: {},
130
+ body: "{}"
131
+ });
132
+ return { host: this.httpHost, ...headers };
133
+ }
134
+ b64(obj) {
135
+ const json = JSON.stringify(obj);
136
+ const btoaFn = globalThis.btoa;
137
+ if (btoaFn)
138
+ return btoaFn(unescape(encodeURIComponent(json)));
139
+ return Buffer.from(json, "utf8").toString("base64");
140
+ }
141
+ ensureConnected() {
142
+ if (this.ws && this.ws.readyState === 1)
143
+ return Promise.resolve();
144
+ if (this.connecting)
145
+ return this.connecting;
146
+ this.closedByUser = false;
147
+ this.connecting = new Promise((resolve, reject) => {
148
+ (async () => {
149
+ const auth = await this.authHeader();
150
+ const url = `${this.realtimeUrl}?header=${this.b64(auth)}&payload=${this.b64({})}`;
151
+ const ws = new this.WebSocketImpl(url, ["graphql-ws"]);
152
+ this.ws = ws;
153
+ const fail = (err) => {
154
+ this.connecting = null;
155
+ reject(err);
156
+ };
157
+ ws.onopen = () => ws.send(JSON.stringify({ type: "connection_init" }));
158
+ ws.onerror = (e) => {
159
+ this.onTransportEvent("socket-error", e);
160
+ fail(new Error("AppSync realtime socket error"));
161
+ };
162
+ ws.onclose = () => this.handleClose();
163
+ ws.onmessage = (event) => {
164
+ const msg = JSON.parse(String(event.data));
165
+ switch (msg.type) {
166
+ case "connection_ack": {
167
+ this.kaTimeoutMs = msg.payload?.connectionTimeoutMs ?? KA_GRACE_MS;
168
+ this.resetKaWatchdog();
169
+ this.reconnectAttempt = 0;
170
+ this.connecting = null;
171
+ resolve();
172
+ break;
173
+ }
174
+ case "ka":
175
+ this.resetKaWatchdog();
176
+ break;
177
+ case "start_ack":
178
+ break;
179
+ case "data": {
180
+ const sub = msg.id ? this.subs.get(msg.id) : void 0;
181
+ const data = msg.payload?.data;
182
+ if (sub && data !== void 0)
183
+ sub.handlers.next(data);
184
+ break;
185
+ }
186
+ case "error":
187
+ case "connection_error": {
188
+ const sub = msg.id ? this.subs.get(msg.id) : void 0;
189
+ this.onTransportEvent("protocol-error", msg.payload);
190
+ if (sub)
191
+ sub.handlers.error?.(msg.payload);
192
+ else
193
+ fail(new Error(`AppSync realtime error: ${JSON.stringify(msg.payload)}`));
194
+ break;
195
+ }
196
+ case "complete":
197
+ if (msg.id)
198
+ this.subs.delete(msg.id);
199
+ break;
200
+ }
201
+ };
202
+ })().catch((err) => {
203
+ this.connecting = null;
204
+ reject(err);
205
+ });
206
+ });
207
+ return this.connecting;
208
+ }
209
+ async startSub(sub) {
210
+ if (!this.ws || this.ws.readyState !== 1 || sub.started)
211
+ return;
212
+ const auth = await this.authHeader();
213
+ if (!this.subs.has(sub.id))
214
+ return;
215
+ sub.started = true;
216
+ this.ws.send(JSON.stringify({
217
+ id: sub.id,
218
+ type: "start",
219
+ payload: {
220
+ data: JSON.stringify({ query: sub.query, variables: sub.variables ?? {} }),
221
+ extensions: { authorization: auth }
222
+ }
223
+ }));
224
+ }
225
+ resetKaWatchdog() {
226
+ if (this.kaTimer)
227
+ clearTimeout(this.kaTimer);
228
+ this.kaTimer = setTimeout(() => {
229
+ this.onTransportEvent("ka-timeout");
230
+ try {
231
+ this.ws?.close();
232
+ } catch {
233
+ }
234
+ }, this.kaTimeoutMs);
235
+ }
236
+ handleClose() {
237
+ if (this.kaTimer)
238
+ clearTimeout(this.kaTimer);
239
+ this.kaTimer = null;
240
+ this.ws = null;
241
+ this.connecting = null;
242
+ for (const sub of this.subs.values())
243
+ sub.started = false;
244
+ if (this.closedByUser || this.subs.size === 0)
245
+ return;
246
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, RECONNECT_MAX_MS);
247
+ this.reconnectAttempt += 1;
248
+ this.onTransportEvent("reconnecting", { attempt: this.reconnectAttempt, delayMs: delay });
249
+ setTimeout(() => {
250
+ if (this.subs.size === 0)
251
+ return;
252
+ this.ensureConnected().then(() => {
253
+ for (const sub of this.subs.values())
254
+ void this.startSub(sub);
255
+ }).catch((err) => this.onTransportEvent("reconnect-failed", err));
256
+ }, delay);
257
+ }
258
+ teardown(reason) {
259
+ this.onTransportEvent("teardown", reason);
260
+ if (this.kaTimer)
261
+ clearTimeout(this.kaTimer);
262
+ this.kaTimer = null;
263
+ const ws = this.ws;
264
+ this.ws = null;
265
+ this.connecting = null;
266
+ try {
267
+ ws?.close();
268
+ } catch {
269
+ }
270
+ }
271
+ };
272
+
273
+ // ../creator-api/dist/stages.js
274
+ var STAGES = {
275
+ dev: {
276
+ graphqlEndpoint: "https://4mtyioftuncyll6x4xlsaafjt4.appsync-api.us-east-1.amazonaws.com/graphql",
277
+ region: "us-east-1"
278
+ },
279
+ prd: {
280
+ graphqlEndpoint: "https://xarmlusdjfdzjkab4ptvf5yqye.appsync-api.us-east-1.amazonaws.com/graphql",
281
+ region: "us-east-1"
282
+ }
283
+ };
284
+
285
+ // ../creator-api/dist/domains/agent.js
286
+ var CREATE_AGENT_SESSION = `
287
+ mutation CreateAgentSession($artworkId: ID!) {
288
+ createAgentSession(artworkId: $artworkId) {
289
+ sessionId
290
+ token
291
+ artworkId
292
+ expiresAt
293
+ }
294
+ }
295
+ `;
296
+ var AGENT_SESSION_HEARTBEAT = `
297
+ mutation AgentSessionHeartbeat($sessionId: ID!) {
298
+ agentSessionHeartbeat(sessionId: $sessionId) {
299
+ sessionId
300
+ artworkId
301
+ status
302
+ expiresAt
303
+ lastHeartbeat
304
+ }
305
+ }
306
+ `;
307
+ var END_AGENT_SESSION = `
308
+ mutation EndAgentSession($sessionId: ID!) {
309
+ endAgentSession(sessionId: $sessionId) {
310
+ sessionId
311
+ artworkId
312
+ status
313
+ expiresAt
314
+ }
315
+ }
316
+ `;
317
+ var AGENT_GET_SESSION = `
318
+ query AgentGetSession {
319
+ agentGetSession {
320
+ sessionId
321
+ artworkId
322
+ userid
323
+ status
324
+ expiresAt
325
+ lastHeartbeat
326
+ }
327
+ }
328
+ `;
329
+ var AGENT_GET_SCENE = `
330
+ query AgentGetScene {
331
+ agentGetScene {
332
+ artworkId
333
+ name
334
+ status
335
+ sceneConfig
336
+ sceneRevision
337
+ modified
338
+ files {
339
+ type
340
+ path
341
+ checksum
342
+ fileID
343
+ size
344
+ width
345
+ height
346
+ pz
347
+ }
348
+ }
349
+ }
350
+ `;
351
+ var AgentApi = class {
352
+ constructor(client2) {
353
+ this.client = client2;
354
+ }
355
+ // --- Browser-side (Cognito) session lifecycle ---
356
+ async createSession(artworkId) {
357
+ const data = await this.client.execute(CREATE_AGENT_SESSION, { artworkId });
358
+ return data.createAgentSession;
359
+ }
360
+ async heartbeat(sessionId) {
361
+ const data = await this.client.execute(AGENT_SESSION_HEARTBEAT, { sessionId });
362
+ return data.agentSessionHeartbeat;
363
+ }
364
+ async endSession(sessionId) {
365
+ const data = await this.client.execute(END_AGENT_SESSION, { sessionId });
366
+ return data.endAgentSession;
367
+ }
368
+ // --- Agent-side (session token) scoped reads ---
369
+ async getSession() {
370
+ const data = await this.client.execute(AGENT_GET_SESSION);
371
+ return data.agentGetSession;
372
+ }
373
+ async getScene() {
374
+ const data = await this.client.execute(AGENT_GET_SCENE);
375
+ return data.agentGetScene;
376
+ }
377
+ };
378
+
379
+ // ../creator-api/dist/documents/artworks.js
380
+ var ARTWORK_FIELDS = `
381
+ id
382
+ userid
383
+ artworkType
384
+ name
385
+ description
386
+ status
387
+ files {
388
+ type
389
+ path
390
+ checksum
391
+ fileID
392
+ size
393
+ width
394
+ height
395
+ pz
396
+ }
397
+ created
398
+ modified
399
+ downloadsCount
400
+ viewsCount
401
+ enabled
402
+ publishEnabled
403
+ redirectLaunchV3
404
+ ctaLinkUrl
405
+ ctaLinkText
406
+ creatorVersion
407
+ animationHasAlpha
408
+ animationVideoOrientation
409
+ sceneConfig
410
+ sceneRevision
411
+ `;
412
+ var GET_ARTWORK = `
413
+ query GetArtwork($id: ID!) {
414
+ getArtwork(id: $id) {
415
+ ${ARTWORK_FIELDS}
416
+ }
417
+ }
418
+ `;
419
+ var GET_ARTWORKS = `
420
+ query GetArtworks($ids: [ID]) {
421
+ getArtworks(ids: $ids) {
422
+ ${ARTWORK_FIELDS}
423
+ }
424
+ }
425
+ `;
426
+ var GET_ARTWORKS_BY_USERID = `
427
+ query GetArtworksByUserid($userid: ID!, $after: String) {
428
+ getArtworksByUserid(userid: $userid, after: $after) {
429
+ nextToken
430
+ items {
431
+ ${ARTWORK_FIELDS}
432
+ }
433
+ }
434
+ }
435
+ `;
436
+ var CREATE_ARTWORK = `
437
+ mutation CreateArtwork($input: CreateArtworkInput!) {
438
+ createArtwork(input: $input) {
439
+ id
440
+ status
441
+ }
442
+ }
443
+ `;
444
+ var UPDATE_ARTWORK = `
445
+ mutation UpdateArtwork($input: UpdateArtworkInput!) {
446
+ updateArtwork(input: $input) {
447
+ id
448
+ userid
449
+ name
450
+ description
451
+ status
452
+ creatorVersion
453
+ animationHasAlpha
454
+ animationVideoOrientation
455
+ redirectLaunchV3
456
+ modified
457
+ sceneRevision
458
+ sceneConfig
459
+ files {
460
+ type
461
+ path
462
+ checksum
463
+ fileID
464
+ size
465
+ width
466
+ height
467
+ pz
468
+ }
469
+ }
470
+ }
471
+ `;
472
+ var UPDATE_ARTWORK_SCENE = `
473
+ mutation UpdateArtworkScene($input: UpdateArtworkSceneInput!) {
474
+ updateArtworkScene(input: $input) {
475
+ id
476
+ userid
477
+ sceneConfig
478
+ sceneRevision
479
+ modified
480
+ ops
481
+ originId
482
+ }
483
+ }
484
+ `;
485
+ var ON_UPDATE_ARTWORK = `
486
+ subscription OnUpdateArtwork($userid: ID!) {
487
+ onUpdateArtwork(userid: $userid) {
488
+ id
489
+ userid
490
+ name
491
+ description
492
+ status
493
+ publishEnabled
494
+ viewsCount
495
+ downloadsCount
496
+ modified
497
+ sceneConfig
498
+ sceneRevision
499
+ files {
500
+ type
501
+ path
502
+ checksum
503
+ fileID
504
+ size
505
+ width
506
+ height
507
+ pz
508
+ }
509
+ }
510
+ }
511
+ `;
512
+ var ON_ARTWORK_SCENE = `
513
+ subscription OnArtworkScene($id: ID!) {
514
+ onArtworkScene(id: $id) {
515
+ id
516
+ userid
517
+ sceneConfig
518
+ sceneRevision
519
+ modified
520
+ ops
521
+ originId
522
+ }
523
+ }
524
+ `;
525
+ var PUBLISH_ARTWORK = `
526
+ mutation PublishArtwork($id: ID!) {
527
+ publishArtwork(id: $id) {
528
+ id
529
+ status
530
+ }
531
+ }
532
+ `;
533
+ var UNPUBLISH_ARTWORK = `
534
+ mutation UnpublishArtwork($id: ID!) {
535
+ unpublishArtwork(id: $id) {
536
+ id
537
+ status
538
+ }
539
+ }
540
+ `;
541
+ var DELETE_ARTWORK = `
542
+ mutation DeleteArtwork($input: DeleteArtworkInput!) {
543
+ deleteArtwork(input: $input) {
544
+ id
545
+ }
546
+ }
547
+ `;
548
+
549
+ // ../creator-api/dist/domains/artworks.js
550
+ var ArtworksApi = class {
551
+ constructor(client2, realtime) {
552
+ this.client = client2;
553
+ this.realtime = realtime;
554
+ }
555
+ /**
556
+ * Live artwork-record updates for a user (fattened onUpdateArtwork) over the
557
+ * SDK's realtime client. Payload fields are null for writers whose mutation
558
+ * selection didn't include them (e.g. creator-electron) — guard accordingly.
559
+ */
560
+ watchUser(userid, handlers) {
561
+ if (!this.realtime)
562
+ throw new Error("ArtworksApi.watchUser: realtime client not configured");
563
+ return this.realtime().subscribe(ON_UPDATE_ARTWORK, { userid }, {
564
+ next: (data) => {
565
+ if (data?.onUpdateArtwork)
566
+ handlers.next(data.onUpdateArtwork);
567
+ },
568
+ error: handlers.error
569
+ });
570
+ }
571
+ async get(id) {
572
+ const data = await this.client.execute(GET_ARTWORK, { id });
573
+ return data.getArtwork;
574
+ }
575
+ /** Batch fetch by ids (DynamoDB BatchGetItem under the hood). Missing ids are dropped. */
576
+ async getMany(ids) {
577
+ if (ids.length === 0)
578
+ return [];
579
+ const data = await this.client.execute(GET_ARTWORKS, {
580
+ ids
581
+ });
582
+ return (data.getArtworks || []).filter((a) => !!a);
583
+ }
584
+ async listByUser(userid, after) {
585
+ const data = await this.client.execute(GET_ARTWORKS_BY_USERID, { userid, after: after ?? null });
586
+ return data.getArtworksByUserid;
587
+ }
588
+ async create(input) {
589
+ const data = await this.client.execute(CREATE_ARTWORK, { input });
590
+ return data.createArtwork;
591
+ }
592
+ /**
593
+ * Legacy whole-record update — the same mutation the editor has always used.
594
+ * The server bumps sceneRevision whenever input contains a non-null
595
+ * sceneConfig. Fields absent from the input are left untouched; fields
596
+ * explicitly null are REMOVED from the record (VTL semantics) — mirror the
597
+ * legacy payload shape when swapping call sites.
598
+ */
599
+ async update(input) {
600
+ const data = await this.client.execute(UPDATE_ARTWORK, {
601
+ input
602
+ });
603
+ return data.updateArtwork;
604
+ }
605
+ async publish(id) {
606
+ const data = await this.client.execute(PUBLISH_ARTWORK, { id });
607
+ return data.publishArtwork;
608
+ }
609
+ async unpublish(id) {
610
+ const data = await this.client.execute(UNPUBLISH_ARTWORK, { id });
611
+ return data.unpublishArtwork;
612
+ }
613
+ /** Soft delete (status='deleted') via the backend's delete pipeline. */
614
+ async remove(id) {
615
+ const data = await this.client.execute(DELETE_ARTWORK, {
616
+ input: { id }
617
+ });
618
+ return data.deleteArtwork;
619
+ }
620
+ };
621
+
622
+ // ../creator-api/dist/domains/scene.js
623
+ var MAX_DOC_BYTES = 3e5;
624
+ function parseSceneConfig(raw) {
625
+ if (!raw)
626
+ return null;
627
+ let parsed;
628
+ try {
629
+ parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
630
+ } catch {
631
+ return null;
632
+ }
633
+ if (typeof parsed !== "object" || parsed === null)
634
+ return null;
635
+ const doc = parsed;
636
+ if (typeof doc.version !== "number")
637
+ return null;
638
+ if (!Array.isArray(doc.assets) || !Array.isArray(doc.scenes))
639
+ return null;
640
+ return parsed;
641
+ }
642
+ function stringifySceneDocument(doc) {
643
+ return JSON.stringify(doc);
644
+ }
645
+ function validateSceneDocument(doc, files) {
646
+ const issues = [];
647
+ if (typeof doc.version !== "number") {
648
+ issues.push({ level: "error", message: "version must be a number" });
649
+ }
650
+ if (!Array.isArray(doc.scenes) || doc.scenes.length === 0) {
651
+ issues.push({ level: "error", message: "scenes must be a non-empty array (only scenes[0] renders)" });
652
+ }
653
+ if (!Array.isArray(doc.assets)) {
654
+ issues.push({ level: "error", message: "assets must be an array" });
655
+ }
656
+ const root = doc.scenes?.[0];
657
+ if (root) {
658
+ if (!Array.isArray(root.children)) {
659
+ issues.push({ level: "error", message: "scenes[0].children must be an array" });
660
+ } else {
661
+ const fileIds = new Set((files ?? []).map((f) => f.fileID).filter(Boolean));
662
+ const assetIds = new Set((doc.assets ?? []).map((a) => a.assetID));
663
+ root.children.forEach((child, i) => {
664
+ for (const key of ["position", "rotation", "scale"]) {
665
+ const v = child[key];
666
+ if (!Array.isArray(v) || v.length !== 3 || v.some((n) => typeof n !== "number" || !Number.isFinite(n))) {
667
+ issues.push({ level: "error", message: `children[${i}].${key} must be [x,y,z] finite numbers` });
668
+ }
669
+ }
670
+ if (!child.sceneID) {
671
+ issues.push({ level: "error", message: `children[${i}].sceneID missing` });
672
+ }
673
+ if (!child.assetID) {
674
+ issues.push({ level: "error", message: `children[${i}].assetID missing` });
675
+ } else if (files && !fileIds.has(child.assetID) && !assetIds.has(child.assetID)) {
676
+ issues.push({
677
+ level: "warning",
678
+ message: `children[${i}].assetID "${child.assetID}" matches no files[].fileID or assets[].assetID \u2014 the viewer will skip it`
679
+ });
680
+ }
681
+ });
682
+ }
683
+ }
684
+ const bytes = new TextEncoder().encode(stringifySceneDocument(doc)).length;
685
+ if (bytes > MAX_DOC_BYTES) {
686
+ issues.push({
687
+ level: "error",
688
+ message: `document is ${bytes} bytes; max ${MAX_DOC_BYTES} (DynamoDB item headroom)`
689
+ });
690
+ }
691
+ return issues;
692
+ }
693
+ var SceneApi = class {
694
+ constructor(client2, artworks, realtime) {
695
+ this.client = client2;
696
+ this.artworks = artworks;
697
+ this.realtime = realtime;
698
+ }
699
+ /** Live scene channel for one artwork (fat onArtworkScene) over the SDK's realtime client. */
700
+ watch(artworkId, handlers) {
701
+ if (!this.realtime)
702
+ throw new Error("SceneApi.watch: realtime client not configured");
703
+ return this.realtime().subscribe(ON_ARTWORK_SCENE, { id: artworkId }, {
704
+ next: (data) => {
705
+ if (data?.onArtworkScene)
706
+ handlers.next(data.onArtworkScene);
707
+ },
708
+ error: handlers.error
709
+ });
710
+ }
711
+ /** Read the structured snapshot (doc + revision + files) for an artwork. */
712
+ async get(artworkId) {
713
+ const artwork = await this.artworks.get(artworkId);
714
+ if (!artwork)
715
+ return null;
716
+ return {
717
+ artworkId: artwork.id,
718
+ name: artwork.name ?? null,
719
+ doc: parseSceneConfig(artwork.sceneConfig),
720
+ raw: artwork.sceneConfig ?? null,
721
+ revision: artwork.sceneRevision ?? 0,
722
+ files: artwork.files ?? [],
723
+ artwork
724
+ };
725
+ }
726
+ /**
727
+ * Revision-checked save (updateArtworkScene). The write is atomic: it lands
728
+ * only if the caller owns the artwork AND the stored revision equals
729
+ * expectedRevision; otherwise the SDK re-reads and returns a typed conflict
730
+ * for the caller to rebase on. Fans out to onArtworkScene subscribers.
731
+ */
732
+ async save(params) {
733
+ const raw = typeof params.doc === "string" ? params.doc : stringifySceneDocument(params.doc);
734
+ if (typeof params.doc !== "string") {
735
+ const errors = validateSceneDocument(params.doc, params.validateAgainstFiles).filter((i) => i.level === "error");
736
+ if (errors.length > 0) {
737
+ throw new Error(`Refusing to save invalid scene document: ${errors.map((i) => i.message).join("; ")}`);
738
+ }
739
+ }
740
+ try {
741
+ const data = await this.client.execute(UPDATE_ARTWORK_SCENE, {
742
+ input: {
743
+ id: params.artworkId,
744
+ expectedRevision: params.expectedRevision,
745
+ sceneConfig: raw,
746
+ ops: params.ops ?? null,
747
+ originId: params.originId ?? null
748
+ }
749
+ });
750
+ const payload = data.updateArtworkScene;
751
+ return {
752
+ conflict: false,
753
+ revision: payload.sceneRevision,
754
+ modified: payload.modified ?? null,
755
+ payload
756
+ };
757
+ } catch (err) {
758
+ if (err instanceof GraphQLRequestError && err.errorType === "SceneRevisionConflict") {
759
+ const current = await this.artworks.get(params.artworkId).catch(() => null);
760
+ if (!current)
761
+ return { conflict: true, exists: false, currentRevision: null };
762
+ return { conflict: true, exists: true, currentRevision: current.sceneRevision ?? 0 };
763
+ }
764
+ throw err;
765
+ }
766
+ }
767
+ /**
768
+ * Save a scene document through the LEGACY path (updateArtwork — no conflict
769
+ * check; the server still bumps sceneRevision). Kept for un-migrated flows;
770
+ * prefer save() everywhere new.
771
+ */
772
+ async saveLegacy(artworkId, doc, options) {
773
+ const raw = typeof doc === "string" ? doc : stringifySceneDocument(doc);
774
+ if (typeof doc !== "string") {
775
+ const issues = validateSceneDocument(doc, options?.validateAgainstFiles).filter((i) => i.level === "error");
776
+ if (issues.length > 0) {
777
+ throw new Error(`Refusing to save invalid scene document: ${issues.map((i) => i.message).join("; ")}`);
778
+ }
779
+ }
780
+ return this.artworks.update({ id: artworkId, sceneConfig: raw });
781
+ }
782
+ };
783
+
784
+ // ../creator-api/dist/client.js
785
+ function createCreatorClient(options) {
786
+ const endpoint = options.endpoint ?? (options.stage ? STAGES[options.stage].graphqlEndpoint : void 0);
787
+ if (!endpoint) {
788
+ throw new Error("createCreatorClient: provide either `stage` or `endpoint`");
789
+ }
790
+ const graphql = new GraphQLClient({ endpoint, auth: options.auth, fetchImpl: options.fetchImpl });
791
+ let realtimeInstance = null;
792
+ const getRealtime = () => {
793
+ if (!realtimeInstance) {
794
+ realtimeInstance = new AppSyncRealtimeClient({
795
+ graphqlEndpoint: endpoint,
796
+ auth: options.auth,
797
+ WebSocketImpl: options.WebSocketImpl,
798
+ onTransportEvent: options.onRealtimeEvent
799
+ });
800
+ }
801
+ return realtimeInstance;
802
+ };
803
+ const agent = new AgentApi(graphql);
804
+ const artworks = new ArtworksApi(graphql, getRealtime);
805
+ const scene = new SceneApi(graphql, artworks, getRealtime);
806
+ return {
807
+ agent,
808
+ artworks,
809
+ scene,
810
+ graphql,
811
+ endpoint,
812
+ get realtime() {
813
+ return getRealtime();
814
+ }
815
+ };
816
+ }
817
+
818
+ // ../creator-api/dist/auth.js
819
+ var StaticTokenProvider = class {
820
+ constructor(token, headerName = "Authorization") {
821
+ this.token = token;
822
+ this.headerName = headerName;
823
+ }
824
+ async authorize() {
825
+ return { [this.headerName]: this.token };
826
+ }
827
+ };
828
+
829
+ // src/config.ts
830
+ function loadConfig() {
831
+ const token = process.env.EYEJACK_SESSION_TOKEN?.trim();
832
+ if (!token) {
833
+ console.error(
834
+ 'EYEJACK_SESSION_TOKEN is not set.\nOpen your artwork in the EyeJack Creator, press "Connect AI", and use the command it shows.'
835
+ );
836
+ process.exit(1);
837
+ }
838
+ const stage = process.env.EYEJACK_STAGE?.trim() || "dev";
839
+ const endpoint = process.env.EYEJACK_GRAPHQL_ENDPOINT?.trim() || (STAGES[stage] ? STAGES[stage].graphqlEndpoint : void 0);
840
+ if (!endpoint) {
841
+ console.error(`Unknown EYEJACK_STAGE "${stage}" and no EYEJACK_GRAPHQL_ENDPOINT set.`);
842
+ process.exit(1);
843
+ }
844
+ return { token, endpoint, stage };
845
+ }
846
+
847
+ // src/tools.ts
848
+ var SCENE_CONTRACT = `Scene document conventions (the stored EyeJack format):
849
+ - Coordinate space is unity-style left-handed; rotations are in DEGREES.
850
+ - Only scenes[0] is rendered ("root"); its children[] are the panels/models.
851
+ - Each child: { sceneID (stable uuid), assetID, position [x,y,z], rotation [x,y,z], scale [x,y,z] }.
852
+ - A child's assetID must equal a files[].fileID (the asset it displays).
853
+ - files[] entries carry type (image | video | video-alpha | glb | image-target | cover), path (CDN url), width, height.
854
+ - Assets are provisioned by the human in the browser for now \u2014 there is no upload tool yet.
855
+ - sceneRevision is the concurrency counter; every write bumps it (writes arrive in a later phase).`;
856
+ var REJECTED_HINT = 'Session token rejected (expired, ended, or superseded). Ask the user to press "Connect AI" in the EyeJack editor again and update EYEJACK_SESSION_TOKEN.';
857
+ function errorText(err) {
858
+ if (err instanceof TransportError && (err.status === 401 || err.status === 403)) {
859
+ return REJECTED_HINT;
860
+ }
861
+ if (err instanceof GraphQLRequestError) {
862
+ if ((err.errorType || "").includes("Unauthorized")) {
863
+ return REJECTED_HINT;
864
+ }
865
+ return `EyeJack API error: ${err.errorType || err.message}`;
866
+ }
867
+ return `Error: ${err.message}`;
868
+ }
869
+ var asText = (value) => ({
870
+ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }]
871
+ });
872
+ var asError = (err) => ({
873
+ content: [{ type: "text", text: errorText(err) }],
874
+ isError: true
875
+ });
876
+ function registerTools(server2, client2) {
877
+ server2.tool(
878
+ "get_scene",
879
+ `Read the full structured snapshot of the connected EyeJack artwork: metadata, the parsed scene document, the current sceneRevision, and the asset files it references. This session's token is scoped to exactly one artwork \u2014 there is nothing to select. ${SCENE_CONTRACT}`,
880
+ async () => {
881
+ try {
882
+ const record = await client2.agent.getScene();
883
+ const doc = parseSceneConfig(record.sceneConfig);
884
+ return asText({
885
+ artworkId: record.artworkId,
886
+ name: record.name,
887
+ status: record.status,
888
+ sceneRevision: record.sceneRevision ?? 0,
889
+ modified: record.modified,
890
+ scene: doc,
891
+ sceneIsEmpty: !doc || !doc.scenes?.[0]?.children?.length,
892
+ files: (record.files || []).map((f) => ({
893
+ fileID: f.fileID,
894
+ type: f.type,
895
+ path: f.path,
896
+ width: f.width,
897
+ height: f.height
898
+ }))
899
+ });
900
+ } catch (err) {
901
+ return asError(err);
902
+ }
903
+ }
904
+ );
905
+ server2.tool(
906
+ "list_assets",
907
+ "List the asset files (images, videos, 3D models) available on the connected artwork \u2014 the palette the scene's panels can reference via assetID == fileID. Read-only.",
908
+ async () => {
909
+ try {
910
+ const record = await client2.agent.getScene();
911
+ const doc = parseSceneConfig(record.sceneConfig);
912
+ const usedIds = new Set((doc?.scenes?.[0]?.children || []).map((c) => c.assetID));
913
+ return asText(
914
+ (record.files || []).map((f) => ({
915
+ fileID: f.fileID,
916
+ type: f.type,
917
+ width: f.width,
918
+ height: f.height,
919
+ path: f.path,
920
+ usedInScene: f.fileID ? usedIds.has(f.fileID) : false
921
+ }))
922
+ );
923
+ } catch (err) {
924
+ return asError(err);
925
+ }
926
+ }
927
+ );
928
+ server2.tool(
929
+ "get_session_status",
930
+ "Inspect the agent session itself: which artwork it is scoped to, whether it is active, and when it expires (the editor tab heartbeats it while open; closing the tab lets it lapse).",
931
+ async () => {
932
+ try {
933
+ const session = await client2.agent.getSession();
934
+ const now = Math.floor(Date.now() / 1e3);
935
+ return asText({
936
+ artworkId: session.artworkId,
937
+ status: session.status,
938
+ expiresAt: session.expiresAt,
939
+ expiresInSeconds: session.expiresAt ? session.expiresAt - now : null,
940
+ lastHeartbeat: session.lastHeartbeat
941
+ });
942
+ } catch (err) {
943
+ return asError(err);
944
+ }
945
+ }
946
+ );
947
+ }
948
+
949
+ // src/index.ts
950
+ var config = loadConfig();
951
+ var client = createCreatorClient({
952
+ endpoint: config.endpoint,
953
+ auth: new StaticTokenProvider(config.token)
954
+ });
955
+ var server = new McpServer({
956
+ name: "eyejack-creator",
957
+ version: "0.1.0"
958
+ });
959
+ registerTools(server, client);
960
+ var transport = new StdioServerTransport();
961
+ await server.connect(transport);
962
+ console.error(`eyejack-mcp connected (stage: ${config.stage}) \u2014 read-only tools ready.`);
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@eyejack-creator/mcp",
3
+ "version": "0.1.0",
4
+ "description": "EyeJack Creator MCP server — connect Claude Code / Codex / Cursor to a live EyeJack editing session.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "bin": {
9
+ "eyejack-mcp": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.0.0",
23
+ "zod": "^3.23.0"
24
+ },
25
+ "devDependencies": {
26
+ "esbuild": "^0.24.0",
27
+ "typescript": "^5.4.0",
28
+ "@eyejack/creator-api": "0.1.0"
29
+ },
30
+ "scripts": {
31
+ "typecheck": "tsc -p tsconfig.json --noEmit",
32
+ "build": "npm run typecheck && rm -rf dist && esbuild src/index.ts --bundle --platform=node --format=esm --target=node18 --outfile=dist/index.js --external:@modelcontextprotocol/sdk --external:zod --banner:js=\"#!/usr/bin/env node\"",
33
+ "smoke": "node examples/smoke-client.mjs"
34
+ }
35
+ }